From 50ca70c1bb468f0c10bb2b513113e1a0f0316093 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sat, 24 Oct 2026 22:31:19 -0400 Subject: [PATCH 01/97] Cache completed dev builds before compile-time demand discovery --- design.md | 67 ++++-- src/backend/dev/PackFile.zig | 3 + src/cli/main.zig | 269 +++++++++++++++---------- src/cli/pack_store.zig | 53 +++-- src/cli/progress.zig | 7 +- src/cli/test/parallel_cli_runner.zig | 168 ++++++++++++++- src/compile/compile_build.zig | 21 +- src/compile/completed_compilation.zig | 175 ++++++++++++++++ src/compile/coordinator.zig | 178 +++++++++++++++- src/compile/mod.zig | 1 + src/eval/compile_time_finalization.zig | 20 ++ src/lir/checked_pipeline.zig | 2 + 12 files changed, 808 insertions(+), 156 deletions(-) create mode 100644 src/compile/completed_compilation.zig diff --git a/design.md b/design.md index fc12abab573..9ffcad5703b 100644 --- a/design.md +++ b/design.md @@ -2701,21 +2701,46 @@ reference counts. #### Recorded Program Output -A dev build also records its program's runtime output: the Roc object it -linked and the link inputs it read from the lowering (hosted symbols, link -roots and app exports), filed under a key covering every checked module the -program lowers against, with their code-generation keys, the build's output -policy, and a digest of the optimization state (the object packs the program -would load, named by their paths and input keys), inside the store's namespace for the target, compiler and -optimization level. Once a later build's program is checked and before it is -evaluated, the build asks for that key. On a hit the program is evaluated -exactly as a build without the record evaluates it: its specialization decides -the demanded compile-time roots ("Demand-Driven Compile-Time Evaluation"), so -it reports the same diagnostics and evaluates no root a build without the -record would leave alone. No runtime program is lowered, and the recorded -object is linked with the platform's current inputs. A build that reports an error records nothing, and neither -does a program that links Boxy runtime inputs or optimized pack objects, -which the record does not carry. +A specialized native dev build records a completed compilation, an explicit +alternative to a live evaluation/lowering session. After the ordinary frontend +has resolved and content-validated the complete dependency closure, it probes +before platform evaluation or app/platform pairing. The identity covers the +compiler and record format, target/ABI/CPU contract, build output and codegen +policy, specialization strategy, synthetic header policy, source-pin validation +version, app/platform identities, every checked module identity, native source +paths (using the original path of staged app sources and logical module names +for compiler-owned platforms), and the +explicitly selected optimized object stores. Checked identities cover source, +transitive imports and imported assets; resolution and pin validation still run +on every invocation. Neither evaluated-root state nor historical dev-pack +population is an input to this identity. + +The record retains the original build's demanded checked roots, their transitive +reads, literal source/value identities, and observations, followed by the final +normalized Roc object, additional selected optimized objects, resolved target +configuration, hosted symbols, link roots and app exports. Checking owns its +ordinary diagnostics; the completed record owns finalization diagnostics and +compile-time debug observations. Diagnostics retain structured documents and +source identities, so replay uses current source paths and presentation options. +An unused root evaluated by another command never joins the recorded demand. + +An exact hit consumes this finished result: it neither creates a program session +nor marks prepared checked modules finalized. It performs no shared demand +specialization, CTFE, post-CTFE specialization, runtime lowering or object +emission, and does not load procedure packs to reconstruct the result. A miss +performs ordinary evaluation and normalization. Only a compilation with no +semantic errors publishes; warning-only successes are eligible. Boxy builds +are separate and this record never admits Boxy runtime dependencies into LSS. + +The length-prefixed binary record carries an integrity digest, and publication +uses a temporary file and atomic rename. Incompatible, truncated, corrupt or +missing records miss. Additional objects are retained in the same validated +record rather than referenced through scratch paths. Publication failure leaves +the live compilation intact. The record certifies compilation, not a linked +executable: every invocation revalidates current platform link inputs and links +the retained artifacts, even if an output executable already exists. Timings +separately report hits, bytes read/written, publication time and skipped producer +work. ### Demand-Driven Compile-Time Evaluation @@ -2733,6 +2758,10 @@ body it stands in for; each demands the root. The set is computed over the specialized program itself, so it is a function of the checked program and the build's roots, never of what any cache holds. +A completed-compilation hit reuses these semantic decisions and replays their +observations without recomputing demand. This is equivalent to the live path, +not check-style evaluation of every root. + Demand-driven evaluation applies to a compilation whose LSS runtime consumer continues the evaluation's specialization, that runs no tests, and whose checking left no static exhaustiveness diagnostic for compile-time evaluation @@ -24092,8 +24121,12 @@ a constant (static data named by content, a short string as its literal, a construction as its parts), and no runtime program declares a slot, a failure record, or a guard for a compile-time root that completed. -The point at which this holds is explicit: the end of the program's -evaluation (`finalizeProgram`). The specialization evaluation made declared +A completed-compilation record retains only artifacts generated after this +normalization. Reusing those final artifacts preserves the same rule without +reconstructing either specialization or the values they consumed. + +The point at which this holds for a live compilation is explicit: the end of the +program's evaluation (`finalizeProgram`). The specialization evaluation made declared each pending root's read as a slot read, because the value did not exist yet, so when that evaluation completed any compile-time root the program demands, its Solved program is not the one a runtime consumer continues. diff --git a/src/backend/dev/PackFile.zig b/src/backend/dev/PackFile.zig index 02a6826c040..765c85ca820 100644 --- a/src/backend/dev/PackFile.zig +++ b/src/backend/dev/PackFile.zig @@ -622,6 +622,9 @@ pub fn readObjectOwned(owning_arena: collections.SingleThreadArena, bytes: []con }; } +/// Bytes needed to validate the format and read an object pack's input key. +pub const object_input_key_prefix_len = object_magic.len + 4 + 32; + /// The input key in an object pack's header, read without decoding the /// rest; null when the bytes are not an object pack of this format. pub fn objectInputKey(bytes: []const u8) ?[32]u8 { diff --git a/src/cli/main.zig b/src/cli/main.zig index 4885cab4655..6e180009e0e 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -11387,7 +11387,7 @@ fn typeCheckBuildCommand( try finishFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), reporter, build_env.getTimingInfo()); const diag = try build_env.renderDiagnostics(ctx.io.stderr(), ctx.reportConfig(.stderr)); - if (diag.errors == 0) try preparePackageDevPacks(ctx, build_env, null); + if (diag.errors == 0 and !build_env.runtimeOutputCached()) try preparePackageDevPacks(ctx, build_env, null); const total_warning_count = diag.warnings + try optimizedDbgWarningsForBuild(ctx, build_env, args.opt); const resolved_targets_config = build_env.getPlatformTargetsConfig() orelse { try renderProblem(ctx, .{ @@ -11862,7 +11862,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu // A build of a program whose runtime output is recorded links that output // and lowers no runtime program. Debug pack emission reads the lowering. var program_probe: ?ProgramOutputProbe = null; - if (object_store) |store| if (target_arch != .wasm32 and !dev_diagnostics.isSet(.dev_pack_objects)) { + if (object_store) |store| if (target_arch != .wasm32 and args.opt == .dev and runtime_lowering.target.specialization_strategy == .lss and !dev_diagnostics.isSet(.dev_pack_objects)) { program_probe = .{ .arena = ctx.arena, .gpa = ctx.gpa, @@ -11877,10 +11877,9 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu build_env.setDetailedLoweringTiming(args.timings); const diag, const resolved_targets_config = try typeCheckBuildCommand(ctx, args, &build_env, &reporter); - const root_artifact = try checkedArtifactForBuild(ctx, &build_env, args.path); if (build_env.runtimeOutputCached()) { const output = program_probe.?.found.?; - reporter.begin(code_generation_phase_name); + reporter.begin("Restoring Completed Objects"); const build_scratch_dir = createUniqueTempDir(ctx) catch |err| { return ctx.fail(.{ .temp_dir_failed = .{ .err = err } }); }; @@ -11892,9 +11891,17 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu }; reporter.end(); reporter.begin("Preparing Link Inputs"); - try linkNativeBuild(ctx, &reporter, args, target, link_type, final_output_path, platform_dir, resolved_targets_config, build_scratch_dir, obj_path, &.{}, output.facts); + var extra_objects = std.ArrayList([]const u8).empty; + for (output.additional_objects, 0..) |object, index| { + const path = try std.fs.path.join(ctx.arena, &.{ build_scratch_dir, try std.fmt.allocPrint(ctx.arena, "roc_pack_{d}.o", .{index}) }); + backend.writeFileWindowsAvSafe(ctx.io.std_io, path, object) catch return error.NativeCompilationFailed; + try extra_objects.append(ctx.arena, path); + } + recordCompletionCounters(&reporter, &build_env, &program_probe.?, true); + try linkNativeBuild(ctx, &reporter, args, target, link_type, final_output_path, platform_dir, resolved_targets_config, build_scratch_dir, obj_path, extra_objects.items, output.facts); return finishBuildCommand(ctx, args, &build_env, &reporter, final_output_path, diag, timer_start_ns); } + const root_artifact = try checkedArtifactForBuild(ctx, &build_env, args.path); const imported_artifacts = try build_env.collectImportedArtifactViews(ctx.gpa, root_artifact); defer ctx.gpa.free(imported_artifacts); const relation_artifacts = try build_env.collectRelationArtifactViews(ctx.gpa, root_artifact); @@ -11996,6 +12003,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu std.log.err("Native compilation failed: {}", .{err}); return error.NativeCompilationFailed; }; + if (program_probe) |*probe| probe.runtime_emissions += 1; defer written.runtime_tables.deinit(ctx.gpa); const uses_boxy = written.uses_boxy; reporter.endWithBreakdown(&devBackendBreakdown(backend_timing.snapshot())); @@ -12016,13 +12024,13 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu }; // The optimized object packs that served this program's specializations. const linked_objects: []const []const u8 = if (loaded_packs) |packs| try packs.linkedObjects(ctx.arena) else &.{}; - // Boxy runtime inputs are read from the lowered program at link time, and - // linked pack objects from the object cache, so only a program without - // either records its output. - if (program_probe) |*probe| if (probe.key) |key| if (diag.errors == 0 and !uses_boxy and linked_objects.len == 0) { - try probe.record(key, obj_path, link_facts); + // The record retains every selected object. LSS records never carry + // Boxy runtime inputs; those remain exclusive to the ordinary Boxy path. + if (program_probe) |*probe| if (probe.key) |key| if (diag.errors == 0 and !uses_boxy) { + try probe.record(key, obj_path, linked_objects, link_facts, resolved_targets_config); }; + if (program_probe) |*probe| recordCompletionCounters(&reporter, &build_env, probe, false); reporter.begin("Preparing Link Inputs"); var extra_objects = std.array_list.Managed([]const u8).init(ctx.arena); for (linked_objects, 0..) |object, index| { @@ -12125,85 +12133,47 @@ fn linkNativeBuild( reporter.end(); } -/// The runtime output of one exact checked program: its Roc object and the -/// facts linking reads from that program's lowering. +/// Final normalized compiler artifacts and their completed semantic outcome. +/// Linking always validates and consumes the platform's current host inputs. const ProgramOutput = struct { object: []const u8, + additional_objects: []const []const u8 = &.{}, facts: LinkFacts, + outcome: compile.completed_compilation.Outcome = .{}, const LinkFacts = struct { hosted_symbols: []const []const u8, link_root_symbols: []const []const u8, app_export_symbols: []const []const u8, }; - - const magic = "ROCPRGO1"; + const magic = "ROCCOMP1"; + const header_len = magic.len + 32; fn encode(allocator: Allocator, output: ProgramOutput) Allocator.Error![]u8 { - var bytes = std.ArrayList(u8).empty; - errdefer bytes.deinit(allocator); - try bytes.appendSlice(allocator, magic); - try appendBytes(allocator, &bytes, output.object); - inline for (.{ output.facts.hosted_symbols, output.facts.link_root_symbols, output.facts.app_export_symbols }) |names| { - try appendWord(allocator, &bytes, names.len); - for (names) |name| try appendBytes(allocator, &bytes, name); - } - return bytes.toOwnedSlice(allocator); - } - - fn appendWord(allocator: Allocator, bytes: *std.ArrayList(u8), value: usize) Allocator.Error!void { - var word: [8]u8 = undefined; - std.mem.writeInt(u64, &word, value, .little); - try bytes.appendSlice(allocator, &word); + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + var header: [header_len]u8 = @splat(0); + @memcpy(header[0..magic.len], magic); + out.writer.writeAll(&header) catch return error.OutOfMemory; + compile.completed_compilation.write(ProgramOutput, &out.writer, output) catch return error.OutOfMemory; + const bytes = try out.toOwnedSlice(); + std.crypto.hash.Blake3.hash(bytes[header_len..], bytes[magic.len..header_len], .{}); + return bytes; } - fn appendBytes(allocator: Allocator, bytes: *std.ArrayList(u8), value: []const u8) Allocator.Error!void { - try appendWord(allocator, bytes, value.len); - try bytes.appendSlice(allocator, value); - } - - /// The output a record holds, borrowing its bytes; null for a record - /// this compiler did not write. fn decode(arena: Allocator, bytes: []const u8) Allocator.Error!?ProgramOutput { - var reader = Reader{ .bytes = bytes }; - const head = reader.take(magic.len) orelse return null; - if (!std.mem.eql(u8, head, magic)) return null; - const object = reader.slice() orelse return null; - var lists: [3][]const []const u8 = undefined; - for (&lists) |*list| { - const count = reader.word() orelse return null; - if (count > bytes.len) return null; - const names = try arena.alloc([]const u8, count); - for (names) |*name| name.* = reader.slice() orelse return null; - list.* = names; - } - if (reader.offset != bytes.len) return null; - return .{ .object = object, .facts = .{ - .hosted_symbols = lists[0], - .link_root_symbols = lists[1], - .app_export_symbols = lists[2], - } }; - } - - const Reader = struct { - bytes: []const u8, - offset: usize = 0, - - fn take(self: *Reader, len: usize) ?[]const u8 { - if (len > self.bytes.len - self.offset) return null; - defer self.offset += len; - return self.bytes[self.offset..][0..len]; - } - - fn word(self: *Reader) ?usize { - const raw = self.take(8) orelse return null; - return std.math.cast(usize, std.mem.readInt(u64, raw[0..8], .little)); - } - - fn slice(self: *Reader) ?[]const u8 { - return self.take(self.word() orelse return null); - } - }; + if (bytes.len < header_len or !std.mem.eql(u8, bytes[0..magic.len], magic)) return null; + var digest: [32]u8 = undefined; + std.crypto.hash.Blake3.hash(bytes[header_len..], &digest, .{}); + if (!std.mem.eql(u8, &digest, bytes[magic.len..header_len])) return null; + var reader = compile.completed_compilation.Reader{ .bytes = bytes[header_len..], .arena = arena, .remaining = (bytes.len - header_len) *| 4 }; + const result = reader.read(ProgramOutput) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidRecord => return null, + }; + if (reader.offset != reader.bytes.len) return null; + return result; + } }; test "program output records round-trip and reject foreign bytes" { @@ -12226,11 +12196,47 @@ test "program output records round-trip and reject foreign bytes" { try std.testing.expect(try ProgramOutput.decode(arena.allocator(), "ROCPRGO0") == null); } -/// Finds the recorded runtime output of a build's exact checked program. -/// Its key covers every checked module the program lowers against, the -/// build's output policy, and the optimization state (the object packs the -/// program would load); the store's namespace covers the target, compiler -/// and optimization level. +test "program output records preserve observations and reject corrupt artifacts and lengths" { + const gpa = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const key: [32]u8 = @splat(7); + var headline = [_]reporting.DocumentElement{.{ .reflowing_text = "A warning" }}; + var body = [_]reporting.DocumentElement{.{ .source_location = .{ .filename = "/old/main.roc", .line = 3, .column = 2 } }}; + const output: ProgramOutput = .{ + .object = "final normalized object", + .additional_objects = &.{"selected optimized object"}, + .facts = .{ .hosted_symbols = &.{"roc_host"}, .link_root_symbols = &.{"data"}, .app_export_symbols = &.{"main"} }, + .outcome = .{ + .modules = &.{.{ .key = key, .path = "/old/main.roc" }}, + .observations = &.{ "first", "second", "second" }, + .diagnostics = &.{.{ .module = key, .title = "Warning", .severity = .warning, .file = null, .headline = &headline, .body = &body }}, + .targets = .{ .inputs_dir = "targets", .targets = &.{} }, + }, + }; + const bytes = try ProgramOutput.encode(gpa, output); + defer gpa.free(bytes); + const restored = (try ProgramOutput.decode(arena.allocator(), bytes)).?; + try std.testing.expectEqualStrings(output.additional_objects[0], restored.additional_objects[0]); + try std.testing.expectEqual(@as(usize, 3), restored.outcome.observations.len); + try std.testing.expectEqualStrings("second", restored.outcome.observations[2]); + const diagnostic = restored.outcome.diagnostics[0]; + try std.testing.expectEqual(reporting.Severity.warning, diagnostic.severity); + compile.completed_compilation.rebindElements(diagnostic.body, restored.outcome.modules, &.{.{ .key = key, .path = "/new/main.roc" }}); + try std.testing.expectEqualStrings("/new/main.roc", diagnostic.body[0].source_location.filename.?); + for (0..bytes.len) |len| try std.testing.expect(try ProgramOutput.decode(arena.allocator(), bytes[0..len]) == null); + bytes[ProgramOutput.header_len + 9] ^= 1; + try std.testing.expect(try ProgramOutput.decode(arena.allocator(), bytes) == null); + // Even a well-checksummed envelope cannot claim more object bytes than it contains. + @memset(bytes[ProgramOutput.header_len..][0..8], 255); + std.crypto.hash.Blake3.hash(bytes[ProgramOutput.header_len..], bytes[ProgramOutput.magic.len..ProgramOutput.header_len], .{}); + try std.testing.expect(try ProgramOutput.decode(arena.allocator(), bytes) == null); +} + +/// Finds a completed compilation using the content-validated frontend +/// closure before evaluation. Explicit optimization state is relevant; +/// incidental dev packs and evaluated-root state are not. The store namespace +/// covers compiler compatibility, target/ABI/CPU contract and optimization. const ProgramOutputProbe = struct { arena: Allocator, gpa: Allocator, @@ -12240,55 +12246,106 @@ const ProgramOutputProbe = struct { linked_stores: ?*const pack_store.LinkedStores, key: ?[32]u8 = null, found: ?ProgramOutput = null, + bytes_read: usize = 0, + bytes_written: usize = 0, + publication_ns: u64 = 0, + runtime_emissions: u64 = 0, fn runtimeOutputProbe(self: *ProgramOutputProbe) compile.build.RuntimeOutputProbe { return .{ .context = self, .cached = cached }; } - fn cached(context: *anyopaque, root: *const check.CheckedArtifact.CheckedModuleArtifact) Allocator.Error!bool { + fn cached(context: *anyopaque, root: *const check.CheckedArtifact.CheckedModuleArtifact) Allocator.Error!?*const compile.completed_compilation.Outcome { const self: *ProgramOutputProbe = @ptrCast(@alignCast(context)); - const imports = try self.build_env.collectImportedArtifactViews(self.gpa, root); - defer self.gpa.free(imports); - const relations = try self.build_env.collectRelationArtifactViews(self.gpa, root); - defer self.gpa.free(relations); + const coord = self.build_env.coordinator.?; + const modules = try coord.compilationModules(self.gpa); + defer self.gpa.free(modules); var hasher = std.crypto.hash.Blake3.init(.{}); - hasher.update("roc.program-output.v2"); + hasher.update("roc.completed-build.v1"); hasher.update(&self.policy); - if (self.linked_stores) |stores| hasher.update(&try stores.stateDigest(self.build_env, root)); + const pin = coord.source_pin_version orelse ""; + var pin_len: [8]u8 = undefined; + std.mem.writeInt(u64, &pin_len, pin.len, .little); + hasher.update(&pin_len); + hasher.update(pin); hasher.update(&root.key.bytes); - hasher.update(&root.codeGenerationKey().bytes); - inline for (.{ imports, relations }) |views| { - var count: [8]u8 = undefined; - std.mem.writeInt(u64, &count, views.len, .little); - hasher.update(&count); - for (views) |view| { - hasher.update(&view.key.bytes); - hasher.update(&(view.code_generation_key orelse view.key).bytes); - } + hasher.update(&coord.appRootCheckedArtifact().key.bytes); + for (modules) |module| { + hasher.update(&module.key); + // Native crash/debug metadata embeds source paths as codegen input. + var path_len: [8]u8 = undefined; + std.mem.writeInt(u64, &path_len, module.identity_path.len, .little); + hasher.update(&path_len); + hasher.update(module.identity_path); + } + // Only explicitly selected optimized stores affect output. Dev packs, + // checked values and this build's own cache writes do not change it. + if (self.linked_stores) |stores| { + hasher.update(&try stores.stateDigest(self.build_env, root)); + hasher.update(&try stores.stateDigest(self.build_env, coord.appRootCheckedArtifact())); } var key: [32]u8 = undefined; hasher.final(&key); self.key = key; - const bytes = try self.store.readProgram(key, self.arena) orelse return false; + const bytes = try self.store.readProgram(key, self.arena) orelse return null; + self.bytes_read = bytes.len; self.found = try ProgramOutput.decode(self.arena, bytes); - return self.found != null; + const found = if (self.found) |*value| value else return null; + // Every manifest member is validated by the current content-based + // frontend. Equality includes cardinality so a new dependency misses. + if (found.outcome.modules.len != modules.len) return null; + for (found.outcome.modules, modules) |recorded, current| { + if (!std.mem.eql(u8, &recorded.key, ¤t.key)) return null; + } + if (found.outcome.targets == null) return null; + for (found.outcome.diagnostics) |diagnostic| { + if (diagnostic.severity.isError()) return null; + var known = false; + for (modules) |module| if (std.mem.eql(u8, &diagnostic.module, &module.key)) { + known = true; + break; + }; + if (!known) return null; + } + return &found.outcome; } - fn record(self: *ProgramOutputProbe, key: [32]u8, obj_path: []const u8, facts: ProgramOutput.LinkFacts) CliMainError!void { + fn record(self: *ProgramOutputProbe, key: [32]u8, obj_path: []const u8, objects: []const []const u8, facts: ProgramOutput.LinkFacts, targets: roc_target.TargetsConfig) CliMainError!void { + const started = std.Io.Timestamp.now(self.store.roc_ctx.std_io, .awake).nanoseconds; + defer self.publication_ns = @intCast(std.Io.Timestamp.now(self.store.roc_ctx.std_io, .awake).nanoseconds - started); const object = self.store.roc_ctx.readFile(obj_path, self.gpa) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return, }; defer self.gpa.free(object); - const bytes = try ProgramOutput.encode(self.gpa, .{ .object = object, .facts = facts }); + var outcome = self.build_env.coordinator.?.completion_outcome; + outcome.targets = targets; + const bytes = try ProgramOutput.encode(self.gpa, .{ .object = object, .additional_objects = objects, .facts = facts, .outcome = outcome }); defer self.gpa.free(bytes); self.store.writeProgram(key, bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, - error.PackWriteFailed => {}, + error.PackWriteFailed => return, }; + self.bytes_written = bytes.len; } }; +/// These counters count producer work, independently of phase labels. +fn recordCompletionCounters(reporter: *progress.Reporter, build_env: *BuildEnv, probe: *const ProgramOutputProbe, hit: bool) void { + const timing = build_env.coordinator.?.ctfe_timing.snapshot(); + reporter.recordCounters("Completed compilation", &.{ + .{ .name = "Completed result hits", .count = @intFromBool(hit) }, + .{ .name = "Demand specialization passes", .count = timing.specialization_passes - timing.respecialization_passes }, + .{ .name = "Post-CTFE specialization passes", .count = timing.respecialization_passes }, + .{ .name = "Fresh CTFE roots", .count = timing.roots_evaluated }, + .{ .name = "CTFE execution ns", .count = timing.execution_ns }, + .{ .name = "Runtime object emissions", .count = probe.runtime_emissions }, + .{ .name = "Completed bytes read", .count = probe.bytes_read }, + .{ .name = "Completed bytes written", .count = probe.bytes_written }, + .{ .name = "Completed publication ns", .count = probe.publication_ns + build_env.coordinator.?.completion_capture_ns }, + }); +} + /// The build output policy a program's recorded output depends on beyond its /// modules and the store's namespace. fn programOutputPolicy(args: cli_args.BuildArgs, link_type: roc_target.OutputKind) [32]u8 { @@ -12306,6 +12363,16 @@ fn programOutputPolicy(args: cli_args.BuildArgs, link_type: roc_target.OutputKin return digest; } +test "completed compilation policy separates codegen choices from presentation" { + const baseline = programOutputPolicy(.{}, .exe); + try std.testing.expectEqual(baseline, programOutputPolicy(.{ .specialization_strategy = .lss }, .exe)); + try std.testing.expectEqual(baseline, programOutputPolicy(.{ .timings = true, .verbose = true, .output = "elsewhere" }, .exe)); + try std.testing.expect(!std.mem.eql(u8, &baseline, &programOutputPolicy(.{ .specialization_strategy = .boxy }, .exe))); + try std.testing.expect(!std.mem.eql(u8, &baseline, &programOutputPolicy(.{ .debug = true }, .exe))); + try std.testing.expect(!std.mem.eql(u8, &baseline, &programOutputPolicy(.{ .synthetic_default_platform = true }, .exe))); + try std.testing.expect(!std.mem.eql(u8, &baseline, &programOutputPolicy(.{}, .shared))); +} + /// Build a standalone binary with the interpreter and an embedded LIR image. /// This is the primary build path that creates executables or libraries without requiring IPC. fn rocBuildEmbedded(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResult { diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index b0b6ce5ec06..3d5e7433b3e 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -87,12 +87,29 @@ pub const Store = struct { }; } - /// Record the runtime output of the program `key` names. A record - /// already present holds the same output, and is left alone. + /// Atomically publish a completed compilation. Replacing an existing + /// record also repairs corruption after a miss; no partial bytes are visible. pub fn writeProgram(self: *const Store, key: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { const path = try self.programPath(key); defer self.allocator.free(path); - try self.writePath(path, bytes); + const dir = std.fs.path.dirname(path).?; + self.roc_ctx.makePath(dir) catch |err| { + self.reportWriteFailure("create directory", dir, bytes.len, err); + return error.PackWriteFailed; + }; + const temp = self.roc_ctx.writeTempFile(self.allocator, path, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => |write_err| { + self.reportWriteFailure("write staging file", path, bytes.len, write_err); + return error.PackWriteFailed; + }, + }; + defer self.allocator.free(temp); + defer self.roc_ctx.deleteFile(temp) catch {}; + self.roc_ctx.rename(temp, path) catch |err| { + self.reportWriteFailure("publish staging file", temp, bytes.len, err); + return error.PackWriteFailed; + }; } /// The object pack an optimized build of the program whose root module @@ -114,12 +131,12 @@ pub const Store = struct { pub fn optimizedInputKey(self: *const Store, identity: [32]u8) Allocator.Error!?[32]u8 { const path = try self.optimizedPackPath(identity); defer self.allocator.free(path); - const bytes = self.roc_ctx.readFile(path, self.allocator) catch |err| switch (err) { + var prefix: [PackFile.object_input_key_prefix_len]u8 = undefined; + const len = self.roc_ctx.readFileInto(path, &prefix) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return null, }; - defer self.allocator.free(bytes); - return PackFile.objectInputKey(bytes); + return PackFile.objectInputKey(prefix[0..len]); } /// File the optimized pack of root `identity`, replacing the one an @@ -424,9 +441,8 @@ test "object cache write failures preserve quiet behavior and report verbose cau .verbose = verbose, }, .arm64mac, "dev"); defer store.deinit(); - for (0..2) |_| { - try std.testing.expectError(error.PackWriteFailed, store.write(.local, @as([32]u8, @splat(1)), @as([32]u8, @splat(2)), "pack")); - } + try std.testing.expectError(error.PackWriteFailed, store.write(.local, @as([32]u8, @splat(1)), @as([32]u8, @splat(2)), "pack")); + try std.testing.expectError(error.PackWriteFailed, store.writeProgram(@splat(1), "pack")); try std.testing.expectEqual(@as(usize, if (stage == .rename) 2 else 0), capture.deleted); if (verbose) { const cause = switch (stage) { @@ -445,8 +461,17 @@ test "object cache write failures preserve quiet behavior and report verbose cau } test "object cache interleaved writers expose only complete final bytes" { + try testAtomicPublication(false); +} + +test "completed compilation interleaved writers expose only complete final bytes" { + try testAtomicPublication(true); +} + +fn testAtomicPublication(program: bool) !void { const Hook = struct { store: *Store, + program: bool, base: CoreCtx, pending: ?[]const u8 = null, distinct: bool = false, @@ -469,7 +494,9 @@ test "object cache interleaved writers expose only complete final bytes" { self.pending = staging; defer self.pending = null; std.debug.assert(!self.base.fileExists(path)); - self.store.writePath(path, bytes) catch return error.IoError; + if (self.program) { + self.store.writeProgram(@splat(1), bytes) catch return error.IoError; + } else self.store.writePath(path, bytes) catch return error.IoError; const visible = self.base.readFile(path, allocator) catch return error.IoError; defer allocator.free(visible); self.observed_complete = std.mem.eql(u8, visible, bytes); @@ -489,14 +516,14 @@ test "object cache interleaved writers expose only complete final bytes" { const base_ctx = CoreCtx.default(allocator, allocator, io); var store = Store{ .allocator = allocator, .roc_ctx = base_ctx, .root = try allocator.dupe(u8, root), .verbose = false }; defer store.deinit(); - var hook = Hook{ .store = &store, .base = base_ctx }; + var hook = Hook{ .store = &store, .base = base_ctx, .program = program }; store.roc_ctx.ctx = &hook; store.roc_ctx.vtable.writeTempFile = &Hook.writeTempFile; const identity = @as([32]u8, @splat(1)); - try store.write(.local, identity, @as([32]u8, @splat(2)), "whole completed pack"); + if (program) try store.writeProgram(identity, "whole completed compilation") else try store.write(.local, identity, @as([32]u8, @splat(2)), "whole completed pack"); try std.testing.expect(hook.distinct); try std.testing.expect(hook.observed_complete); - const dir_path = try store.identityDir(.local, identity); + const dir_path = if (program) try std.fs.path.join(allocator, &.{ root, "programs" }) else try store.identityDir(.local, identity); defer allocator.free(dir_path); var dir = try std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }); defer dir.close(io); diff --git a/src/cli/progress.zig b/src/cli/progress.zig index 8ea4d441e4f..bb4a3557e6a 100644 --- a/src/cli/progress.zig +++ b/src/cli/progress.zig @@ -52,8 +52,9 @@ const row_marker_width: usize = 4; const max_phases: usize = 16; const max_subphases: usize = 25; // Shared lowering and a runtime continuation each report eight lowering groups -// and native emission; tests also report result-cache counts. -const max_counter_groups: usize = 2 * 9 + 1; +// and native emission; result-cache and completed-compilation counts each +// have an additional group. +const max_counter_groups: usize = 2 * 9 + 2; const max_counters_per_group: usize = 32; /// Rows in the ranked timing list printed after the phases. const max_ranked_timings: usize = 10; @@ -1015,9 +1016,11 @@ test "counter capacity includes shared runtime native and test groups" { // Eight lowering groups plus native emission for each consumer. for (0..18) |_| reporter.recordCounters("Compiler work", &.{.{ .name = "Count", .count = 1 }}); reporter.recordCounters("Final test cache", &.{.{ .name = "Hits", .count = 7 }}); + reporter.recordCounters("Completed compilation", &.{.{ .name = "Hits", .count = 1 }}); reporter.finish(); try testing.expectEqual(@as(usize, 18), std.mem.count(u8, buf.written(), "Compiler work")); try testing.expect(std.mem.find(u8, buf.written(), "Final test cache") != null); + try testing.expect(std.mem.find(u8, buf.written(), "Completed compilation") != null); } test "static breakdown lists every phase with the timings flag" { diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 01463fccade..216a718db42 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -10787,6 +10787,16 @@ fn customCtfeStaticCacheRuntime( return null; } +fn completionCounter(output: []const u8, name: []const u8) ?u64 { + var lines = std.mem.splitScalar(u8, output, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t"); + if (!std.mem.startsWith(u8, trimmed, name)) continue; + return std.fmt.parseInt(u64, std.mem.trim(u8, trimmed[name.len..], " \t"), 10) catch null; + } + return null; +} + /// A dev build records its program's runtime output. A later build of the /// unchanged program links that output without specializing the program, /// and a build of an edited program specializes it again. @@ -10805,8 +10815,10 @@ fn customRecordedProgramOutput( .sources = &.{ "test/cli/ctfe_cache_publication/Closed.roc", "test/cli/ctfe_cache_publication/Stable.roc", "test/cli/ctfe_cache_publication/main.roc" }, .app_name = "main.roc", }, &app)) |failure| return failure; - const source = std.Io.Dir.cwd().readFileAlloc(io, app, allocator, .limited(1024 * 1024)) catch |err| + const original = std.Io.Dir.cwd().readFileAlloc(io, app, allocator, .limited(1024 * 1024)) catch |err| return customInfraFailure(allocator, timer, "failed to read app: {}", .{err}); + const source = std.fmt.allocPrint(allocator, "{s}\nunused : U64\nunused = {{\n expect 1 == 2\n 99\n}}\n", .{original}) catch |err| + return customInfraFailure(allocator, timer, "failed to create unused failing root: {}", .{err}); const edited = std.mem.replaceOwned(u8, allocator, source, "Closed.total(10)", "Closed.total(11)") catch |err| return customInfraFailure(allocator, timer, "failed to edit app: {}", .{err}); const revised = std.mem.replaceOwned(u8, allocator, edited, "expect initial == 45", "expect initial == 55") catch |err| @@ -10825,6 +10837,7 @@ fn customRecordedProgramOutput( .{ .source = source, .specializes = true, .stdout = "45 155\n" }, .{ .source = source, .specializes = false, .stdout = "45 155\n" }, .{ .source = revised, .specializes = true, .stdout = "55 155\n" }, + .{ .source = source, .specializes = false, .stdout = "45 155\n" }, }; for (steps, 0..) |step, index| { std.Io.Dir.cwd().writeFile(io, .{ .sub_path = app, .data = step.source }) catch |err| @@ -10840,16 +10853,161 @@ fn customRecordedProgramOutput( .result => |run| run, .failure => |failure| return failure, }; - const specialized = std.mem.find(u8, run.stdout, "Specializing") != null or std.mem.find(u8, run.stderr, "Specializing") != null; - if (specialized != step.specializes) { - return failureFromRun(allocator, timer, run, if (step.specializes) "a changed program linked recorded output" else "an unchanged program was specialized again"); + const output = std.fmt.allocPrint(allocator, "{s}\n{s}", .{ run.stdout, run.stderr }) catch return customInfraFailure(allocator, timer, "out of memory reading counters", .{}); + const hits = completionCounter(output, "Completed result hits") orelse return failureFromRun(allocator, timer, run, "missing completed compilation counters"); + if (hits != @intFromBool(!step.specializes)) return failureFromRun(allocator, timer, run, "unexpected completed compilation hit count"); + if (index == 0 and (completionCounter(output, "Post-CTFE specialization passes") orelse 0) == 0) return failureFromRun(allocator, timer, run, "cold fixture did not exercise runtime normalization"); + if (!step.specializes) { + for ([_][]const u8{ "Demand specialization passes", "Post-CTFE specialization passes", "Fresh CTFE roots", "CTFE execution ns", "Runtime object emissions" }) |name| { + if (completionCounter(output, name) != 0) return failureFromRun(allocator, timer, run, "completed compilation repeated compiler work"); + } } + if (std.mem.count(u8, run.stderr, "CTFE publication observation") != 1) return failureFromRun(allocator, timer, run, "completed compilation changed debug observation multiplicity"); if (runRawAndCheck(io, allocator, env, timer, timeout_ms, &.{exe}, env.dirs.work_dir, .{ .args = &.{}, .stdout_exact = step.stdout, .stderr_exact = "", })) |failure| return failure; } + const exe = std.fs.path.join(allocator, &.{ env.dirs.work_dir, "completed-relinked" }) catch |err| + return customInfraFailure(allocator, timer, "failed to allocate output: {}", .{err}); + const out_arg = outputArg(allocator, exe) catch |err| + return customInfraFailure(allocator, timer, "failed to allocate argument: {}", .{err}); + // Check and test demand the unused failing root; build never does, even + // after those commands have populated their caches. + for ([_][]const []const u8{ &.{ "check", "--no-color" }, &.{ "test", "--opt=dev", "--no-color" }, &.{ "build", "--opt=dev", "--specialize=no", "--no-color", out_arg } }) |args| { + if (runRocAndCheck(io, allocator, &isolated, timer, timeout_ms, .{ + .args = args, + .roc_file = app, + .exit = .failure, + .contains = &.{.{ .stream = .stderr, .text = "compile time expect failed" }}, + })) |failure| return failure; + } + for ([_][]const []const u8{ &.{ "build", "--opt=dev", "--no-cache", "--no-color", out_arg }, &.{ "build", "--opt=dev", "--specialize=yes", "--no-color", out_arg } }) |args| { + // Replacing the executable must not preserve these unrelated bytes. + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = exe, .data = "stale output" }) catch |err| + return customInfraFailure(allocator, timer, "failed to replace output: {}", .{err}); + if (runRocAndCheck(io, allocator, &isolated, timer, timeout_ms, .{ + .args = args, + .roc_file = app, + .occurrences = &.{.{ .stream = .stderr, .text = "CTFE publication observation", .count = 1 }}, + })) |failure| return failure; + if (runRawAndCheck(io, allocator, env, timer, timeout_ms, &.{exe}, env.dirs.work_dir, .{ .args = &.{}, .stdout_exact = "45 155\n", .stderr_exact = "" })) |failure| return failure; + } + // An edit making the failure demanded must not use the older success. + const failed = std.mem.replaceOwned(u8, allocator, source, "Closed.total(10)", "unused") catch |err| + return customInfraFailure(allocator, timer, "failed to demand failing root: {}", .{err}); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = app, .data = failed }) catch |err| + return customInfraFailure(allocator, timer, "failed to edit source: {}", .{err}); + if (runRocAndCheck(io, allocator, &isolated, timer, timeout_ms, .{ + .args = &.{ "build", "--opt=dev", "--no-color", out_arg }, + .roc_file = app, + .exit = .failure, + .contains = &.{.{ .stream = .stderr, .text = "compile time expect failed" }}, + })) |failure| return failure; + // Warning-only successful compilations remain eligible; the frontend + // reports the warning once even when finalization comes from the record. + const warning = std.mem.replaceOwned(u8, allocator, source, "main! = |args| {", "main! = |args| {\n unused_local = 0") catch |err| + return customInfraFailure(allocator, timer, "failed to create warning: {}", .{err}); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = app, .data = warning }) catch |err| + return customInfraFailure(allocator, timer, "failed to write warning: {}", .{err}); + for (0..2) |index| { + const run = switch (captureRocRun(io, allocator, &isolated, timer, timeout_ms, .{ + .args = &.{ "build", "--opt=dev", "--timings", "--no-color", out_arg }, + .roc_file = app, + .exit = .{ .code = 2 }, + .occurrences = &.{ .{ .stream = .stderr, .text = "unused variable", .count = 1 }, .{ .stream = .stderr, .text = "CTFE publication observation", .count = 1 } }, + })) { + .result => |run| run, + .failure => |failure| return failure, + }; + if (completionCounter(run.stderr, "Completed result hits") != index) return failureFromRun(allocator, timer, run, "warning-only compilation did not reuse completed result"); + } + // A headerless app stages its synthetic platform in a fresh directory. + // Finalization owns this warning; replay must rebind it to the user file. + const headerless = std.fs.path.join(allocator, &.{ std.fs.path.dirname(app).?, "headerless.roc" }) catch |err| + return customInfraFailure(allocator, timer, "headerless path: {}", .{err}); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = headerless, .data = + \\value : Str + \\value = { + \\ dbg "headerless observation" + \\ "hi".concat(" there") + \\} + \\main! = |_args| if value == "hi there" Ok({}) else Err(Exit(1)) + \\ + }) catch |err| return customInfraFailure(allocator, timer, "write headerless: {}", .{err}); + for (0..2) |index| { + const run = switch (captureRocRun(io, allocator, &isolated, timer, timeout_ms, .{ + .args = &.{ "build", "--opt=dev", "--timings", "--no-color", out_arg }, + .roc_file = headerless, + .exit = .{ .code = 2 }, + .occurrences = &.{ .{ .stream = .stderr, .text = "unconditional condition", .count = 1 }, .{ .stream = .stderr, .text = "headerless observation", .count = 1 } }, + })) { + .result => |run| run, + .failure => |failure| return failure, + }; + if (completionCounter(run.stderr, "Completed result hits") != index) return failureFromRun(allocator, timer, run, "staging paths invalidated the completed compilation"); + if (std.mem.find(u8, run.stderr, "headerless.roc") == null) return failureFromRun(allocator, timer, run, "finalization warning lost its user source path"); + if (runRawAndCheck(io, allocator, env, timer, timeout_ms, &.{exe}, env.dirs.work_dir, .{ .args = &.{}, .stdout_exact = "", .stderr_exact = "" })) |failure| return failure; + } + // Transitive source changes and same-size asset edits are content inputs. + // Restore the successful source before exercising their independent keys. + const imports = std.mem.replaceOwned(u8, allocator, source, "import Stable", "import Stable\nimport \"input.txt\" as imported_text : Str\nimport \"input.bin\" as imported_bytes : List(U8)") catch |err| + return customInfraFailure(allocator, timer, "failed to add asset imports: {}", .{err}); + const with_assets = std.mem.replaceOwned(u8, allocator, imports, "runtime = Closed.runtime_total", "Stdout.line!(imported_text)\n Stdout.line!(imported_bytes.len().to_str())\n runtime = Closed.runtime_total") catch |err| + return customInfraFailure(allocator, timer, "failed to read assets: {}", .{err}); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = app, .data = with_assets }) catch |err| + return customInfraFailure(allocator, timer, "failed to write asset fixture: {}", .{err}); + const dir = std.fs.path.dirname(app).?; + const text_path = std.fs.path.join(allocator, &.{ dir, "input.txt" }) catch |err| return customInfraFailure(allocator, timer, "asset path: {}", .{err}); + const binary_path = std.fs.path.join(allocator, &.{ dir, "input.bin" }) catch |err| return customInfraFailure(allocator, timer, "asset path: {}", .{err}); + const closed_path = std.fs.path.join(allocator, &.{ dir, "Closed.roc" }) catch |err| return customInfraFailure(allocator, timer, "import path: {}", .{err}); + const closed = std.Io.Dir.cwd().readFileAlloc(io, closed_path, allocator, .limited(1024 * 1024)) catch |err| return customInfraFailure(allocator, timer, "read import: {}", .{err}); + const closed_edit = std.fmt.allocPrint(allocator, "{s}\n# a changed transitive source\n", .{closed}) catch |err| return customInfraFailure(allocator, timer, "edit import: {}", .{err}); + const asset_steps = [_]struct { text: []const u8, binary: []const u8, import: []const u8, hit: u64 }{ + .{ .text = "one", .binary = "abc", .import = closed, .hit = 0 }, + .{ .text = "one", .binary = "abc", .import = closed, .hit = 1 }, + .{ .text = "two", .binary = "abc", .import = closed, .hit = 0 }, + .{ .text = "two", .binary = "xyz", .import = closed, .hit = 0 }, + .{ .text = "two", .binary = "xyz", .import = closed_edit, .hit = 0 }, + .{ .text = "one", .binary = "abc", .import = closed, .hit = 1 }, + }; + for (asset_steps) |step| { + for ([_][]const u8{ text_path, binary_path, closed_path }, [_][]const u8{ step.text, step.binary, step.import }) |path, data| { + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = data }) catch |err| return customInfraFailure(allocator, timer, "write input: {}", .{err}); + } + const run = switch (captureRocRun(io, allocator, &isolated, timer, timeout_ms, .{ + .args = &.{ "build", "--opt=dev", "--timings", "--no-color", out_arg }, + .roc_file = app, + })) { + .result => |run| run, + .failure => |failure| return failure, + }; + if (completionCounter(run.stderr, "Completed result hits") != step.hit) return failureFromRun(allocator, timer, run, "source/asset content invalidation disagrees"); + const expected = std.fmt.allocPrint(allocator, "{s}\n3\n45 155\n", .{step.text}) catch |err| return customInfraFailure(allocator, timer, "expected output: {}", .{err}); + if (runRawAndCheck(io, allocator, env, timer, timeout_ms, &.{exe}, env.dirs.work_dir, .{ .args = &.{}, .stdout_exact = expected, .stderr_exact = "" })) |failure| return failure; + } + // Corrupt and missing records take the live path and still emit dbg once. + for (0..3) |index| { + if (index == 0) { + var cache_dir = std.Io.Dir.cwd().openDir(io, cache, .{ .iterate = true }) catch |err| return customInfraFailure(allocator, timer, "open cache: {}", .{err}); + defer cache_dir.close(io); + var walker = cache_dir.walk(allocator) catch |err| return customInfraFailure(allocator, timer, "walk cache: {}", .{err}); + defer walker.deinit(); + while (walker.next(io) catch |err| return customInfraFailure(allocator, timer, "walk cache: {}", .{err})) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.path, ".rpo")) cache_dir.writeFile(io, .{ .sub_path = entry.path, .data = "ROCCOMP1 truncated" }) catch |err| return customInfraFailure(allocator, timer, "corrupt record: {}", .{err}); + } + } else if (index == 1) deleteRecordedProgramOutputs(io, allocator, cache) catch |err| return customInfraFailure(allocator, timer, "delete records: {}", .{err}); + const run = switch (captureRocRun(io, allocator, &isolated, timer, timeout_ms, .{ + .args = &.{ "build", "--opt=dev", "--timings", "--no-color", out_arg }, + .roc_file = app, + .occurrences = &.{.{ .stream = .stderr, .text = "CTFE publication observation", .count = 1 }}, + })) { + .result => |run| run, + .failure => |failure| return failure, + }; + if (completionCounter(run.stderr, "Completed result hits") != @intFromBool(index == 2)) return failureFromRun(allocator, timer, run, "corrupt/missing compilation was not repaired"); + } return null; } @@ -11119,7 +11277,7 @@ fn customCtfeCachePublication( .result => |run| run, .failure => |failure| return failure, }; - if (std.mem.count(u8, run.stderr, "[dbg] \"CTFE publication observation\"") != 1) { + if (std.mem.count(u8, run.stderr, "CTFE publication observation") != 1) { return failureFromRun(allocator, timer, run, "command did not preserve its compile-time observation exactly once"); } // Every command files the code its compile-time evaluation ran, diff --git a/src/compile/compile_build.zig b/src/compile/compile_build.zig index a2b99ee5acd..a48b9cc9b5d 100644 --- a/src/compile/compile_build.zig +++ b/src/compile/compile_build.zig @@ -21,11 +21,12 @@ const lir = @import("lir"); const unbundle = if (is_freestanding) struct {} else @import("unbundle"); const CoreCtx = @import("ctx").CoreCtx; -/// Asks a build, once its executable program is checked and before it is -/// evaluated, whether that exact program's runtime output is recorded. +/// Probes a completed compilation after frontend validation, before any +/// platform evaluation or app pairing. The returned outcome is an explicit +/// alternative to a live evaluation session, owned by the build driver. pub const RuntimeOutputProbe = struct { context: *anyopaque, - cached: *const fn (*anyopaque, *const check.CheckedArtifact.CheckedModuleArtifact) Allocator.Error!bool, + cached: *const fn (*anyopaque, *const check.CheckedArtifact.CheckedModuleArtifact) Allocator.Error!?*const @import("completed_compilation.zig").Outcome, }; /// Runtime demand fixed before post-check finalization. Explicit plans may be @@ -522,6 +523,10 @@ pub const BuildEnv = struct { /// Get the TargetsConfig from the platform package, if any. pub fn getPlatformTargetsConfig(self: *const BuildEnv) ?targets_config_mod.TargetsConfig { + if (self.coordinator) |coord| switch (coord.compilation_result) { + .recorded => |outcome| return outcome.targets, + .live => {}, + }; const name = self.selected_platform_package_name orelse return null; const platform = self.packages.get(name) orelse return null; std.debug.assert(platform.kind == .platform); @@ -937,11 +942,11 @@ pub const BuildEnv = struct { self.runtime_output_probe = probe; } - /// Whether checked-program finalization found the program's runtime - /// output recorded and so lowered no runtime program. + /// Whether the driver is consuming a completed compilation instead of a + /// live program session. No module evaluation state is changed by a hit. pub fn runtimeOutputCached(self: *const BuildEnv) bool { const coordinator = self.coordinator orelse return false; - return coordinator.runtime_output_cached; + return coordinator.compilation_result == .recorded; } pub fn setRuntimeLowering(self: *BuildEnv, config: RuntimeLoweringConfig) void { @@ -1343,9 +1348,9 @@ pub const BuildEnv = struct { self.emitAccumulatedReportsForError(); return err; }; - self.executable_artifacts_finalized = coord.hasCheckedProgram(); + self.executable_artifacts_finalized = coord.hasCheckedProgram() and !self.runtimeOutputCached(); - try self.resolvePlatformTargetConfigConstants(); + if (!self.runtimeOutputCached()) try self.resolvePlatformTargetConfigConstants(); try self.emitCoordinatorReports(); diff --git a/src/compile/completed_compilation.zig b/src/compile/completed_compilation.zig new file mode 100644 index 00000000000..e60a1536997 --- /dev/null +++ b/src/compile/completed_compilation.zig @@ -0,0 +1,175 @@ +//! A completed compilation is an alternative to a live evaluation session. +//! Checked input identities are validated before consuming this outcome; no +//! checked module is marked evaluated by replaying it. +const std = @import("std"); +const reporting = @import("reporting"); +const Allocator = std.mem.Allocator; + +pub const Module = struct { + key: [32]u8, + path: []const u8, + identity_path: []const u8 = "", +}; + +pub const Diagnostic = struct { + module: [32]u8, + title: []const u8, + severity: reporting.Severity, + file: ?[]const u8, + headline: []reporting.DocumentElement, + body: []reporting.DocumentElement, +}; + +/// Checked identities survive arena teardown and are independent of the +/// producer's transient Monotype/Solved root positions. +pub const Root = struct { module: [32]u8, root: u32 }; +pub const RootDemand = struct { root: Root, reads: []const Root }; +pub const LiteralDemand = struct { + module: [32]u8, + checked_expr: u32, + kind: @import("lir").LIR.LiteralRejectionKind, + identity: ?[32]u8, + reads: []const Root, +}; +pub const Demand = struct { + evaluated: []const RootDemand, + stored: []const Root, + literals: []const LiteralDemand, +}; + +pub const Outcome = struct { + modules: []const Module = &.{}, + diagnostics: []const Diagnostic = &.{}, + observations: []const []const u8 = &.{}, + demand: []const Demand = &.{}, + targets: ?@import("targets_config.zig").TargetsConfig = null, +}; + +/// Small, versioned records use length-prefixed slices, explicit union tags, +/// and little-endian integers. No padding, pointers or allocator state is +/// persisted. Callers own an arena for decoded slices. +pub fn encode(allocator: Allocator, value: anytype) Allocator.Error![]u8 { + var out: std.Io.Writer.Allocating = .init(allocator); + defer out.deinit(); + write(@TypeOf(value), &out.writer, value) catch return error.OutOfMemory; + return out.toOwnedSlice(); +} + +pub fn write(comptime T: type, out: *std.Io.Writer, value: T) std.Io.Writer.Error!void { + switch (@typeInfo(T)) { + .void => {}, + .bool => try out.writeByte(@intFromBool(value)), + .int => try out.writeInt(T, value, .little), + .@"enum" => try out.writeInt(u64, @backingInt(value), .little), + .optional => |info| { + try out.writeByte(@intFromBool(value != null)); + if (value) |v| try write(info.child, out, v); + }, + .array => |info| for (value) |v| try write(info.child, out, v), + .pointer => |info| { + if (info.size != .slice) @compileError("record contains a process pointer"); + try out.writeInt(u64, value.len, .little); + if (info.child == u8) try out.writeAll(value) else for (value) |v| try write(info.child, out, v); + }, + .@"struct" => |info| inline for (info.field_names, info.field_types) |name, Field| try write(Field, out, @field(value, name)), + .@"union" => |info| { + try write(info.tag_type.?, out, std.meta.activeTag(value)); + switch (value) { + inline else => |v| try write(@TypeOf(v), out, v), + } + }, + else => @compileError("unsupported record field: " ++ @typeName(T)), + } +} + +pub const Reader = struct { + bytes: []const u8, + arena: Allocator, + offset: usize = 0, + /// Each decoded node consumes at least one unit, bounding allocations and + /// recursion by the validated record's size rather than untrusted counts. + remaining: usize, + + pub const Error = Allocator.Error || error{InvalidRecord}; + + pub fn take(self: *Reader, len: usize) Error![]const u8 { + if (len > self.bytes.len - self.offset) return error.InvalidRecord; + defer self.offset += len; + return self.bytes[self.offset..][0..len]; + } + + pub fn read(self: *Reader, comptime T: type) Error!T { + if (self.remaining == 0) return error.InvalidRecord; + self.remaining -= 1; + switch (@typeInfo(T)) { + .void => return {}, + .bool => return switch ((try self.take(1))[0]) { + 0 => false, + 1 => true, + else => error.InvalidRecord, + }, + .int => return std.mem.readInt(T, (try self.take(@sizeOf(T)))[0..@sizeOf(T)], .little), + .@"enum" => { + const value = try self.read(u64); + return std.enums.fromInt(T, value) orelse return error.InvalidRecord; + }, + .optional => |info| return if (try self.read(bool)) try self.read(info.child) else null, + .array => |info| { + var result: T = undefined; + for (&result) |*v| v.* = try self.read(info.child); + return result; + }, + .pointer => |info| { + if (info.size != .slice) @compileError("record contains a process pointer"); + const count = std.math.cast(usize, try self.read(u64)) orelse return error.InvalidRecord; + if (info.child == u8) { + const bytes = try self.take(count); + return if (info.attrs.@"const") bytes else try self.arena.dupe(u8, bytes); + } + if (count > self.remaining) return error.InvalidRecord; + const result = try self.arena.alloc(info.child, count); + for (result) |*v| v.* = try self.read(info.child); + return result; + }, + .@"struct" => |info| { + var result: T = undefined; + inline for (info.field_names, info.field_types) |name, Field| @field(result, name) = try self.read(Field); + return result; + }, + .@"union" => |info| { + const tag = try self.read(info.tag_type.?); + inline for (info.field_names, info.field_types) |name, Field| { + if (@backingInt(tag) == @backingInt(@field(info.tag_type.?, name))) + return @unionInit(T, name, try self.read(Field)); + } + return error.InvalidRecord; + }, + else => @compileError("unsupported record field: " ++ @typeName(T)), + } + } +}; + +/// Rebind typed paths to this invocation's source paths. Prose and dbg text +/// remain language observations, never terminal-rendered output. +pub fn rebindElements(elements: []reporting.DocumentElement, before: []const Module, after: []const Module) void { + for (elements) |*element| switch (element.*) { + .source_location => |*v| v.filename = rebindPath(v.filename, before, after), + .source_code_region => |*v| v.filename = rebindPath(v.filename, before, after), + .source_code_multi_region => |*v| v.filename = rebindPath(v.filename, before, after), + .source_code_with_underlines => |*v| v.display_region.filename = rebindPath(v.display_region.filename, before, after), + .annotated => |*v| if (v.annotation == .path) { + v.content = rebindPath(v.content, before, after).?; + }, + .vertical_stack, .horizontal_concat => |v| rebindElements(v, before, after), + else => {}, + }; +} + +pub fn rebindPath(path: ?[]const u8, before: []const Module, after: []const Module) ?[]const u8 { + const value = path orelse return null; + for (before) |old| { + if (!std.mem.eql(u8, old.path, value)) continue; + for (after) |new| if (std.mem.eql(u8, &old.key, &new.key)) return new.path; + } + return value; +} diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index 997c18acaa5..c8174d79096 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -34,6 +34,7 @@ //! ``` const std = @import("std"); +const completed = @import("completed_compilation.zig"); const builtin = @import("builtin"); const threading = @import("threading.zig"); const can = @import("can"); @@ -765,6 +766,9 @@ pub const ModuleState = struct { external_dependents: std.ArrayList(ModuleRef), /// Diagnostic reports reports: std.ArrayList(Report), + completion_report_start: usize = 0, + /// Diagnostic ownership before app/platform pairing replaces an artifact. + completion_module_key: ?[32]u8 = null, /// Reports already present when type checking began: parsing and /// canonicalization produced them, and their own caches reproduce them. pre_check_report_count: usize = 0, @@ -1462,9 +1466,14 @@ pub const Coordinator = struct { checked_program: bool = false, runtime_lowering: ?compile_build.RuntimeLoweringConfig = null, runtime_output_probe: ?compile_build.RuntimeOutputProbe = null, - /// Set by checked-program finalization when the probe found this exact - /// program's runtime output recorded, so no runtime program was lowered. - runtime_output_cached: bool = false, + /// A completed result owns the semantic outcome independently of checked + /// modules, which can remain prepared when no live finalization is needed. + compilation_result: union(enum) { live, recorded: *const completed.Outcome } = .live, + completion_arena: ?*base.SingleThreadArena = null, + completion_outcome: completed.Outcome = .{}, + completion_capture_ns: u64 = 0, + completion_observations: std.ArrayList([]const u8) = .empty, + completion_demand: std.ArrayList(completed.Demand) = .empty, compile_time_object_cache: ?eval.CompileTimeFinalization.CompileTimeObjectCache = null, /// Keep the code compile-time evaluation ran as artifacts even without /// an object cache to publish it to, so a test can compare it with a @@ -1732,6 +1741,12 @@ pub const Coordinator = struct { } } + if (self.completion_arena) |arena| { + self.completion_demand.deinit(self.gpa); + self.completion_observations.deinit(self.gpa); + arena.deinit(); + self.gpa.destroy(arena); + } if (self.program_session) |*session| session.deinit(); self.program_session = null; for (self.program_literal_values.items) |entry| { @@ -2327,6 +2342,17 @@ pub const Coordinator = struct { try self.checkCancelRequest(); if (self.release_checked_solver_types) try self.releaseCheckedSolverTypes(); self.checked_program = mode == .executable_artifacts and self.appRootChecked(); + if (self.checked_program and self.runtime_lowering != null and !self.hasUserErrors()) { + if (self.runtime_output_probe) |probe| { + if (try probe.cached(probe.context, self.executableRootCheckedArtifact())) |outcome| { + try self.replayCompleted(outcome); + self.compilation_result = .{ .recorded = outcome }; + return; + } + try self.beginCompletion(); + } + } + if (self.checked_program and self.findRootModule(.platform) != null) { const platform_root = self.findRootModule(.platform).?; const platform = platform_root.mod.checkedArtifact().?; @@ -2352,23 +2378,154 @@ pub const Coordinator = struct { const program_root = if (self.checked_program) self.executableRootCheckedArtifact() else null; try self.evaluateProgram(null, program_root); } + if (self.completion_arena != null) try self.captureCompletionReports(); } - /// Evaluate the checked program. A build whose runtime output for exactly - /// this program is recorded evaluates the same compile-time roots a build - /// without the record does, since its program's specialization decides - /// which roots are demanded, and its consumer lowers no runtime program. + /// Evaluate only the live alternative. Recorded compilations return before + /// platform evaluation, pairing and shared demand discovery. fn evaluateProgram( self: *Coordinator, extra_cached: ?*const CheckedArtifact.CheckedModuleArtifact, program_root: ?*const CheckedArtifact.CheckedModuleArtifact, ) CoordinatorError!void { - if (self.runtime_lowering != null) if (program_root) |root| if (self.runtime_output_probe) |probe| { - self.runtime_output_cached = try probe.cached(probe.context, root); - }; return self.evaluatePreparedModules(true, null, extra_cached, program_root); } + /// Full checked closure after resolution and content validation, before any + /// evaluation or app/platform pairing. Physical paths serve diagnostic + /// rebinding; identity paths exclude compiler-owned staging directories. + pub fn compilationModules(self: *Coordinator, allocator: Allocator) Allocator.Error![]completed.Module { + var modules = std.ArrayList(completed.Module).empty; + errdefer modules.deinit(allocator); + var packages = self.packages.iterator(); + while (packages.next()) |entry| { + const pkg = entry.value_ptr.*; + const compiler_source = std.mem.eql(u8, pkg.name, package_identity.synthetic_platform_identity) or compiler_platforms.fromIdentity(pkg.name) != null; + for (pkg.modules.items) |*mod| { + if (mod.checkedArtifact()) |artifact| try modules.append(allocator, .{ + .key = artifact.key.bytes, + .path = mod.path, + // Compiler-owned platform locations are logical names; + // their materialization directory is not a program input. + .identity_path = if (compiler_source) mod.name else self.crashReportPath(mod.path), + }); + } + } + std.mem.sort(completed.Module, modules.items, {}, struct { + fn lessThan(_: void, a: completed.Module, b: completed.Module) bool { + const order = std.mem.order(u8, &a.key, &b.key); + return if (order == .eq) std.mem.lessThan(u8, a.identity_path, b.identity_path) else order == .lt; + } + }.lessThan); + return modules.toOwnedSlice(allocator); + } + + fn beginCompletion(self: *Coordinator) Allocator.Error!void { + const started = std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds; + defer self.completion_capture_ns += @intCast(std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds - started); + const arena = try self.gpa.create(base.SingleThreadArena); + arena.* = base.SingleThreadArena.init(self.gpa); + self.completion_arena = arena; + self.completion_outcome.modules = try self.compilationModules(arena.allocator()); + var packages = self.packages.iterator(); + while (packages.next()) |entry| for (entry.value_ptr.*.modules.items) |*mod| { + mod.completion_report_start = mod.reports.items.len; + mod.completion_module_key = if (mod.checkedArtifact()) |artifact| artifact.key.bytes else null; + }; + } + + fn captureDebug(context: *anyopaque, message: []const u8) Allocator.Error!void { + const self: *Coordinator = @ptrCast(@alignCast(context)); + const started = std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds; + defer self.completion_capture_ns += @intCast(std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds - started); + try self.completion_observations.append(self.gpa, try self.completion_arena.?.allocator().dupe(u8, message)); + } + + fn captureDemand(context: *anyopaque, demand: lir.CheckedPipeline.ComptimeDemand, roots: []const lir.CheckedPipeline.MonotypeRoot, modules: []const CheckedArtifact.ModuleId, literals: []const lir.CheckedPipeline.MonotypeLiteralRoot) Allocator.Error!void { + const self: *Coordinator = @ptrCast(@alignCast(context)); + const started = std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds; + defer self.completion_capture_ns += @intCast(std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds - started); + const allocator = self.completion_arena.?.allocator(); + const evaluated = try allocator.alloc(completed.RootDemand, roots.len); + for (roots, evaluated, 0..) |root, *out, i| out.* = .{ + .root = .{ .module = modules[@backingInt(root.owner)].bytes, .root = @backingInt(root.request.compile_time_root.?) }, + .reads = try copyCompletionRoots(allocator, demand.rootReads(i)), + }; + const literal_demands = try allocator.alloc(completed.LiteralDemand, demand.literals.len); + for (demand.literals, literals, literal_demands) |literal, source, *out| out.* = .{ + .module = source.module.bytes, + .checked_expr = source.site.checked_expr, + .kind = source.site.kind, + .identity = literal.identity, + .reads = try copyCompletionRoots(allocator, demand.reads[literal.reads.start..][0..literal.reads.len]), + }; + try self.completion_demand.append(self.gpa, .{ + .evaluated = evaluated, + .stored = try copyCompletionRoots(allocator, demand.stored), + .literals = literal_demands, + }); + } + + fn copyCompletionRoots(allocator: Allocator, roots: []const lir.CheckedPipeline.ComptimeDemandRoot) Allocator.Error![]const completed.Root { + const result = try allocator.alloc(completed.Root, roots.len); + for (roots, result) |root, *out| out.* = .{ .module = root.module.bytes, .root = @backingInt(root.root) }; + return result; + } + + fn captureCompletionReports(self: *Coordinator) Allocator.Error!void { + const started = std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds; + defer self.completion_capture_ns += @intCast(std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds - started); + const allocator = self.completion_arena.?.allocator(); + var diagnostics = std.ArrayList(completed.Diagnostic).empty; + var packages = self.packages.iterator(); + while (packages.next()) |entry| for (entry.value_ptr.*.modules.items) |*mod| { + const module_key = mod.completion_module_key orelse continue; + for (mod.reports.items[mod.completion_report_start..]) |report| try diagnostics.append(allocator, .{ + .module = module_key, + .title = report.title, + .severity = report.severity, + .file = report.file, + .headline = report.headline.elements.items, + .body = report.document.elements.items, + }); + }; + // Reports move to the build's sink after finalization; retain structured + // copies before their owner can render and release them. + const bytes = try completed.encode(allocator, diagnostics.items); + var reader = completed.Reader{ .bytes = bytes, .arena = allocator, .remaining = bytes.len * 4 }; + self.completion_outcome.diagnostics = reader.read([]completed.Diagnostic) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidRecord => coordinatorInvariant("the completion writer produced an invalid diagnostic record", .{}), + }; + self.completion_outcome.observations = self.completion_observations.items; + self.completion_outcome.demand = self.completion_demand.items; + } + + fn replayCompleted(self: *Coordinator, outcome: *const completed.Outcome) Allocator.Error!void { + const modules = try self.compilationModules(self.gpa); + defer self.gpa.free(modules); + for (outcome.diagnostics) |diagnostic| { + const location = self.checked_artifact_index.get(diagnostic.module) orelse + coordinatorInvariant("validated completion diagnostic omitted its checked module", .{}); + const mod = location.module(); + var report = try Report.init(self.gpa, diagnostic.title, "", diagnostic.severity); + errdefer report.deinit(); + report.title = try report.addOwnedString(diagnostic.title); + // The record's arena outlives all reports emitted by this build. + completed.rebindElements(diagnostic.headline, outcome.modules, modules); + completed.rebindElements(diagnostic.body, outcome.modules, modules); + try report.headline.elements.appendSlice(diagnostic.headline); + try report.document.elements.appendSlice(diagnostic.body); + if (completed.rebindPath(diagnostic.file, outcome.modules, modules)) |path| try report.setFile(path); + try mod.reports.append(self.gpa, report); + } + for (outcome.observations) |message| { + self.roc_ctx.writeStderr("[dbg] ") catch {}; + self.roc_ctx.writeStderr(message) catch {}; + self.roc_ctx.writeStderr("\n") catch {}; + } + } + /// Return `error.Canceled` when the embedder has asked to abandon this build. fn checkCancelRequest(self: *const Coordinator) error{Canceled}!void { const request = self.cancel_request orelse return; @@ -3150,6 +3307,7 @@ pub const Coordinator = struct { options.keep_artifacts = self.compile_time_object_cache != null or self.keep_compile_time_artifacts; options.cached_debug_modules = cached_debug_modules.items; options.defer_debug_replay = !replay_cached_debug; + if (self.completion_arena != null) options.completion_sink = .{ .context = self, .demand = captureDemand, .debug = captureDebug }; var runtime_target: ?lir.CheckedPipeline.TargetConfig = if (self.runtime_lowering) |config| config.target else null; if (runtime_target) |*target| target.post_check_executor = self.postCheckExecutor(); var unfinalized = UnfinalizedReportDestinations{ .coordinator = self, .program_root = root.key.digest() }; diff --git a/src/compile/mod.zig b/src/compile/mod.zig index feb4c163e2a..b87f7cb53b6 100644 --- a/src/compile/mod.zig +++ b/src/compile/mod.zig @@ -5,6 +5,7 @@ const Allocator = std.mem.Allocator; const threading_mod = @import("threading.zig"); pub const package = @import("compile_package.zig"); +pub const completed_compilation = @import("completed_compilation.zig"); pub const build = @import("compile_build.zig"); pub const targets_config = @import("targets_config.zig"); pub const TargetsConfig = targets_config.TargetsConfig; diff --git a/src/eval/compile_time_finalization.zig b/src/eval/compile_time_finalization.zig index 7d98cbc3c3a..266a2cb0cbb 100644 --- a/src/eval/compile_time_finalization.zig +++ b/src/eval/compile_time_finalization.zig @@ -79,6 +79,12 @@ pub const StageSink = struct { comptime_reads: *const lir.CheckedPipeline.SpecComptimeReads, ) Allocator.Error!void, }; +/// Observes the completed semantic decisions while their producer owns them. +pub const CompletionSink = struct { + context: *anyopaque, + demand: *const fn (*anyopaque, lir.CheckedPipeline.ComptimeDemand, []const lir.CheckedPipeline.MonotypeRoot, []const checked.ModuleId, []const lir.CheckedPipeline.MonotypeLiteralRoot) Allocator.Error!void, + debug: *const fn (*anyopaque, []const u8) Allocator.Error!void, +}; /// Runtime options for compile-time finalization. pub const Options = struct { pub const StderrWriter = struct { @@ -99,6 +105,7 @@ pub const Options = struct { stderr: ?StderrWriter = null, event_callback: ?EventCallback = null, debug_events: ?*DebugEvents = null, + completion_sink: ?CompletionSink = null, /// Artifact copies store results independently; shared slots publish once. publish_shared_slots: bool = true, /// A coordinator may persist an early dependency batch, then replay all @@ -1269,6 +1276,7 @@ pub fn finalizeProgram( var quiet_options = options; quiet_options.stderr = null; quiet_options.event_callback = null; + quiet_options.completion_sink = null; quiet_options.keep_artifacts = false; quiet_options.unfinalized_reports = if (options.unfinalized_reports != null) quiet_unfinalized.reports() else null; // The evaluation's run verified the program's restored literal @@ -1278,6 +1286,7 @@ pub fn finalizeProgram( if (quiet_options.object_cache) |*cache| cache.stage_sink = null; var quiet_events = DebugEvents{ .allocator = allocator }; defer quiet_events.deinit(); + if (options.timing) |timing| timing.respecialization_passes.add(1); var respecialized = try specializeAndEvaluate( allocator, quiet_modules, @@ -1506,6 +1515,7 @@ fn specializeAndEvaluate( } if (options.object_cache != null) host_target.spec_comptime_reads = true; try options.checkCancelRequest(); + if (options.timing) |timing| timing.specialization_passes.add(1); var monotype = lir.CheckedPipeline.prepareCheckedModulesMonotype(allocator, lowering_modules, union_roots, host_target) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.HostedFunctionNotBound => finalizationInvariant("compile-time evaluation required every hosted procedure to be bound"), @@ -1518,6 +1528,7 @@ fn specializeAndEvaluate( if (!demand_driven and compile_time_root_count != requested_root_count) finalizationInvariant("compile-time evaluation published a different count of compile-time roots than it requested"); if (options.timing) |timing| timing.recordRoots(demand.?); + if (options.completion_sink) |sink| try sink.demand(sink.context, demand.?, monotype.program.rootsView()[0..compile_time_root_count], monotype.program.view().lowering_modules, monotype.program.view().literal_roots); try published.init(allocator, modules, monotype.program.rootsView()[0..compile_time_root_count], monotype.program.view().lowering_modules); if (demand_driven) if (stored_demand) |stored| try stored.fill(allocator, demand.?.stored); // An LSS runtime consumer continues this Solved program. @@ -2622,6 +2633,8 @@ pub const Timing = struct { store_results_ns: TimingCounter = .{}, /// Compile-time roots the shared program declared, and how many of them /// it published for evaluation. + specialization_passes: TimingCounter = .{}, + respecialization_passes: TimingCounter = .{}, roots_declared: TimingCounter = .{}, roots_evaluated: TimingCounter = .{}, /// Process footprint range observed at burst boundaries. Compile-time @@ -2651,6 +2664,8 @@ pub const Timing = struct { .code_generation_ns = self.code_generation_ns.load(), .execution_ns = self.execution_ns.load(), .store_results_ns = self.store_results_ns.load(), + .specialization_passes = self.specialization_passes.load(), + .respecialization_passes = self.respecialization_passes.load(), .roots_declared = self.roots_declared.load(), .roots_evaluated = self.roots_evaluated.load(), .mem_min = self.mem_min.load(), @@ -2673,6 +2688,8 @@ pub const Timing = struct { self.code_generation_ns.add(snapshot_value.code_generation_ns); self.execution_ns.add(snapshot_value.execution_ns); self.store_results_ns.add(snapshot_value.store_results_ns); + self.specialization_passes.add(snapshot_value.specialization_passes); + self.respecialization_passes.add(snapshot_value.respecialization_passes); self.roots_declared.add(snapshot_value.roots_declared); self.roots_evaluated.add(snapshot_value.roots_evaluated); if (snapshot_value.mem_min != std.math.maxInt(u64)) self.mem_min.min(snapshot_value.mem_min); @@ -2727,6 +2744,8 @@ pub const TimingSnapshot = struct { code_generation_ns: u64 = 0, execution_ns: u64 = 0, store_results_ns: u64 = 0, + specialization_passes: u64 = 0, + respecialization_passes: u64 = 0, roots_declared: u64 = 0, roots_evaluated: u64 = 0, mem_min: u64 = std.math.maxInt(u64), @@ -5610,6 +5629,7 @@ fn reportDebugMessage(allocator: Allocator, options: Options, module: checked.Mo } fn emitDebugMessage(allocator: Allocator, options: Options, is_repl: bool, message: []const u8) Allocator.Error!void { + if (options.completion_sink) |sink| try sink.debug(sink.context, message); if (is_repl) { if (options.event_callback) |callback| callback.notify(callback.context, .{ .dbg = message }); } diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index 95db915bb09..d1f97235100 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -1446,6 +1446,8 @@ pub const ComptimeRootRead = postcheck.Common.ComptimeRootRead; pub const ComptimeDemandRoot = postcheck.Monotype.Solve.EntryRoot; /// A root of a prepared Monotype program. pub const MonotypeRoot = postcheck.Monotype.Ast.Root; +/// Source identity of a literal demand, before the producer is released. +pub const MonotypeLiteralRoot = postcheck.Monotype.Ast.LiteralRoot; /// Lower checked modules and explicit roots directly into an ARC-ready LIR program. pub fn lowerCheckedModulesToLir( From 3f013f770cb71fb5a32a6ad189673cdb0a34a4af Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sat, 24 Oct 2026 22:32:02 -0400 Subject: [PATCH 02/97] Index dev specialization offers and load artifact payloads lazily --- design.md | 126 ++++- src/backend/dev/PackFile.zig | 8 +- src/cli/main.zig | 177 +++---- src/cli/pack_index.zig | 206 ++++++++ src/cli/pack_store.zig | 672 ++++++++++++++++++++++++++- src/cli/test/parallel_cli_runner.zig | 16 +- src/compile/cache_cleanup.zig | 8 +- test/cli/pack_index_bench.py | 143 ++++++ 8 files changed, 1193 insertions(+), 163 deletions(-) create mode 100644 src/cli/pack_index.zig create mode 100644 test/cli/pack_index_bench.py diff --git a/design.md b/design.md index fc12abab573..b11b8506b98 100644 --- a/design.md +++ b/design.md @@ -646,22 +646,107 @@ and source frames from it. ### Object Cache Store -Packs live under `//objects-v/ --///.rpk`, and the pack an -optimized build files for its program, which each optimized build of the -program replaces, under `-/optimized/.rpk`. Every -other pack's path is a function of its content, so a writer stages it beside its path in a file it -alone created (`CoreCtx.writeTempFile`), so two writers never share a partly -written file, renames it into place, and leaves a pack already present alone; builds with runtime -safety compare the bytes they would have written with the pack in place. The -background sweep (`cache_cleanup.sweep`) deletes every `objects-v*` namespace -of a format other than the compiler's own, ages the current namespace's -`local/` packs (rewritten by every edit) and recorded program outputs -(`programs/`, see "Recorded Program Output") on one day and its `pkg/` and -`optimized/` packs on thirty, by last use (`max(atime, mtime)`), and then deletes the least -recently used persistent cache files of every family until the cache fits -`CacheConfig.max_size_mb`. Eviction only deletes files; it never changes what -a build writes. +The store namespace is `//objects-v/ +-/`. Compiler compatibility, target (including libc/CPU domain), +optimization policy, specialization strategy and schema isolation remain the +existing explicit key and namespace contracts. A specialized consumer never +acquires Boxy code: offer production rejects closures that depend on the +program's runtime descriptor tables, and indexed metadata admission rejects +runtime references too. The index does not broaden any target compatibility. + +Dev offers live under `//offers//.rpi`. The versioned bespoke `ROFFER01` record contains a +little-endian artifact count, one 32-byte payload digest per artifact, and a +payload-free `PackFile` encoding of the producer's explicit dependency +closure and its one specialization entry. This preserves procedure identities, +set-local and symbolic dependencies, relocations, source/debug mappings, +failure meanings, compile-time root reads and every ARC signature/uniqueness +field returned in `SpecCacheHit`. Code and static-data bytes are excluded. +Content names are computed from the original producer-owned data before +separating payloads; neither the writer nor the reader reconstructs them from +stripped metadata. Closure order preserves the original set's first-definition +rule for symbolic references. +The closure includes available symbolic definitions before the consumer's +final procedure set is known. Splicing still lets the current program supply +its own symbolic callees; pinning the producer's closure guarantees availability +for the definitions it does use without consulting other historical offers. + +Each artifact's code and carried data bytes live separately at +`payload/.rpa` under the same module directory. `RPAYLD01` stores +length-prefixed code and data slices in metadata order, with 64-bit +little-endian lengths. One record is one artifact, including its carried data; +reading a chosen artifact does not read unrelated procedures from its original +pack. Offers can share payloads. Metadata duplicates closure descriptions across +specialization keys; it does not duplicate their code/data in memory. An offer +has no process-local addresses or implicit filesystem dependencies. + +`PackOffers` owns eligibility and specialization metadata. While its artifact +set still exists, it publishes each required immutable payload through a unique +staging file and atomic rename, then the content-named offer, and finally a +`ROCINDEX01` completion marker at `.rpd`. This marker records pack +preparation, not a lookup manifest. Each distinct offer has its own immutable +path, so concurrent writers add different offers without a shared manifest or +lost updates. Equal offers have equal bytes. Republishing can repair corrupt +optional records. Interrupted writers can leave unreferenced payloads, but never +make a partially written offer visible. Legacy namespaces are invalidated by +the pack format bump; no historical-directory migration scan is needed. + +`LoadedPacks` registers visible module directories without opening their +historical files. Lookup visits only the requested specialization's bucket, +preserving the checked program's provider order (root first, then its visible +modules), and ordering candidates within each provider by their content digest. +An ineligible candidate cannot hide a later eligible candidate. Before loading +payloads it validates metadata integrity and compatibility with the existing +`reachingAbsentFiles` and `reachingIdentities` rules. In particular, a site-named +symbolic callee is supplied by the current program, so its historical source +version does not invalidate its caller. Linked objects retain their established +precedence and exact-path selection. The completed-result probe does not +consult the dev offer index or include unrelated history in its key. It defers +runtime and compile-time procedure lookups until its result is known: a miss +enables indexed reuse, while a hit leaves demand evaluation using source bodies +and cached compile-time values, preserving all required diagnostics without +reading procedure offers. A completed-result hit also needs no package-pack +preparation. + +Both exact warm lookup (when the completed-result cache misses) and cross-edit +lookup perform O(M) keyed directory probes for M visible modules. Only the K +candidates for the requested key are enumerated, with O(K log K) filename +sorting and metadata reads through the first eligible, available candidate; +unrelated historical keys do not increase metadata bytes read. Candidate +metadata and sorting grow with duplicates of that key, reported separately +from code/data. A repeated eligible lookup uses the in-memory specialization +map and opens no files. Payload bytes and allocations grow only with the +selected explicit dependency closures, not accumulated source history. + +Before returning a body-elision hit, the loader reads and hashes every required +payload and retains its bytes. Missing, truncated, incompatible or corrupt +metadata/payloads are optional cache misses; no promised code can subsequently +disappear through eviction. Successfully read payloads are memoized by digest +and shared by all selected offers until the loader is destroyed. Decoded +metadata also remains alive for every borrower. Source edits can retain and +reuse old code when the existing key and source/debug compatibility rules +permit it; a stale source location is never accepted just because bytes match. +Retaining history is independent of eagerly reading it. + +Optimized object packs retain their existing locations: a package artifact's +`.rpk` and the program's `optimized/.rpk`. Explicit +pack export/import for developer diagnostics still uses complete `PackFile` +records. The background sweep deletes obsolete `objects-v*` namespaces and +recursively ages current `local/` files and recorded `programs/` outputs on one +day, and `pkg/` and `optimized/` files on thirty, using `max(atime, mtime)`. +Size eviction also includes indexed metadata and payloads. Either may be +removed first: a stale reference is detected before a hit, and live readers own +their bytes. Unused historical payloads remain available until eviction. + +`ROC_PACK_STATS`, through the existing gated `base.dev_diagnostics` interface, +reports metadata bytes, candidate/duplicate counts, payload open attempts, +unique successfully retained payload records, payload bytes, decoded records, +requests/hits and missing/corrupt/source/linked rejection counts. Publication +also reports metadata/payload bytes and elapsed wall-clock nanoseconds. +`test/cli/pack_index_bench.py` supplies the focused synthetic-history benchmark. Normal builds +print none of these diagnostics. Performance comparisons use a ReleaseFast +compiler with developer diagnostics enabled, `roc build --opt=dev`, wall time +and peak RSS; cold publication is measured separately from lookup. A pack entry's code is a function of the procedure alone: every reference from it into code placed with it, and every reference by identity to code @@ -765,10 +850,11 @@ ordinary dev pack policy; it neither writes local-module packs nor starts background optimization. Existing packs are left alone, and missing or evicted packs are rebuilt from the checked modules. A command with errors or `--no-cache` prepares none. -New packs are also offered directly to the command's cache providers, so -runtime lowering can use them even if compile-time evaluation already loaded -the store. Decoded packs retain stable addresses as new offers arrive; artifacts -already handed to evaluation remain valid. +Newly published index directories are registered with the command's cache +providers, so runtime lowering sees the new offers even if compile-time +evaluation already registered the store. Selected metadata and payloads retain +stable addresses as new offers arrive; artifacts already handed to evaluation +remain valid. The wasm32 dev backend cannot splice dev code, so a wasm32 dev build reads and writes no dev packs and takes only object packs' entries diff --git a/src/backend/dev/PackFile.zig b/src/backend/dev/PackFile.zig index 02a6826c040..f6145a2ccd4 100644 --- a/src/backend/dev/PackFile.zig +++ b/src/backend/dev/PackFile.zig @@ -23,7 +23,7 @@ const Allocator = std.mem.Allocator; const magic = "RPCK"; /// Format version; bump whenever the encoding or artifact contents change. -pub const format_version: u32 = 12; +pub const format_version: u32 = 13; /// One specialization the pack can serve: its reservation-time key, the /// artifact holding its procedure, and the ownership signature and @@ -67,6 +67,12 @@ pub const ReadError = Allocator.Error || error{ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const SpecEntry) Allocator.Error![]u8 { var names = try ProcArtifact.ContentNames.init(allocator, set); defer names.deinit(); + return writeWithNames(allocator, set, specs, &names); +} + +/// Encode producer-owned metadata using the content names computed before +/// code/data bytes are separated into independently addressable records. +pub fn writeWithNames(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const SpecEntry, names: *const ProcArtifact.ContentNames) Allocator.Error![]u8 { var bytes = std.ArrayList(u8).empty; errdefer bytes.deinit(allocator); var writer = Writer{ .allocator = allocator, .bytes = &bytes }; diff --git a/src/cli/main.zig b/src/cli/main.zig index 4885cab4655..35ff8acd2a1 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -358,7 +358,7 @@ const CompileTimePackParts = struct { const root_artifact = root_semantic.checked_artifact orelse return null; const placement = build_env.packPlacementForArtifactKey(root_artifact.key) orelse return null; const key = pack_store.compileTimePackKey(root_artifact.codeGenerationKey().bytes, publisher); - if (try cache.store.has(placement.origin, placement.identity, key)) return null; + if (try cache.store.hasIndexed(placement.origin, placement.identity, key)) return null; return .{ .placement = placement, .key = key }; } @@ -8543,7 +8543,7 @@ fn writePackageDevPacksToStore( std.debug.print("dev pack candidate {s} ({s})\n", .{ artifact.canonical_names.moduleNameText(artifact.module_identity.module_name), @tagName(placement.origin) }); } if (placement.origin != .pkg) continue; - if (try store.has(placement.origin, placement.identity, artifact.codeGenerationKey().bytes)) continue; + if (try store.hasIndexed(placement.origin, placement.identity, artifact.codeGenerationKey().bytes)) continue; const roots = try lir.PackProgram.closedExportRoots(ctx.gpa, artifact); defer ctx.gpa.free(roots); if (roots.len == 0) continue; @@ -8553,20 +8553,11 @@ fn writePackageDevPacksToStore( var pack = try compileModulePack(ctx, build_env, root_artifact, imports, relations, artifact, roots, .dev, .lss, RocTarget.detectNative(), false); defer pack.deinit(); const set = &pack.compiled.artifacts.?; - const bytes = try packFileBytes(ctx.gpa, set, &pack.lowered); - defer ctx.gpa.free(bytes); - // Offer before filing: a provider whose first lookup happens here - // loads the old store, then adds exactly the pack just compiled. + try publishDevPack(store, placement.origin, placement.identity, artifact.codeGenerationKey().bytes, set, &pack.lowered); for ([_]?*pack_store.LoadedPacks{ offers, runtime_packs }) |maybe_packs| { const packs = maybe_packs orelse continue; - packs.offerPack(bytes) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => return error.NativeCompilationFailed, - }; + try packs.offerIndexed(store, placement.origin, placement.identity); } - store.write(placement.origin, placement.identity, artifact.codeGenerationKey().bytes, bytes) catch |err| { - std.log.warn("object cache could not store a package's dev pack: {}", .{err}); - }; } } @@ -8609,13 +8600,7 @@ fn writePacksToStore( } if (app_artifacts) |set| { if (build_env.packPlacementForArtifactKey(root_artifact.key)) |placement| { - if (!try store.has(placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes)) { - const bytes = try packFileBytes(ctx.gpa, set, app_lowered); - defer ctx.gpa.free(bytes); - store.write(placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes, bytes) catch |err| { - std.log.warn("object cache could not store the program's pack: {}", .{err}); - }; - } + try publishDevPack(store, placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes, set, app_lowered); } } // An app module is never imported, so its procedures only ever link into @@ -8634,7 +8619,7 @@ fn writePacksToStore( const placement = build_env.packPlacementForArtifactKey(artifact.key) orelse continue; const origin = placement.origin; const identity = placement.identity; - if (try store.has(origin, identity, artifact.codeGenerationKey().bytes)) continue; + if (try store.hasIndexed(origin, identity, artifact.codeGenerationKey().bytes)) continue; const roots = try lir.PackProgram.closedExportRoots(ctx.gpa, artifact); defer ctx.gpa.free(roots); if (roots.len == 0) continue; @@ -8644,11 +8629,7 @@ fn writePacksToStore( var pack = try compileModulePack(ctx, build_env, root_artifact, app_imports, app_relations, artifact, roots, args.opt, currentRuntimeSpecializationStrategy(args.specialization_strategy), target, false); defer pack.deinit(); const set = &(pack.compiled.artifacts orelse continue); - const bytes = try packFileBytes(ctx.gpa, set, &pack.lowered); - defer ctx.gpa.free(bytes); - store.write(origin, identity, artifact.codeGenerationKey().bytes, bytes) catch |err| { - std.log.warn("object cache could not store a module's pack: {}", .{err}); - }; + try publishDevPack(store, origin, identity, artifact.codeGenerationKey().bytes, set, &pack.lowered); } } @@ -9239,12 +9220,7 @@ fn publishCompileTimePack(ctx: *CliCtx, build_env: *BuildEnv) Allocator.Error!vo // its own program, and the pack offers the procedures of all of them. var combined = try completed.combinedArtifacts(ctx.gpa); defer combined.deinit(); - const bytes = try parts.offers.encode(ctx.gpa, &combined); - defer ctx.gpa.free(bytes); - if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("compile-time pack {d} bytes\n", .{bytes.len}); - store.write(destination.placement.origin, destination.placement.identity, destination.key, bytes) catch |err| { - std.log.warn("object cache could not store the compile-time pack: {}", .{err}); - }; + try parts.offers.publish(store, destination.placement.origin, destination.placement.identity, destination.key, &combined); } /// File the discovery summaries of a compilation that reported no error beside @@ -9301,6 +9277,14 @@ fn packFileBytes( return offers.encode(allocator, set); } +fn publishDevPack(store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set, lowered: *const lir.CheckedPipeline.LoweredProgram) Allocator.Error!void { + const reads = if (lowered.spec_comptime_reads) |*reads| reads else base.invariant("a dev pack producer recorded compile-time reads", .{}); + var offers = PackOffers.init(store.allocator, false); + defer offers.deinit(); + try offers.addProgram(lowered, reads); + try offers.publish(store, origin, identity, key, set); +} + /// The procedures a pack may offer, gathered from the programs it is written /// from, all lowered from one Solved program. Everything an offer needs from /// its program's code is decided and copied here, so the offers outlive that @@ -9446,9 +9430,9 @@ const PackOffers = struct { /// Encode `set`, compiled from the gathered programs, with the spec table /// of every procedure they offer. - fn encode(self: *const PackOffers, allocator: Allocator, set: *const backend.dev.ProcArtifact.Set) Allocator.Error![]u8 { + fn entries(self: *const PackOffers, allocator: Allocator, set: *const backend.dev.ProcArtifact.Set) Allocator.Error![]backend.dev.PackFile.SpecEntry { var specs = std.ArrayList(backend.dev.PackFile.SpecEntry).empty; - defer specs.deinit(allocator); + errdefer specs.deinit(allocator); const trace = dev_diagnostics.isSet(.pack_trace); var withheld: usize = 0; var converting_artifacts = try literalConvertingArtifacts(allocator, set, &self.literal_converters); @@ -9491,7 +9475,27 @@ const PackOffers = struct { if (trace) { std.debug.print("pack: {d} artifacts, {d} specs offered, {d} withheld\n", .{ set.artifacts.len, specs.items.len, withheld }); } - return backend.dev.PackFile.write(allocator, set, specs.items); + return specs.toOwnedSlice(allocator); + } + + fn encode(self: *const PackOffers, allocator: Allocator, set: *const backend.dev.ProcArtifact.Set) Allocator.Error![]u8 { + const specs = try self.entries(allocator, set); + defer allocator.free(specs); + return backend.dev.PackFile.write(allocator, set, specs); + } + + fn publish(self: *const PackOffers, store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set) Allocator.Error!void { + const specs = try self.entries(store.allocator, set); + defer store.allocator.free(specs); + store.writeIndexed(origin, identity, set, specs) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.PackWriteFailed => return, + }; + // A completion marker is published only after all independent offers. + store.completeIndexed(origin, identity, key) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.PackWriteFailed => {}, + }; } }; @@ -9507,88 +9511,7 @@ fn procFingerprint(allocator: Allocator, lowered: *const lir.CheckedPipeline.Low /// entry, and everything it reaches through set-local references and stable /// references to definitions in the same set. The set's definitions are /// indexed once; each query reuses the walk's storage. -const SpliceClosure = struct { - allocator: Allocator, - set: *const backend.dev.ProcArtifact.Set, - procs: std.AutoHashMap(lir.ProcIdentity, ProcArtifacts), - thunks: std.AutoHashMap(lir.ProcIdentity, u32), - helpers: std.StringHashMap(u32), - /// Artifacts the current query reached; exactly the indices in `order`. - seen: std.DynamicBitSetUnmanaged, - order: std.ArrayList(u32) = .empty, - - /// The artifacts of one procedure identity: a splice reaches the first, - /// and a pack offers the last. - const ProcArtifacts = struct { first: u32, last: u32 }; - - fn init(allocator: Allocator, set: *const backend.dev.ProcArtifact.Set) Allocator.Error!SpliceClosure { - var self = SpliceClosure{ - .allocator = allocator, - .set = set, - .procs = std.AutoHashMap(lir.ProcIdentity, ProcArtifacts).init(allocator), - .thunks = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator), - .helpers = std.StringHashMap(u32).init(allocator), - .seen = .{}, - }; - errdefer self.deinit(); - self.seen = try std.DynamicBitSetUnmanaged.initEmpty(allocator, set.artifacts.len); - for (set.artifacts, 0..) |artifact, index| { - switch (artifact.kind) { - .proc => |identity| { - const entry = try self.procs.getOrPut(identity); - if (!entry.found_existing) entry.value_ptr.first = @intCast(index); - entry.value_ptr.last = @intCast(index); - }, - .boxy_thunk => |identity| _ = try self.thunks.getOrPutValue(identity, @intCast(index)), - .rc_helper => |name| _ = try self.helpers.getOrPutValue(name, @intCast(index)), - .entrypoint, .message_pool_run, .branch_island => {}, - } - } - return self; - } - - /// The artifact a pack offers for the procedure `identity`. - fn offeredProcArtifact(self: *const SpliceClosure, identity: lir.ProcIdentity) ?u32 { - const artifacts = self.procs.get(identity) orelse return null; - return artifacts.last; - } - - fn deinit(self: *SpliceClosure) void { - self.order.deinit(self.allocator); - self.seen.deinit(self.allocator); - self.helpers.deinit(); - self.thunks.deinit(); - self.procs.deinit(); - } - - /// Indices of the artifacts a splice of `root` places, valid until the - /// next query. - fn of(self: *SpliceClosure, root: u32) Allocator.Error![]const u32 { - for (self.order.items) |index| self.seen.unset(index); - self.order.clearRetainingCapacity(); - try self.visit(root); - var cursor: usize = 0; - while (cursor < self.order.items.len) : (cursor += 1) { - const artifact = self.set.artifacts[self.order.items[cursor]]; - for (artifact.refs) |ref| try self.visit(ref.target); - for (artifact.symbolic_refs) |ref| { - const target = switch (ref.target) { - .proc => |identity| if (self.procs.get(identity)) |artifacts| artifacts.first else null, - .boxy_thunk => |identity| self.thunks.get(identity), - .rc_helper => |name| self.helpers.get(name), - }; - if (target) |index| try self.visit(index); - } - } - return self.order.items; - } - - fn visit(self: *SpliceClosure, index: u32) Allocator.Error!void { - if (self.seen.isSet(index)) return; - try self.order.append(self.allocator, index); - self.seen.set(index); - } -}; +const SpliceClosure = pack_store.SpliceClosure; /// The artifacts of `set` whose procedure converts a specialized custom /// literal when it runs (`PackProgram.procConvertsLiteralAtRuntime`). @@ -11387,7 +11310,7 @@ fn typeCheckBuildCommand( try finishFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), reporter, build_env.getTimingInfo()); const diag = try build_env.renderDiagnostics(ctx.io.stderr(), ctx.reportConfig(.stderr)); - if (diag.errors == 0) try preparePackageDevPacks(ctx, build_env, null); + if (diag.errors == 0 and !build_env.runtimeOutputCached()) try preparePackageDevPacks(ctx, build_env, null); const total_warning_count = diag.warnings + try optimizedDbgWarningsForBuild(ctx, build_env, args.opt); const resolved_targets_config = build_env.getPlatformTargetsConfig() orelse { try renderProblem(ctx, .{ @@ -11872,7 +11795,17 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu .linked_stores = if (linked_stores) |*stores| stores else null, }; }; - if (program_probe) |*probe| build_env.setRuntimeOutputProbe(probe.runtimeOutputProbe()); + if (program_probe) |*probe| { + probe.code_providers[0] = loaded_packs; + if (build_env.compile_time_object_cache_owner) |owner| { + const cache: *CompileTimeObjectCache = @ptrCast(@alignCast(owner.context)); + probe.code_providers[1] = &cache.packs; + } + for (probe.code_providers) |provider| if (provider) |packs| { + packs.defer_code_lookup = true; + }; + build_env.setRuntimeOutputProbe(probe.runtimeOutputProbe()); + } build_env.setValidateTargetFilesForSelectedTarget(true); build_env.setDetailedLoweringTiming(args.timings); const diag, const resolved_targets_config = try typeCheckBuildCommand(ctx, args, &build_env, &reporter); @@ -12240,6 +12173,9 @@ const ProgramOutputProbe = struct { linked_stores: ?*const pack_store.LinkedStores, key: ?[32]u8 = null, found: ?ProgramOutput = null, + /// Runtime and compile-time providers may be the same collection. A probe + /// miss enables procedure reuse; a hit needs no procedure-pack discovery. + code_providers: [2]?*pack_store.LoadedPacks = .{ null, null }, fn runtimeOutputProbe(self: *ProgramOutputProbe) compile.build.RuntimeOutputProbe { return .{ .context = self, .cached = cached }; @@ -12247,6 +12183,9 @@ const ProgramOutputProbe = struct { fn cached(context: *anyopaque, root: *const check.CheckedArtifact.CheckedModuleArtifact) Allocator.Error!bool { const self: *ProgramOutputProbe = @ptrCast(@alignCast(context)); + defer for (self.code_providers) |provider| { + if (provider) |packs| packs.defer_code_lookup = self.found != null; + }; const imports = try self.build_env.collectImportedArtifactViews(self.gpa, root); defer self.gpa.free(imports); const relations = try self.build_env.collectRelationArtifactViews(self.gpa, root); diff --git a/src/cli/pack_index.zig b/src/cli/pack_index.zig new file mode 100644 index 00000000000..3bf6a9131e6 --- /dev/null +++ b/src/cli/pack_index.zig @@ -0,0 +1,206 @@ +//! Persistent keyed dev offers. Metadata is a payload-free pack of one +//! producer-declared dependency closure; each artifact's bytes are addressed +//! separately by SHA-256. No directory of historical packs is consulted. +const std = @import("std"); +const base = @import("base"); +const backend = @import("backend"); +const lir = @import("lir"); +const PA = backend.dev.ProcArtifact; +const PF = backend.dev.PackFile; +const Allocator = std.mem.Allocator; + +/// Magic and schema version of an offer metadata record. +pub const magic = "ROFFER01"; +/// Magic and schema version of an independently addressed payload record. +pub const payload_magic = "RPAYLD01"; +/// Optional cache records can fail validation or allocation. +pub const Error = PF.ReadError; + +/// Identity and integrity digest of immutable record bytes. +pub fn digest(bytes: []const u8) [32]u8 { + var result: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &result, .{}); + return result; +} + +fn appendSlice(allocator: Allocator, bytes: *std.ArrayList(u8), value: []const u8) Allocator.Error!void { + var count: [8]u8 = undefined; + std.mem.writeInt(u64, &count, value.len, .little); + try bytes.appendSlice(allocator, &count); + try bytes.appendSlice(allocator, value); +} + +/// Encode one artifact's code and static-data bytes, without metadata. +pub fn payload(allocator: Allocator, artifact: PA.Artifact) Allocator.Error![]u8 { + var bytes = std.ArrayList(u8).empty; + errdefer bytes.deinit(allocator); + try bytes.appendSlice(allocator, payload_magic); + try appendSlice(allocator, &bytes, artifact.code); + for (artifact.data) |item| try appendSlice(allocator, &bytes, item.bytes); + return bytes.toOwnedSlice(allocator); +} + +/// The producer supplies the exact splice closure, in original set order. +/// Content naming uses the original bytes, never the stripped metadata. +pub fn encode(allocator: Allocator, set: *const PA.Set, spec: PF.SpecEntry, closure: []const u32, hashes: []const [32]u8, names: *const PA.ContentNames) Allocator.Error![]u8 { + var arena = base.SingleThreadArena.init(allocator); + defer arena.deinit(); + const scratch = arena.allocator(); + const map = try scratch.alloc(u32, set.artifacts.len); + @memset(map, std.math.maxInt(u32)); + for (closure, 0..) |old, new| map[old] = std.math.cast(u32, new) orelse return error.OutOfMemory; + const artifacts = try scratch.alloc(PA.Artifact, closure.len); + for (closure, artifacts) |old, *artifact| { + artifact.* = set.artifacts[old]; + artifact.code = &.{}; + const refs = try scratch.dupe(PA.Reference, artifact.refs); + for (refs) |*ref| { + std.debug.assert(map[ref.target] != std.math.maxInt(u32)); + ref.target = map[ref.target]; + } + artifact.refs = refs; + const data = try scratch.dupe(PA.DataItem, artifact.data); + for (data) |*item| item.bytes = &.{}; + artifact.data = data; + } + var entry = spec; + entry.artifact = map[spec.artifact]; + std.debug.assert(entry.artifact != std.math.maxInt(u32)); + const metadata = try PF.writeWithNames(scratch, &.{ .arena = undefined, .artifacts = artifacts, .source_files = set.source_files }, &.{entry}, names); + var bytes = std.ArrayList(u8).empty; + errdefer bytes.deinit(allocator); + try bytes.appendSlice(allocator, magic); + var count: [4]u8 = undefined; + std.mem.writeInt(u32, &count, std.math.cast(u32, closure.len) orelse return error.OutOfMemory, .little); + try bytes.appendSlice(allocator, &count); + for (closure) |old| try bytes.appendSlice(allocator, &hashes[old]); + try bytes.appendSlice(allocator, metadata); + return bytes.toOwnedSlice(allocator); +} + +/// An owned decoded offer and the explicit digests of its required payloads. +pub const Offer = struct { + pack: PF.Pack, + hashes: []const [32]u8, +}; + +/// Own both the metadata and its decoding; reject foreign/corrupt optional data. +pub fn decode(allocator: Allocator, bytes: []const u8, key: [32]u8) Error!Offer { + if (bytes.len < 12 or !std.mem.eql(u8, bytes[0..8], magic)) return error.MalformedPack; + const count = std.mem.readInt(u32, bytes[8..12], .little); + if (count > (bytes.len - 12) / 32) return error.MalformedPack; + var pack = try PF.read(allocator, bytes[12 + @as(usize, count) * 32 ..]); + errdefer pack.deinit(); + if (pack.set.artifacts.len != count or pack.specs.len != 1 or !std.mem.eql(u8, &pack.specs[0].key, &key)) return error.MalformedPack; + if (pack.set.artifacts[pack.specs[0].artifact].kind != .proc) return error.MalformedPack; + for (pack.set.artifacts) |artifact| { + if (artifact.code.len != 0 or artifact.runtime_refs.len != 0) return error.MalformedPack; + for (artifact.data) |item| if (item.bytes.len != 0) return error.MalformedPack; + } + const hashes = try pack.set.arena.allocator().alloc([32]u8, count); + for (hashes, 0..) |*hash, i| hash.* = bytes[12 + i * 32 ..][0..32].*; + return .{ .pack = pack, .hashes = hashes }; +} + +/// Bytes are owned by the loader, and remain live until all its consumers end. +pub fn materialize(artifact: *PA.Artifact, bytes: []const u8) Error!void { + if (!std.mem.startsWith(u8, bytes, payload_magic)) return error.MalformedPack; + var offset: usize = payload_magic.len; + artifact.code = try take(bytes, &offset); + if (artifact.entry > artifact.code.len) return error.MalformedPack; + for (@constCast(artifact.data)) |*item| item.bytes = try take(bytes, &offset); + if (offset != bytes.len) return error.MalformedPack; +} + +fn take(bytes: []const u8, offset: *usize) Error![]const u8 { + if (bytes.len - offset.* < 8) return error.MalformedPack; + const len = std.math.cast(usize, std.mem.readInt(u64, bytes[offset.*..][0..8], .little)) orelse return error.MalformedPack; + offset.* += 8; + if (len > bytes.len - offset.*) return error.MalformedPack; + defer offset.* += len; + return bytes[offset.*..][0..len]; +} + +/// The exact set-local and symbolic dependency closure followed by a splice. +pub const SpliceClosure = struct { + allocator: Allocator, + set: *const backend.dev.ProcArtifact.Set, + procs: std.AutoHashMap(lir.ProcIdentity, ProcArtifacts), + thunks: std.AutoHashMap(lir.ProcIdentity, u32), + helpers: std.StringHashMap(u32), + /// Artifacts the current query reached; exactly the indices in `order`. + seen: std.DynamicBitSetUnmanaged, + order: std.ArrayList(u32) = .empty, + + /// The artifacts of one procedure identity: a splice reaches the first, + /// and a pack offers the last. + const ProcArtifacts = struct { first: u32, last: u32 }; + + pub fn init(allocator: Allocator, set: *const backend.dev.ProcArtifact.Set) Allocator.Error!SpliceClosure { + var self = SpliceClosure{ + .allocator = allocator, + .set = set, + .procs = std.AutoHashMap(lir.ProcIdentity, ProcArtifacts).init(allocator), + .thunks = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator), + .helpers = std.StringHashMap(u32).init(allocator), + .seen = .{}, + }; + errdefer self.deinit(); + self.seen = try std.DynamicBitSetUnmanaged.initEmpty(allocator, set.artifacts.len); + for (set.artifacts, 0..) |artifact, index| { + switch (artifact.kind) { + .proc => |identity| { + const entry = try self.procs.getOrPut(identity); + if (!entry.found_existing) entry.value_ptr.first = @intCast(index); + entry.value_ptr.last = @intCast(index); + }, + .boxy_thunk => |identity| _ = try self.thunks.getOrPutValue(identity, @intCast(index)), + .rc_helper => |name| _ = try self.helpers.getOrPutValue(name, @intCast(index)), + .entrypoint, .message_pool_run, .branch_island => {}, + } + } + return self; + } + + /// The artifact a pack offers for the procedure `identity`. + pub fn offeredProcArtifact(self: *const SpliceClosure, identity: lir.ProcIdentity) ?u32 { + const artifacts = self.procs.get(identity) orelse return null; + return artifacts.last; + } + + pub fn deinit(self: *SpliceClosure) void { + self.order.deinit(self.allocator); + self.seen.deinit(self.allocator); + self.helpers.deinit(); + self.thunks.deinit(); + self.procs.deinit(); + } + + /// Indices of the artifacts a splice of `root` places, valid until the + /// next query. + pub fn of(self: *SpliceClosure, root: u32) Allocator.Error![]const u32 { + for (self.order.items) |index| self.seen.unset(index); + self.order.clearRetainingCapacity(); + try self.visit(root); + var cursor: usize = 0; + while (cursor < self.order.items.len) : (cursor += 1) { + const artifact = self.set.artifacts[self.order.items[cursor]]; + for (artifact.refs) |ref| try self.visit(ref.target); + for (artifact.symbolic_refs) |ref| { + const target = switch (ref.target) { + .proc => |identity| if (self.procs.get(identity)) |artifacts| artifacts.first else null, + .boxy_thunk => |identity| self.thunks.get(identity), + .rc_helper => |name| self.helpers.get(name), + }; + if (target) |index| try self.visit(index); + } + } + return self.order.items; + } + + pub fn visit(self: *SpliceClosure, index: u32) Allocator.Error!void { + if (self.seen.isSet(index)) return; + try self.order.append(self.allocator, index); + self.seen.set(index); + } +}; diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index b0b6ce5ec06..f6fcf95fb0a 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -14,6 +14,9 @@ const RocTarget = @import("target.zig").RocTarget; const Allocator = std.mem.Allocator; const PackFile = backend.dev.PackFile; const Common = postcheck.Common; +const Index = @import("pack_index.zig"); +/// Producer-owned traversal shared by offer admission and index publication. +pub const SpliceClosure = Index.SpliceClosure; const CoreCtx = ctx_mod.CoreCtx; /// The pack files of one compiler build, target, and optimization level, @@ -163,6 +166,86 @@ pub const Store = struct { try self.writePath(path, bytes); } + /// Publish immutable artifact bytes first, then independent content-named + /// offers. Writers never replace a shared manifest, so concurrent additions + /// cannot lose one another. The caller publishes its completion marker last. + pub fn writeIndexed(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, set: *const backend.dev.ProcArtifact.Set, specs: []const PackFile.SpecEntry) (Allocator.Error || error{PackWriteFailed})!void { + const tracing = base.dev_diagnostics.isSet(.pack_stats); + const start = if (tracing) std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds else 0; + var metadata_bytes: u64 = 0; + var payload_bytes: u64 = 0; + defer if (tracing) std.debug.print("pack index publication: offers={d} metadata_bytes={d} payload_bytes={d} wall_ns={d}\n", .{ specs.len, metadata_bytes, payload_bytes, std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds - start }); + const allocator = self.allocator; + const dir = try self.identityDir(origin, identity); + defer allocator.free(dir); + var names = try backend.dev.ProcArtifact.ContentNames.init(allocator, set); + defer names.deinit(); + var closure = try SpliceClosure.init(allocator, set); + defer closure.deinit(); + const hashes = try allocator.alloc([32]u8, set.artifacts.len); + defer allocator.free(hashes); + const written = try allocator.alloc(bool, set.artifacts.len); + defer allocator.free(written); + @memset(written, false); + for (specs) |spec| { + const reached = try closure.of(spec.artifact); + // Preserve the original first-definition rule for symbolic refs. + std.mem.sort(u32, closure.order.items, {}, std.sort.asc(u32)); + for (reached) |index| { + if (written[index]) continue; + const bytes = try Index.payload(allocator, set.artifacts[index]); + defer allocator.free(bytes); + hashes[index] = Index.digest(bytes); + const name = std.fmt.bytesToHex(hashes[index], .lower) ++ ".rpa".*; + const path = try std.fs.path.join(allocator, &.{ dir, "payload", &name }); + defer allocator.free(path); + try self.publishIndexedPath(path, bytes); + payload_bytes += bytes.len; + written[index] = true; + } + const bytes = try Index.encode(allocator, set, spec, reached, hashes, &names); + defer allocator.free(bytes); + const name = std.fmt.bytesToHex(Index.digest(bytes), .lower) ++ ".rpi".*; + const path = try std.fs.path.join(allocator, &.{ dir, "offers", &std.fmt.bytesToHex(spec.key, .lower), &name }); + defer allocator.free(path); + try self.publishIndexedPath(path, bytes); + metadata_bytes += bytes.len; + } + } + + /// Whether this exact producer completed indexed publication. A missing or + /// corrupt marker is a miss; artifact availability is checked per lookup. + pub fn hasIndexed(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8) Allocator.Error!bool { + const path = try self.filePath(origin, identity, key, ".rpd"); + defer self.allocator.free(path); + const bytes = self.roc_ctx.readFile(path, self.allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return false, + }; + defer self.allocator.free(bytes); + return std.mem.eql(u8, bytes, "ROCINDEX01"); + } + + /// Publish the completion marker only after publishing every offer. + pub fn completeIndexed(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8) (Allocator.Error || error{PackWriteFailed})!void { + const path = try self.filePath(origin, identity, key, ".rpd"); + defer self.allocator.free(path); + try self.publishIndexedPath(path, "ROCINDEX01"); + } + + /// Replacing a corrupt optional record is repair, not an invariant failure. + /// Identical concurrent publishers have identical content-addressed bytes. + fn publishIndexedPath(self: *const Store, path: []const u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + self.roc_ctx.makePath(std.fs.path.dirname(path).?) catch return error.PackWriteFailed; + const staged = self.roc_ctx.writeTempFile(self.allocator, path, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.PackWriteFailed, + }; + defer self.allocator.free(staged); + defer self.roc_ctx.deleteFile(staged) catch {}; + self.roc_ctx.rename(staged, path) catch return error.PackWriteFailed; + } + /// Stage `bytes` in a file only this writer owns, beside `path`, and /// rename it into place, so a reader or another writer never sees a /// partly written file. @@ -214,11 +297,11 @@ pub const Store = struct { self.roc_ctx.writeStderr(message) catch {}; } - /// Every pack filed under a module identity, in name order. - pub fn loadIdentity(self: *const Store, io: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, identity: [32]u8) LoadedPacks.LoadError!void { + /// Register a keyed offer index without reading or enumerating history. + pub fn loadIdentity(self: *const Store, io: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, identity: [32]u8) Allocator.Error!void { const dir = try self.identityDir(origin, identity); defer self.allocator.free(dir); - try packs.loadDirInto(io, dir, .cache_offers); + try packs.addIndexDir(io, dir); } /// Read the discovery summary files filed under a module identity. @@ -542,6 +625,10 @@ test "the cache sweep finds and ages the packs the store writes" { defer allocator.free(stale_dir); try std.Io.Dir.cwd().createDirPath(io, stale_dir); + const artifact = IndexFixture.artifact(1, "indexed code"); + const spec = IndexFixture.spec(1, 0); + try store.writeIndexed(.local, local_identity, &.{ .arena = undefined, .artifacts = &.{artifact} }, &.{spec}); + const limits: compile.CacheCleanup.Limits = .{ .object_pack_format_version = PackFile.format_version, .max_size_bytes = std.math.maxInt(u64), @@ -550,7 +637,7 @@ test "the cache sweep finds and ages the packs the store writes" { var stats: compile.CacheCleanup.CleanupStats = .{}; compile.CacheCleanup.sweep(io, cache_root, limits, now_ns + 2 * compile.CacheCleanup.Config.LOCAL_PACK_MAX_AGE_NS, &stats); try std.testing.expectEqual(@as(u32, 1), stats.stale_namespaces_deleted); - try std.testing.expectEqual(@as(u32, 1), stats.cache_files_deleted); + try std.testing.expectEqual(@as(u32, 3), stats.cache_files_deleted); try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().access(io, stale_dir, .{})); try std.testing.expect(!try store.has(.local, local_identity, key)); try std.testing.expect(try store.has(.pkg, pkg_identity, key)); @@ -768,6 +855,13 @@ pub const LoadedPacks = struct { }; allocator: Allocator, + index_dirs: std.ArrayList([]const u8) = .empty, + index_io: ?std.Io = null, + payloads: std.AutoHashMapUnmanaged([32]u8, []const u8) = .empty, + index_stats: IndexStats = .{}, + /// A completed-output probe has priority over procedure discovery. Until + /// it misses, compile-time demand uses source bodies and cached values. + defer_code_lookup: bool = false, packs: std.ArrayList(*PackFile.Pack), specs: std.AutoHashMap([32]u8, Common.SpecCacheHit), /// The artifact of each entry `specs` holds, by the same key. @@ -799,8 +893,8 @@ pub const LoadedPacks = struct { hits: u64 = 0, /// Artifacts handed to a splice so far. artifacts_served: u64 = 0, - /// Set until `indexPacks` runs; lookups before that load the pending - /// store's packs for the modules in view first. + /// Set until `indexPacks` runs; lookups before that register the pending + /// store's index directories for the modules in view first. pending: ?Pending = null, /// The discovery settlements of the loaded summary files, by /// specialization key; their module lists live in `discovery_arena`. @@ -814,6 +908,177 @@ pub const LoadedPacks = struct { state: enum { pending, ready, unavailable } = .pending, failure: ?LoadError = null, + pub const IndexStats = struct { + index_bytes: u64 = 0, + candidates: u64 = 0, + duplicates: u64 = 0, + payload_opens: u64 = 0, + payload_files: u64 = 0, + payload_bytes: u64 = 0, + decoded_artifacts: u64 = 0, + requested_artifacts: u64 = 0, + served_artifacts: u64 = 0, + allocation_failures: u64 = 0, + requested: u64 = 0, + served: u64 = 0, + misses: u64 = 0, + source_ineligible: u64 = 0, + linked_ineligible: u64 = 0, + corrupt: u64 = 0, + missing: u64 = 0, + }; + + pub fn addIndexDir(self: *LoadedPacks, io: std.Io, dir: []const u8) Allocator.Error!void { + for (self.index_dirs.items) |existing| if (std.mem.eql(u8, existing, dir)) return; + try self.index_dirs.ensureUnusedCapacity(self.allocator, 1); + self.index_dirs.appendAssumeCapacity(try self.allocator.dupe(u8, dir)); + self.index_io = io; + // Preserve the checked program's provider order: its root, then + // visible modules. This is semantic precedence, not filesystem order. + } + + /// Register freshly published offers after the checked program's existing + /// providers, preserving the same precedence as freshly produced full packs. + pub fn offerIndexed(self: *LoadedPacks, store: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8) Allocator.Error!void { + self.ensureLoaded(); + try store.loadIdentity(store.roc_ctx.std_io, self, origin, identity); + } + + /// Read only the candidate bucket for this exact specialization. The same + /// keyed lookup serves both warm builds and reuse across source edits. + fn findIndexed(self: *LoadedPacks, key: [32]u8, takes: Common.SpecCacheTakes, modules: Common.ProgramModules) Allocator.Error!bool { + const io = self.index_io orelse return false; + const allocator = self.allocator; + var candidates_seen: usize = 0; + for (self.index_dirs.items) |module_dir| { + const bucket = try std.fs.path.join(allocator, &.{ module_dir, "offers", &std.fmt.bytesToHex(key, .lower) }); + defer allocator.free(bucket); + var dir = std.Io.Dir.cwd().openDir(io, bucket, .{ .iterate = true }) catch continue; + defer dir.close(io); + var candidates = std.ArrayList([]u8).empty; + defer { + for (candidates.items) |name| allocator.free(name); + candidates.deinit(allocator); + } + var it = dir.iterate(); + while (it.next(io) catch null) |entry| { + if (entry.kind != .file or entry.name.len != 68 or !std.mem.endsWith(u8, entry.name, ".rpi")) continue; + try candidates.ensureUnusedCapacity(allocator, 1); + candidates.appendAssumeCapacity(try allocator.dupe(u8, entry.name)); + } + std.mem.sort([]u8, candidates.items, {}, nameLessThan); + for (candidates.items) |name| { + self.index_stats.candidates += 1; + if (candidates_seen != 0) self.index_stats.duplicates += 1; + candidates_seen += 1; + const bytes = dir.readFileAlloc(io, name, allocator, .limited(1024 * 1024 * 1024)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + self.index_stats.missing += 1; + continue; + }, + }; + defer allocator.free(bytes); + self.index_stats.index_bytes += bytes.len; + const expected = std.fmt.bytesToHex(Index.digest(bytes), .lower); + if (!std.mem.eql(u8, name[0..64], &expected)) { + self.index_stats.corrupt += 1; + continue; + } + var offer = Index.decode(allocator, bytes, key) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + self.index_stats.corrupt += 1; + continue; + }, + }; + var owned = true; + defer if (owned) offer.pack.deinit(); + // Metadata covers exactly this offer's explicit closure. + const present = try allocator.alloc(bool, offer.pack.set.source_files.len); + defer allocator.free(present); + for (offer.pack.set.source_files, present) |identity, *is_present| is_present.* = Common.programHasModule(modules, identity); + // Site-named symbolic callees are supplied by the current + // program. Preserve the same eligibility walk as full packs. + const absent = try backend.dev.ProcArtifact.reachingAbsentFiles(allocator, &offer.pack.set, present); + defer allocator.free(absent); + var eligible = !absent[offer.pack.specs[0].artifact]; + if (!eligible) { + self.index_stats.source_ineligible += 1; + continue; + } + if (takes.refusesDevCodeOfLinkedProcs()) { + self.ensureLinkedLoaded(); + const linked = try backend.dev.ProcArtifact.reachingIdentities(allocator, &offer.pack.set, &self.linked_identities); + defer allocator.free(linked); + eligible = !linked[offer.pack.specs[0].artifact]; + if (!eligible) { + self.index_stats.linked_ineligible += 1; + continue; + } + } + self.index_stats.requested_artifacts += offer.hashes.len; + for (@constCast(offer.pack.set.artifacts), offer.hashes) |*artifact, hash| { + const payload_bytes = try self.readPayload(io, module_dir, hash) orelse { + eligible = false; + break; + }; + Index.materialize(artifact, payload_bytes) catch { + self.index_stats.corrupt += 1; + eligible = false; + break; + }; + self.index_stats.decoded_artifacts += 1; + } + if (!eligible) continue; + // Commit only after the complete closure is owned. Eviction can + // now remove every on-disk file without invalidating this hit. + const pack = try allocator.create(PackFile.Pack); + errdefer allocator.destroy(pack); + try self.packs.ensureUnusedCapacity(allocator, 1); + try self.artifacts.ensureUnusedCapacity(1); + try self.specs.ensureUnusedCapacity(1); + pack.* = offer.pack; + owned = false; + self.packs.appendAssumeCapacity(pack); + _ = self.specs.remove(key); + _ = self.artifacts.remove(key); + self.indexPack(pack) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => base.invariant("validated indexed offer has a procedure root", .{}), + }; + self.index_stats.served_artifacts += offer.hashes.len; + return true; + } + } + return false; + } + + fn readPayload(self: *LoadedPacks, io: std.Io, module_dir: []const u8, hash: [32]u8) Allocator.Error!?[]const u8 { + if (self.payloads.get(hash)) |bytes| return bytes; + const name = std.fmt.bytesToHex(hash, .lower) ++ ".rpa".*; + const path = try std.fs.path.join(self.allocator, &.{ module_dir, "payload", &name }); + defer self.allocator.free(path); + self.index_stats.payload_opens += 1; + const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, self.allocator, .limited(1024 * 1024 * 1024)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + self.index_stats.missing += 1; + return null; + }, + }; + errdefer self.allocator.free(bytes); + self.index_stats.payload_bytes += bytes.len; + if (!std.mem.eql(u8, &Index.digest(bytes), &hash)) { + self.allocator.free(bytes); + self.index_stats.corrupt += 1; + return null; + } + try self.payloads.put(self.allocator, hash, bytes); + self.index_stats.payload_files += 1; + return bytes; + } + pub const LoadError = Allocator.Error || PackFile.ReadError || error{PackDirectoryUnreadable}; pub const Input = enum { /// Independent optional offers; an incompatible file does not veto peers. @@ -1127,8 +1392,8 @@ pub const LoadedPacks = struct { try self.indexObjectPacks(try programBindingKey(self.allocator, pending.build_env, root_artifact)); } - /// Load the pending store's packs for the root module and every module - /// it lowers against, then index. Runs once, at the first lookup after + /// Register the pending store's keyed indexes for visible modules, without + /// enumerating historical keys or reading any metadata or payload. Runs once, at the first lookup after /// checking has produced the module set; before that a lookup finds /// nothing. fn ensureLoaded(self: *LoadedPacks) void { @@ -1197,6 +1462,15 @@ pub const LoadedPacks = struct { } pub fn deinit(self: *LoadedPacks) void { + if (base.dev_diagnostics.isSet(.pack_stats)) { + const stats = self.index_stats; + std.debug.print("pack index: index_bytes={d} candidates={d} duplicates={d} payload_opens={d} payload_files={d} payload_bytes={d} decoded_artifacts={d} requested_artifacts={d} served_artifacts={d} allocation_failures={d} requested={d} served={d} misses={d} source_ineligible={d} linked_ineligible={d} corrupt={d} missing={d}\n", .{ stats.index_bytes, stats.candidates, stats.duplicates, stats.payload_opens, stats.payload_files, stats.payload_bytes, stats.decoded_artifacts, stats.requested_artifacts, stats.served_artifacts, stats.allocation_failures, stats.requested, stats.served, stats.misses, stats.source_ineligible, stats.linked_ineligible, stats.corrupt, stats.missing }); + } + for (self.index_dirs.items) |dir| self.allocator.free(dir); + self.index_dirs.deinit(self.allocator); + var payloads = self.payloads.valueIterator(); + while (payloads.next()) |bytes| self.allocator.free(bytes.*); + self.payloads.deinit(self.allocator); var settlements = self.discovery.valueIterator(); while (settlements.next()) |list| list.deinit(self.allocator); self.discovery.deinit(self.allocator); @@ -1263,6 +1537,7 @@ pub const LoadedPacks = struct { fn findSpec(context: *anyopaque, key: [32]u8, takes: Common.SpecCacheTakes, modules: Common.ProgramModules) ?Common.SpecCacheHit { const self: *LoadedPacks = @ptrCast(@alignCast(context)); + if (self.defer_code_lookup) return null; self.ensureLoaded(); if (self.state != .ready) return null; // An optimized object serves a consumer whose code is linked ahead @@ -1275,22 +1550,29 @@ pub const LoadedPacks = struct { } } if (!takes.takesDevCode()) return null; - const hit = self.specs.get(key) orelse return null; - // An entry whose splice would place a line or a location in a module - // version the asking program does not lower against would place lines - // that differ from the procedure's own; the program compiles it. - if (self.linesOutsideProgram(key, modules)) return null; - // A consumer whose code is linked takes every procedure an object - // offers from the object. A dev entry whose splice would place dev - // code for such a procedure, as an entry compile-time evaluation - // filed does, is not taken, so which procedures link an object's - // entry depends on the object packs alone, never on which dev packs - // happen to exist. - if (takes.refusesDevCodeOfLinkedProcs()) { + self.index_stats.requested += 1; + var eligible = self.specs.contains(key); + if (eligible and self.linesOutsideProgram(key, modules)) { + self.index_stats.source_ineligible += 1; + eligible = false; + } + if (eligible and takes.refusesDevCodeOfLinkedProcs()) { self.ensureLinkedLoaded(); - if (self.devEntryReachesLinked(key)) return null; + if (self.devEntryReachesLinked(key)) { + self.index_stats.linked_ineligible += 1; + eligible = false; + } + } + if (!eligible and !(self.findIndexed(key, takes, modules) catch blk: { + self.index_stats.allocation_failures += 1; + break :blk false; + })) { + self.index_stats.misses += 1; + return null; } + const hit = self.specs.get(key) orelse return null; self.hits += 1; + self.index_stats.served += 1; return hit; } @@ -1353,6 +1635,7 @@ pub const LoadedPacks = struct { fn findDiscovery(context: *anyopaque, key: [32]u8) []const Common.DiscoverySettlement { const self: *LoadedPacks = @ptrCast(@alignCast(context)); + if (self.defer_code_lookup) return &.{}; self.ensureDiscoveryLoaded(); if (self.discovery_state != .ready) return &.{}; const list = self.discovery.get(key) orelse return &.{}; @@ -1521,3 +1804,350 @@ test "an object pack that offers nothing is no offer and contributes no object t try std.testing.expectEqual(@as(usize, 1), packs.object_packs.items.len); try std.testing.expectEqualStrings("object", packs.object_packs.items[0].object); } + +const IndexFixture = struct { + tmp: std.testing.TmpDir, + arena: std.heap.ArenaAllocator, + store: Store, + const allocator = std.testing.allocator; + const identity: [32]u8 = @splat(0xa1); + + fn init(self: *@This()) !void { + self.tmp = std.testing.tmpDir(.{}); + self.arena = std.heap.ArenaAllocator.init(allocator); + const root = try self.tmp.dir.realPathFileAlloc(std.testing.io, ".", allocator); + defer allocator.free(root); + self.store = .{ + .allocator = allocator, + .roc_ctx = CoreCtx.default(allocator, self.arena.allocator(), std.testing.io), + .root = try allocator.dupe(u8, root), + .verbose = false, + }; + } + + fn deinit(self: *@This()) void { + self.store.deinit(); + self.arena.deinit(); + self.tmp.cleanup(); + } + + fn loader(self: *@This()) !LoadedPacks { + var packs = LoadedPacks.init(allocator); + errdefer packs.deinit(); + try self.store.loadIdentity(std.testing.io, &packs, .local, identity); + try packs.indexPacks(); + return packs; + } + + fn artifact(id: u8, code: []const u8) backend.dev.ProcArtifact.Artifact { + return .{ .kind = .{ .proc = lir.ProcIdentity.forTest(id) }, .code = code, .entry = 0, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }; + } + + fn spec(key: u8, artifact_index: u32) PackFile.SpecEntry { + return .{ .key = @splat(key), .artifact = artifact_index, .rc_borrowed_params = 3, .rc_ret_borrowed = true, .rc_ret_lenders = 2, .rc_read_only_params = 1, .rc_ret_unique = false, .rc_ret_unique_fields = 0, .rc_ret_conditions = &.{1} }; + } + + fn publish(self: *@This(), artifacts: []const backend.dev.ProcArtifact.Artifact, specs: []const PackFile.SpecEntry, files: []const [32]u8) !void { + try self.store.writeIndexed(.local, identity, &.{ .arena = undefined, .artifacts = artifacts, .source_files = files }, specs); + } + + fn payloadPath(self: *@This(), artifact_value: backend.dev.ProcArtifact.Artifact) ![]u8 { + const bytes = try Index.payload(allocator, artifact_value); + defer allocator.free(bytes); + const dir = try self.store.identityDir(.local, identity); + defer allocator.free(dir); + const name = std.fmt.bytesToHex(Index.digest(bytes), .lower) ++ ".rpa".*; + return std.fs.path.join(allocator, &.{ dir, "payload", &name }); + } +}; + +test "indexed object cache reads only requested closure regardless of unrelated history" { + var fixture: IndexFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + const F = IndexFixture; + const module: [32]u8 = @splat(17); + var root = F.artifact(1, "caller code"); + root.refs = &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}; + root.symbolic_refs = &.{.{ .site = 0, .form = .call, .target = .{ .proc = lir.ProcIdentity.forTest(3) } }}; + root.data = &.{.{ .program_local_name = true, .name = "roc__d0", .bytes = "constant bytes", .alignment = 8, .symbol_offset = 0, .relocations = &.{.{ .offset = 0, .name = "roc__d1", .addend = 0, .function = false }} }}; + root.lines = &.{.{ .offset = 0, .loc = .{ .file = 0, .line = 4, .column = 2 }, .region = base.Region.zero() }}; + var spec = F.spec(1, 0); + spec.comptime_root_reads = &.{.{ .module = module, .root = 9 }}; + var callee = F.artifact(2, "callee code"); + callee.data = &.{.{ .program_local_name = true, .name = "roc__d1", .bytes = "other bytes", .alignment = 8, .symbol_offset = 0, .relocations = &.{} }}; + const unused_code: [64 * 1024]u8 = @splat(3); + var unused = F.artifact(4, &unused_code); + unused.loc = .{ .file = 1, .line = 99, .column = 1 }; + const artifacts = [_]backend.dev.ProcArtifact.Artifact{ root, callee, F.artifact(3, "symbolic callee"), unused }; + try fixture.publish(&artifacts, &.{ spec, F.spec(200, 3) }, &.{ module, @splat(250) }); + var first = try fixture.loader(); + defer first.deinit(); + const hit = first.specCacheLookup().lookup(spec.key, .dev_code, &.{module}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u64, 3), hit.rc_borrowed_params); + try std.testing.expectEqual(@as(u32, 9), hit.comptime_root_reads[0].root); + try std.testing.expectEqualSlices(u32, &.{1}, hit.rc_ret_conditions); + const located = first.artifacts.get(spec.key).?; + try std.testing.expectEqual(@as(usize, 3), located.set.artifacts.len); + try std.testing.expectEqualStrings("constant bytes", located.set.artifacts[located.index].data[0].bytes); + try std.testing.expectEqual(@as(u32, 4), located.set.artifacts[located.index].lines[0].loc.line); + try std.testing.expectEqualStrings(located.set.artifacts[1].data[0].name, located.set.artifacts[located.index].data[0].relocations[0].name); + try std.testing.expect(std.mem.startsWith(u8, located.set.artifacts[1].data[0].name, "roc__h")); + try std.testing.expectEqual(@as(u64, 3), first.index_stats.payload_files); + const before = first.index_stats; + _ = first.specCacheLookup().lookup(spec.key, .dev_code, &.{module}).?; + try std.testing.expectEqual(before.payload_opens, first.index_stats.payload_opens); + try std.testing.expectEqual(before.index_bytes, first.index_stats.index_bytes); + try std.testing.expectEqual(located.set, first.artifacts.get(spec.key).?.set); + + var large: [16 * 1024]u8 = @splat(7); + for (2..130) |version| { + large[0] = @intCast(version); + const unrelated = F.artifact(@intCast(version), &large); + try fixture.publish(&.{unrelated}, &.{F.spec(@intCast(version), 0)}, &.{}); + } + // A malformed legacy pack is deliberately irrelevant to indexed lookup. + try fixture.store.write(.local, F.identity, @splat(99), "not a pack"); + var after = try fixture.loader(); + defer after.deinit(); + _ = after.specCacheLookup().lookup(spec.key, .dev_code, &.{module}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(before.payload_bytes, after.index_stats.payload_bytes); + try std.testing.expectEqual(before.payload_opens, after.index_stats.payload_opens); + try std.testing.expectEqual(before.index_bytes, after.index_stats.index_bytes); + try std.testing.expectEqual(before.decoded_artifacts, after.index_stats.decoded_artifacts); + try std.testing.expect(after.specCacheLookup().lookup(@splat(250), .dev_code, &.{module}) == null); +} + +test "indexed object cache selects eligible provenance deterministically and reuses reverted history" { + const F = IndexFixture; + for ([_]bool{ false, true }) |reverse| { + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const files = [_][32]u8{ @splat(1), @splat(2) }; + var artifact = F.artifact(1, "same machine code"); + artifact.loc = .{ .file = 0, .line = 12, .column = 1 }; + const spec = F.spec(1, 0); + for (0..2) |i| { + const version = if (reverse) 1 - i else i; + try fixture.publish(&.{artifact}, &.{spec}, files[version .. version + 1]); + } + // Each source version can find its own candidate regardless of which + // filename sorts first or which writer published last. + var packs = try fixture.loader(); + defer packs.deinit(); + for ([_]usize{ 0, 1, 0 }) |version| { + _ = packs.specCacheLookup().lookup(spec.key, .dev_code, files[version .. version + 1]) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualSlices([32]u8, files[version .. version + 1], packs.artifacts.get(spec.key).?.set.source_files); + } + try std.testing.expectEqual(@as(u64, 1), packs.index_stats.payload_opens); + try std.testing.expect(packs.index_stats.source_ineligible > 0); + try std.testing.expect(packs.specCacheLookup().lookup(spec.key, .dev_code, &.{@splat(3)}) == null); + try std.testing.expect(packs.specCacheLookup().lookup(@splat(2), .dev_code, &files) == null); + } +} + +test "indexed object cache requires complete intact payloads and owns hits across eviction" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var root = F.artifact(1, "root"); + root.refs = &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}; + const dep = F.artifact(2, "dependency"); + const spec = F.spec(1, 0); + try fixture.publish(&.{ root, dep }, &.{spec}, &.{}); + var pinned = try fixture.loader(); + defer pinned.deinit(); + _ = pinned.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + const path = try fixture.payloadPath(dep); + defer F.allocator.free(path); + try std.Io.Dir.cwd().deleteFile(std.testing.io, path); + var missing = try fixture.loader(); + defer missing.deinit(); + try std.testing.expect(missing.specCacheLookup().lookup(spec.key, .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 1), missing.index_stats.missing); + try std.testing.expect(!missing.artifacts.contains(spec.key)); + try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = path, .data = "corrupt" }); + var corrupt = try fixture.loader(); + defer corrupt.deinit(); + try std.testing.expect(corrupt.specCacheLookup().lookup(spec.key, .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 1), corrupt.index_stats.corrupt); + // Republishing repairs corrupt immutable optional storage. + try fixture.publish(&.{ root, dep }, &.{spec}, &.{}); + var repaired = try fixture.loader(); + defer repaired.deinit(); + _ = repaired.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try fixture.tmp.dir.deleteTree(std.testing.io, "local"); + _ = pinned.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + const located = pinned.artifacts.get(spec.key).?; + try std.testing.expectEqualStrings("dependency", located.set.artifacts[1].code); +} + +test "indexed object cache source eligibility follows local dependencies but not site-named symbolic procedures" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var root = F.artifact(1, "root"); + root.symbolic_refs = &.{.{ .site = 0, .form = .call, .target = .{ .proc = lir.ProcIdentity.forTest(2) } }}; + var callee = F.artifact(2, "old site-named callee"); + callee.loc = .{ .file = 0, .line = 1, .column = 1 }; + try fixture.publish(&.{ root, callee }, &.{F.spec(1, 0)}, &.{@splat(7)}); + var packs = try fixture.loader(); + defer packs.deinit(); + _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + root.symbolic_refs = &.{}; + root.refs = &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}; + try fixture.publish(&.{ root, callee }, &.{F.spec(2, 0)}, &.{@splat(7)}); + try std.testing.expect(packs.specCacheLookup().lookup(@splat(2), .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 1), packs.index_stats.source_ineligible); +} + +test "indexed object cache preserves linked precedence and rejects dev closures reaching linked procedures" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var root = F.artifact(1, "root"); + root.refs = &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}; + const dep = F.artifact(2, "dependency"); + const spec = F.spec(1, 0); + try fixture.publish(&.{ root, dep }, &.{spec}, &.{}); + var packs = try fixture.loader(); + defer packs.deinit(); + packs.linked_state = .ready; + try packs.linked_identities.put(F.allocator, dep.kind.proc, 0); + try std.testing.expect(packs.specCacheLookup().lookup(spec.key, .dev_code_beside_linked_objects, &.{}) == null); + try std.testing.expectEqual(@as(u64, 0), packs.index_stats.payload_opens); + const dev = packs.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + var linked = dev; + linked.c_abi_entry = true; + try packs.linked_specs.put(spec.key, .{ .hit = linked, .pack = 0 }); + const hit = packs.specCacheLookup().lookup(spec.key, .dev_code_and_linked_objects, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expect(hit.c_abi_entry); +} + +test "indexed object cache interrupted publication exposes no offer and concurrent writers lose none" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const RefuseIndex = struct { + fn rename(_: ?*anyopaque, io: std.Io, old: []const u8, new: []const u8) CoreCtx.RenameError!void { + if (std.mem.endsWith(u8, new, ".rpi")) return error.AccessDenied; + try std.Io.Dir.cwd().rename(old, std.Io.Dir.cwd(), new, io); + } + }; + const real_rename = fixture.store.roc_ctx.vtable.rename; + fixture.store.roc_ctx.vtable.rename = RefuseIndex.rename; + try std.testing.expectError(error.PackWriteFailed, fixture.publish(&.{F.artifact(1, "code")}, &.{F.spec(1, 0)}, &.{})); + fixture.store.roc_ctx.vtable.rename = real_rename; + var interrupted = try fixture.loader(); + defer interrupted.deinit(); + try std.testing.expect(interrupted.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 0), interrupted.index_stats.payload_opens); + + const Writer = struct { + store: *const Store, + key: u8, + failed: bool = false, + fn run(self: *@This()) void { + for (0..8) |_| { + self.store.writeIndexed(.local, F.identity, &.{ .arena = undefined, .artifacts = &.{F.artifact(self.key, "code")} }, &.{F.spec(self.key, 0)}) catch { + self.failed = true; + return; + }; + } + } + }; + var a = Writer{ .store = &fixture.store, .key = 1 }; + var b = Writer{ .store = &fixture.store, .key = 2 }; + { + const thread_a = try std.Thread.spawn(.{}, Writer.run, .{&a}); + defer thread_a.join(); + const thread_b = try std.Thread.spawn(.{}, Writer.run, .{&b}); + defer thread_b.join(); + // A concurrent reader sees a miss or a completely materialized hit. + // Misses are not memoized, so publication remains visible later. + for (0..8) |_| { + if (interrupted.specCacheLookup().lookup(@splat(1), .dev_code, &.{})) |hit| { + try std.testing.expectEqual(lir.ProcIdentity.forTest(1).bytes, hit.identity); + } + } + } + try std.testing.expect(!a.failed and !b.failed); + for ([_]u8{ 1, 2 }) |key| { + const hit = interrupted.specCacheLookup().lookup(@splat(key), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(lir.ProcIdentity.forTest(key).bytes, hit.identity); + } + try std.testing.expectEqual(@as(u64, 1), interrupted.index_stats.payload_opens); +} + +test "indexed object cache rejects corrupt metadata before reading payloads" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + try fixture.publish(&.{F.artifact(1, "code")}, &.{F.spec(1, 0)}, &.{}); + const module_dir = try fixture.store.identityDir(.local, F.identity); + defer F.allocator.free(module_dir); + const bucket = try std.fs.path.join(F.allocator, &.{ module_dir, "offers", &std.fmt.bytesToHex(@as([32]u8, @splat(1)), .lower) }); + defer F.allocator.free(bucket); + var dir = try std.Io.Dir.cwd().openDir(std.testing.io, bucket, .{ .iterate = true }); + defer dir.close(std.testing.io); + var it = dir.iterate(); + const entry = (try it.next(std.testing.io)).?; + try dir.writeFile(std.testing.io, .{ .sub_path = entry.name, .data = "truncated metadata" }); + var packs = try fixture.loader(); + defer packs.deinit(); + try std.testing.expect(packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 1), packs.index_stats.corrupt); + try std.testing.expectEqual(@as(u64, 0), packs.index_stats.payload_opens); +} + +test "indexed object cache duplicate eligible closures select by content independent of publication order" { + const F = IndexFixture; + var selected: ?u32 = null; + for ([_]bool{ false, true }) |reverse| { + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const callee = F.artifact(2, "callee"); + for (0..2) |i| { + const swapped = (i == 1) != reverse; + var caller = F.artifact(1, "caller"); + const refs = [_]backend.dev.ProcArtifact.Reference{.{ .site = 0, .form = .call, .target = if (swapped) 0 else 1, .delta = 0 }}; + caller.refs = &refs; + const artifacts = if (swapped) [_]backend.dev.ProcArtifact.Artifact{ callee, caller } else [_]backend.dev.ProcArtifact.Artifact{ caller, callee }; + try fixture.publish(&artifacts, &.{F.spec(1, if (swapped) 1 else 0)}, &.{}); + } + var packs = try fixture.loader(); + defer packs.deinit(); + _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + const located = packs.artifacts.get(@splat(1)).?; + if (selected) |expected| try std.testing.expectEqual(expected, located.index) else selected = located.index; + try std.testing.expectEqualStrings("caller", located.set.artifacts[located.index].code); + const ref = located.set.artifacts[located.index].refs[0]; + try std.testing.expectEqualStrings("callee", located.set.artifacts[ref.target].code); + } +} + +test "indexed object cache defers discovery until a completed-output probe misses" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + try fixture.publish(&.{F.artifact(1, "code")}, &.{F.spec(1, 0)}, &.{}); + var packs = try fixture.loader(); + defer packs.deinit(); + packs.defer_code_lookup = true; + try std.testing.expect(packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(usize, 0), LoadedPacks.findDiscovery(&packs, @splat(1)).len); + try std.testing.expect(packs.discovery_state == .pending); + try std.testing.expectEqual(@as(u64, 0), packs.index_stats.index_bytes); + try std.testing.expectEqual(@as(u64, 0), packs.index_stats.payload_opens); + packs.defer_code_lookup = false; + _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u64, 1), packs.index_stats.payload_opens); +} diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 01463fccade..a06064d4b2a 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -8123,7 +8123,7 @@ const PackStoreFiles = struct { var walker = try cache.walk(allocator); defer walker.deinit(); while (try walker.next(io)) |entry| { - if (entry.kind != .file or !std.mem.endsWith(u8, entry.basename, ".rpk")) continue; + if (entry.kind != .file or !(std.mem.endsWith(u8, entry.basename, ".rpk") or std.mem.endsWith(u8, entry.basename, ".rpi") or std.mem.endsWith(u8, entry.basename, ".rpa") or std.mem.endsWith(u8, entry.basename, ".rpd"))) continue; const path = try allocator.dupe(u8, entry.path); result.paths.append(allocator, path) catch |err| { allocator.free(path); @@ -10821,6 +10821,8 @@ fn customRecordedProgramOutput( return customInfraFailure(allocator, timer, "failed to allocate cache root: {}", .{err}); isolated.env_map.put("ROC_CACHE_DIR", cache) catch |err| return customInfraFailure(allocator, timer, "failed to isolate the cache: {}", .{err}); + if (build_options.dev_diagnostics) isolated.env_map.put("ROC_PACK_STATS", "1") catch |err| + return customInfraFailure(allocator, timer, "failed to enable cache statistics: {}", .{err}); const steps = [_]struct { source: []const u8, specializes: bool, stdout: []const u8 }{ .{ .source = source, .specializes = true, .stdout = "45 155\n" }, .{ .source = source, .specializes = false, .stdout = "45 155\n" }, @@ -10844,6 +10846,18 @@ fn customRecordedProgramOutput( if (specialized != step.specializes) { return failureFromRun(allocator, timer, run, if (step.specializes) "a changed program linked recorded output" else "an unchanged program was specialized again"); } + if (build_options.dev_diagnostics and !step.specializes) { + var lines = std.mem.splitScalar(u8, run.stderr, '\n'); + var observed = false; + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "pack index:")) continue; + observed = true; + if (std.mem.find(u8, line, "index_bytes=0 ") == null or std.mem.find(u8, line, "payload_opens=0 ") == null) { + return failureFromRun(allocator, timer, run, "recorded output hit consulted procedure offers or payloads"); + } + } + if (!observed) return failureFromRun(allocator, timer, run, "missing procedure index statistics"); + } if (runRawAndCheck(io, allocator, env, timer, timeout_ms, &.{exe}, env.dirs.work_dir, .{ .args = &.{}, .stdout_exact = step.stdout, diff --git a/src/compile/cache_cleanup.zig b/src/compile/cache_cleanup.zig index 512c62bc112..ecb111b32fe 100644 --- a/src/compile/cache_cleanup.zig +++ b/src/compile/cache_cleanup.zig @@ -345,11 +345,17 @@ fn cleanupPackNamespace(std_io: Io, namespace_dir: Dir, now_ns: i128, maybe_stat } } -/// Delete the files of `dir` last used more than `max_age_ns` before `now_ns`. +/// Recursively delete the files of `dir` last used more than `max_age_ns` before `now_ns`. fn ageFilesIn(std_io: Io, dir: Dir, max_age_ns: i96, now_ns: i128, maybe_stats: ?*CleanupStats) void { var file_it = dir.iterate(); while (true) { const file_entry = (file_it.next(std_io) catch break) orelse break; + if (file_entry.kind == .directory) { + var child = dir.openDir(std_io, file_entry.name, .{ .iterate = true }) catch continue; + defer child.close(std_io); + ageFilesIn(std_io, child, max_age_ns, now_ns, maybe_stats); + continue; + } if (file_entry.kind != .file) continue; const last_use = lastUseNs(dir, std_io, file_entry.name) orelse continue; if (now_ns - last_use <= max_age_ns) continue; diff --git a/test/cli/pack_index_bench.py b/test/cli/pack_index_bench.py new file mode 100644 index 00000000000..8d01334b1d9 --- /dev/null +++ b/test/cli/pack_index_bench.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Focused Linux benchmark for #12571; use a ReleaseFast diagnostic compiler. + + zig build -j2 roc -Doptimize=ReleaseFast -Ddev-diagnostics + python3 test/cli/pack_index_bench.py zig-out/bin/roc --platform /main.roc + +All measured commands are `roc build --opt=dev`. GNU time records wall time +and peak RSS. Synthetic history consists of valid versioned offer records for +unrequested keys, each with a distinct enlarged artifact payload. The script +checks actual executable output and asserts metadata/payload I/O independence. +Use the staged fx-open platform from a CLI test build, or prepare the checked-out +fixture with `zig build -j2 update-test-fixtures`. Temporary source, cache and +executable files live under TMPDIR. +""" +import argparse +import hashlib +import os +from pathlib import Path +import re +import statistics +import struct +import subprocess +import tempfile + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("roc", type=Path) + parser.add_argument("--platform", type=Path, default=Path(__file__).resolve().parents[1] / "fx-open/platform/main.roc") + parser.add_argument("--history", type=int, default=1000) + parser.add_argument("--repeats", type=int, default=3) + args = parser.parse_args() + roc = args.roc.resolve() + with tempfile.TemporaryDirectory(prefix="roc-pack-index-") as temp: + work = Path(temp) + cache = work / "cache" + app = work / "main.roc" + app.write_text('app [main!] { pf: platform "' + os.path.relpath(args.platform.resolve(), work) + '" }\n' + '''import pf.Stdout +import Stable +main! = |args| { + Stdout.line!(Stable.total(args.len().to_u64() + 10).to_str()) + Ok({}) +} +''') + (work / "Stable.roc").write_text('''Stable := [].{ + total : U64 -> U64 + total = |limit| { + var $sum = 0 + var $index = 0 + while $index < limit { + $sum = $sum + $index + $index = $index + 1 + } + $sum + } +} +''') + env = dict(os.environ, ROC_CACHE_DIR=str(cache), ROC_PACK_STATS="1") + results = [] + + def run(label, force_lookup=False, no_cache=False, expected="55\n"): + if force_lookup: + for result in cache.rglob("*.rpo"): + result.unlink() + cmd = [str(roc), "build", "--opt=dev", "--jobs=1", str(app), f"--output={work / 'app'}"] + if no_cache: + cmd.append("--no-cache") + completed = subprocess.run(["/usr/bin/time", "-f", "%e %M", "-o", str(work / "time"), *cmd], env=env, capture_output=True, text=True) + if completed.returncode: + raise RuntimeError(completed.stdout + completed.stderr) + stats = {} + publication_ns = 0 + for line in completed.stderr.splitlines(): + if line.startswith("pack index publication:"): + publication_ns += int(re.search(r"wall_ns=(\d+)", line)[1]) + if not line.startswith("pack index:"): + continue + for key, value in re.findall(r"(\w+)=(\d+)", line): + stats[key] = stats.get(key, 0) + int(value) + if not no_cache and not stats: + raise RuntimeError("Build roc with -Doptimize=ReleaseFast -Ddev-diagnostics") + output = subprocess.check_output([str(work / "app")], text=True) + assert output == expected, output + wall, rss = (work / "time").read_text().split() + results.append((label, float(wall), int(rss), stats)) + print(label, f"{wall}s", f"{rss}KiB", f"publication={publication_ns / 1e6:.3f}ms", stats, flush=True) + return stats + + run("cold publication") + warm = run("completed-result hit") + assert warm["payload_opens"] == warm["index_bytes"] == 0 + run("lookup stabilization", force_lookup=True) + baseline = run("lookup baseline", force_lookup=True) + assert baseline["payload_files"] > 0 + templates = sorted(cache.rglob("*.rpi")) + template = next(p for p in templates if struct.unpack_from(" 0) + metadata = template.read_bytes() + assert metadata[:8] == b"ROFFER01" + module = template.parent.parent.parent + payload_dir = module / "payload" + original_payload = (payload_dir / (metadata[12:44].hex() + ".rpa")).read_bytes() + assert original_payload[:8] == b"RPAYLD01" + code_len = struct.unpack_from(" 0 + run("cache disabled", no_cache=True, expected="66\n") + app.write_text(original) + reverted = run("reverted edit with history", force_lookup=True) + assert reverted["served"] > 0 + sample = [r for r in results if r[0] == "lookup with history"] + print("history lookup medians:", statistics.median(r[1] for r in sample), "seconds;", statistics.median(r[2] for r in sample), "KiB") + + +if __name__ == "__main__": + main() From afccc1128d3f8f257391280c8578e2b47fc8a169 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sat, 24 Oct 2026 22:54:35 -0400 Subject: [PATCH 03/97] Name the error sets of the indexed pack test fixture --- src/cli/pack_store.zig | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index f6fcf95fb0a..90bac9036de 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -1812,7 +1812,7 @@ const IndexFixture = struct { const allocator = std.testing.allocator; const identity: [32]u8 = @splat(0xa1); - fn init(self: *@This()) !void { + fn init(self: *@This()) (std.Io.Dir.RealPathFileAllocError || Allocator.Error)!void { self.tmp = std.testing.tmpDir(.{}); self.arena = std.heap.ArenaAllocator.init(allocator); const root = try self.tmp.dir.realPathFileAlloc(std.testing.io, ".", allocator); @@ -1831,7 +1831,7 @@ const IndexFixture = struct { self.tmp.cleanup(); } - fn loader(self: *@This()) !LoadedPacks { + fn loader(self: *@This()) LoadedPacks.LoadError!LoadedPacks { var packs = LoadedPacks.init(allocator); errdefer packs.deinit(); try self.store.loadIdentity(std.testing.io, &packs, .local, identity); @@ -1847,11 +1847,11 @@ const IndexFixture = struct { return .{ .key = @splat(key), .artifact = artifact_index, .rc_borrowed_params = 3, .rc_ret_borrowed = true, .rc_ret_lenders = 2, .rc_read_only_params = 1, .rc_ret_unique = false, .rc_ret_unique_fields = 0, .rc_ret_conditions = &.{1} }; } - fn publish(self: *@This(), artifacts: []const backend.dev.ProcArtifact.Artifact, specs: []const PackFile.SpecEntry, files: []const [32]u8) !void { + fn publish(self: *@This(), artifacts: []const backend.dev.ProcArtifact.Artifact, specs: []const PackFile.SpecEntry, files: []const [32]u8) (Allocator.Error || error{PackWriteFailed})!void { try self.store.writeIndexed(.local, identity, &.{ .arena = undefined, .artifacts = artifacts, .source_files = files }, specs); } - fn payloadPath(self: *@This(), artifact_value: backend.dev.ProcArtifact.Artifact) ![]u8 { + fn payloadPath(self: *@This(), artifact_value: backend.dev.ProcArtifact.Artifact) Allocator.Error![]u8 { const bytes = try Index.payload(allocator, artifact_value); defer allocator.free(bytes); const dir = try self.store.identityDir(.local, identity); From 9f1f8c929142a0a11d98f8707c0c3a353a240294 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sat, 24 Oct 2026 23:42:09 -0400 Subject: [PATCH 04/97] List every case in the completed compilation record switches and document its declarations --- src/cli/pack_store.zig | 2 +- src/compile/completed_compilation.zig | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 3d5e7433b3e..f11eea4d051 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -468,7 +468,7 @@ test "completed compilation interleaved writers expose only complete final bytes try testAtomicPublication(true); } -fn testAtomicPublication(program: bool) !void { +fn testAtomicPublication(program: bool) (Allocator.Error || std.Io.Dir.RealPathFileAllocError || std.Io.Dir.OpenError || std.Io.Dir.Iterator.Error || error{ PackWriteFailed, TestUnexpectedResult, TestExpectedEqual })!void { const Hook = struct { store: *Store, program: bool, diff --git a/src/compile/completed_compilation.zig b/src/compile/completed_compilation.zig index e60a1536997..bba1d7dee21 100644 --- a/src/compile/completed_compilation.zig +++ b/src/compile/completed_compilation.zig @@ -5,12 +5,15 @@ const std = @import("std"); const reporting = @import("reporting"); const Allocator = std.mem.Allocator; +/// A module of the completed compilation, named by its checked identity. pub const Module = struct { key: [32]u8, path: []const u8, identity_path: []const u8 = "", }; +/// A report a module produced, kept as typed document items so its paths +/// can be rebound to the consuming invocation's source paths. pub const Diagnostic = struct { module: [32]u8, title: []const u8, @@ -23,7 +26,9 @@ pub const Diagnostic = struct { /// Checked identities survive arena teardown and are independent of the /// producer's transient Monotype/Solved root positions. pub const Root = struct { module: [32]u8, root: u32 }; +/// A compile-time root the compilation evaluated and the roots it read. pub const RootDemand = struct { root: Root, reads: []const Root }; +/// A literal checked at compile time and the roots its check read. pub const LiteralDemand = struct { module: [32]u8, checked_expr: u32, @@ -31,12 +36,15 @@ pub const LiteralDemand = struct { identity: ?[32]u8, reads: []const Root, }; +/// The compile-time demand one compile-time evaluation recorded. pub const Demand = struct { evaluated: []const RootDemand, stored: []const Root, literals: []const LiteralDemand, }; +/// Everything a completed compilation records for a later invocation to +/// consume in place of evaluating its modules again. pub const Outcome = struct { modules: []const Module = &.{}, diagnostics: []const Diagnostic = &.{}, @@ -55,6 +63,7 @@ pub fn encode(allocator: Allocator, value: anytype) Allocator.Error![]u8 { return out.toOwnedSlice(); } +/// Appends the encoding of one record value to `out`. pub fn write(comptime T: type, out: *std.Io.Writer, value: T) std.Io.Writer.Error!void { switch (@typeInfo(T)) { .void => {}, @@ -73,15 +82,17 @@ pub fn write(comptime T: type, out: *std.Io.Writer, value: T) std.Io.Writer.Erro }, .@"struct" => |info| inline for (info.field_names, info.field_types) |name, Field| try write(Field, out, @field(value, name)), .@"union" => |info| { - try write(info.tag_type.?, out, std.meta.activeTag(value)); - switch (value) { - inline else => |v| try write(@TypeOf(v), out, v), + const tag = std.meta.activeTag(value); + try write(info.tag_type.?, out, tag); + inline for (info.field_names, info.field_types) |name, Field| { + if (tag == @field(info.tag_type.?, name)) try write(Field, out, @field(value, name)); } }, - else => @compileError("unsupported record field: " ++ @typeName(T)), + .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported record field: " ++ @typeName(T)), } } +/// Decodes records written by `write`, rejecting malformed input. pub const Reader = struct { bytes: []const u8, arena: Allocator, @@ -144,7 +155,7 @@ pub const Reader = struct { } return error.InvalidRecord; }, - else => @compileError("unsupported record field: " ++ @typeName(T)), + .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported record field: " ++ @typeName(T)), } } }; @@ -161,10 +172,12 @@ pub fn rebindElements(elements: []reporting.DocumentElement, before: []const Mod v.content = rebindPath(v.content, before, after).?; }, .vertical_stack, .horizontal_concat => |v| rebindElements(v, before, after), - else => {}, + .text, .line_break, .indent, .space, .horizontal_rule, .annotation_start, .annotation_end, .raw, .reflowing_text, .link => {}, }; } +/// The source path in `after` of the module `path` named in `before`, or +/// `path` itself when no module of `before` has it. pub fn rebindPath(path: ?[]const u8, before: []const Module, after: []const Module) ?[]const u8 { const value = path orelse return null; for (before) |old| { From bbdb924002ca35ec9be058620984125a4abf815d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 00:29:12 -0400 Subject: [PATCH 05/97] Reduce dev cache lookup and publication work after edits --- design.md | 175 ++++---- src/backend/dev/PackFile.zig | 2 +- src/cli/main.zig | 131 +++--- src/cli/pack_index.zig | 57 ++- src/cli/pack_store.zig | 636 +++++++++++++++++++-------- src/cli/test/parallel_cli_runner.zig | 56 ++- src/lir/LIR.zig | 3 + src/postcheck/solved_lir_lower.zig | 4 + test/cli/cache_work_bench.py | 120 +++++ test/cli/pack_index_bench.py | 32 +- 10 files changed, 849 insertions(+), 367 deletions(-) create mode 100644 test/cli/cache_work_bench.py diff --git a/design.md b/design.md index b11b8506b98..4430ead22cd 100644 --- a/design.md +++ b/design.md @@ -654,79 +654,81 @@ acquires Boxy code: offer production rejects closures that depend on the program's runtime descriptor tables, and indexed metadata admission rejects runtime references too. The index does not broaden any target compatibility. -Dev offers live under `//offers//.rpi`. The versioned bespoke `ROFFER01` record contains a -little-endian artifact count, one 32-byte payload digest per artifact, and a -payload-free `PackFile` encoding of the producer's explicit dependency -closure and its one specialization entry. This preserves procedure identities, -set-local and symbolic dependencies, relocations, source/debug mappings, -failure meanings, compile-time root reads and every ARC signature/uniqueness -field returned in `SpecCacheHit`. Code and static-data bytes are excluded. -Content names are computed from the original producer-owned data before -separating payloads; neither the writer nor the reader reconstructs them from -stripped metadata. Closure order preserves the original set's first-definition -rule for symbolic references. -The closure includes available symbolic definitions before the consumer's -final procedure set is known. Splicing still lets the current program supply -its own symbolic callees; pinning the producer's closure guarantees availability -for the definitions it does use without consulting other historical offers. - -Each artifact's code and carried data bytes live separately at -`payload/.rpa` under the same module directory. `RPAYLD01` stores -length-prefixed code and data slices in metadata order, with 64-bit -little-endian lengths. One record is one artifact, including its carried data; -reading a chosen artifact does not read unrelated procedures from its original -pack. Offers can share payloads. Metadata duplicates closure descriptions across -specialization keys; it does not duplicate their code/data in memory. An offer -has no process-local addresses or implicit filesystem dependencies. - -`PackOffers` owns eligibility and specialization metadata. While its artifact -set still exists, it publishes each required immutable payload through a unique -staging file and atomic rename, then the content-named offer, and finally a -`ROCINDEX01` completion marker at `.rpd`. This marker records pack -preparation, not a lookup manifest. Each distinct offer has its own immutable -path, so concurrent writers add different offers without a shared manifest or -lost updates. Equal offers have equal bytes. Republishing can repair corrupt -optional records. Interrupted writers can leave unreferenced payloads, but never -make a partially written offer visible. Legacy namespaces are invalidated by -the pack format bump; no historical-directory migration scan is needed. - -`LoadedPacks` registers visible module directories without opening their -historical files. Lookup visits only the requested specialization's bucket, -preserving the checked program's provider order (root first, then its visible -modules), and ordering candidates within each provider by their content digest. -An ineligible candidate cannot hide a later eligible candidate. Before loading -payloads it validates metadata integrity and compatibility with the existing -`reachingAbsentFiles` and `reachingIdentities` rules. In particular, a site-named -symbolic callee is supplied by the current program, so its historical source -version does not invalidate its caller. Linked objects retain their established -precedence and exact-path selection. The completed-result probe does not -consult the dev offer index or include unrelated history in its key. It defers -runtime and compile-time procedure lookups until its result is known: a miss -enables indexed reuse, while a hit leaves demand evaluation using source bodies -and cached compile-time values, preserving all required diagnostics without -reading procedure offers. A completed-result hit also needs no package-pack -preparation. - -Both exact warm lookup (when the completed-result cache misses) and cross-edit -lookup perform O(M) keyed directory probes for M visible modules. Only the K -candidates for the requested key are enumerated, with O(K log K) filename -sorting and metadata reads through the first eligible, available candidate; -unrelated historical keys do not increase metadata bytes read. Candidate -metadata and sorting grow with duplicates of that key, reported separately -from code/data. A repeated eligible lookup uses the in-memory specialization -map and opens no files. Payload bytes and allocations grow only with the -selected explicit dependency closures, not accumulated source history. - -Before returning a body-elision hit, the loader reads and hashes every required -payload and retains its bytes. Missing, truncated, incompatible or corrupt -metadata/payloads are optional cache misses; no promised code can subsequently -disappear through eviction. Successfully read payloads are memoized by digest -and shared by all selected offers until the loader is destroyed. Decoded -metadata also remains alive for every borrower. Source edits can retain and -reuse old code when the existing key and source/debug compatibility rules -permit it; a stale source location is never accepted just because bytes match. -Retaining history is independent of eagerly reading it. +Dev offers live under `/offers// +-.rpi`. The `ROFFER02` binary record names a +shared metadata digest, a specialization ordinal, and the explicit origin and +module identity owning its storage. Provider visibility and rank are supplied +by the checked program, root first. A lookup probes one key bucket per origin, +filters candidates against that provider map, then sorts by provider rank and +content digest. It never probes every visible module or enumerates unrelated +specialization keys. Concurrent writers publish independent immutable records, +so they cannot overwrite another writer's additions. + +One `ROFMETA1` metadata record per producer holds the union of its offered +closures: one payload digest per artifact and a payload-free `PackFile` with +all specialization entries. It is stored at `//metadata/ +.rpm`. Source/debug locations, explicit local and symbolic references, +relocations, failure meanings, compile-time reads and ARC interfaces are +serialized once for all of that producer's offers. The record is read and +decoded once per loader, and shared across hits. Metadata granularity is a +producer's offered union, not a single artifact: selecting an offer can read +metadata for other offers in that producer, but does not read their code/data +or any unrelated producer history. Different producer records can still carry +overlapping descriptions. Cycles remain explicit set-local references; there +is no recursive hash scheme that attempts to infer graph identity. + +Code/data payloads remain independently addressed `RPAYLD01` records at +`//payload/.rpa`. Each record contains length-prefixed +code and carried data slices. Publication deduplicates payload digests within +the producer. Before replacing an existing immutable record, the writer reads +and compares it with the known producer bytes: verified equal bytes need no +write; corrupt or missing records are repaired through staging and atomic +rename. This adds an unsuccessful read on an empty cache, and a verification +read on a warm cache, in exchange for avoiding repeated writes. It never scans +historical records. Payloads and shared metadata publish before their offers; +the `ROCINDEX01` completion marker publishes last. + +`LoadedPacks` memoizes misses under the specialization key, code-provision +policy, and exact program-module digest. Its index snapshot lasts until +`refreshIndex`, a new visible provider, or a new compilation. Local producers +explicitly refresh readers after publication, including publication to an +already registered provider. Concurrent external publications become visible +on the next refresh or compilation. Missing/corrupt selected records, transient +I/O failures, allocation failures, and lookups deferred for a completed-output +probe are not memoized as absence. Provider registration does not read history. +For U distinct requests and M visible modules, filesystem probes depend on U +and the number of cache origins, not U*M or repeated requests. Eligibility +rejections remain scoped to the exact request context. + +Selection uses the existing `reachingAbsentFiles` and `reachingIdentities` +contracts before payload I/O. Site-named symbolic callees supplied by the +current program do not invalidate their caller merely because their historical +source version changed. Linked-object precedence remains intact. The loader +traverses the producer's explicit dependency graph and pins every required +payload before promising a body-elision hit. It memoizes materialization per +artifact and payload bytes by digest. Eviction cannot invalidate a served hit; +missing or corrupt optional storage is a miss. Available symbolic definitions +are retained because the consumer's final procedure set is not yet known; +splicing still gives the current program ownership of its symbolic callees. + +Runtime builds publish artifacts from their actual compilation, without +compiling extra module-export programs to populate the cache. Direct LIR stamps +`cache_provider_module` from a direct template's checked owner only when +`fnReachesAppRecord` says it is app-independent. This is producer metadata, not +a source-location or filename inference. Nested, generated-without-template, +and app-dependent procedures remain program-scoped; Builtin instantiations +also belong to the producing program. A reusable offer is routed under its +checked provider while referencing the producer's shared storage. The existing +specialization keys, ownership interfaces, source compatibility, runtime-table +restrictions and compile-time demand rules still govern admission. Publication +of a runtime subset is not a claim that every export of a module was compiled. + +The completed-result probe defers procedure and discovery lookups until its +result is known. A miss enables indexed reuse; a hit uses source demand and +cached compile-time values to preserve diagnostics without procedure discovery. +Unrelated history does not enter its key. Retaining historical artifacts is +independent of eagerly reading them. The pack-format bump invalidates prior +namespaces without a migration scan. Optimized object packs retain their existing locations: a package artifact's `.rpk` and the program's `optimized/.rpk`. Explicit @@ -739,7 +741,8 @@ removed first: a stale reference is detected before a hit, and live readers own their bytes. Unused historical payloads remain available until eviction. `ROC_PACK_STATS`, through the existing gated `base.dev_diagnostics` interface, -reports metadata bytes, candidate/duplicate counts, payload open attempts, +reports direct bucket opens and absences, negative-cache hits, shared metadata +opens/bytes, candidate/duplicate counts, payload open attempts, unique successfully retained payload records, payload bytes, decoded records, requests/hits and missing/corrupt/source/linked rejection counts. Publication also reports metadata/payload bytes and elapsed wall-clock nanoseconds. @@ -841,20 +844,16 @@ of its program. The first pack to offer a key or a procedure serves it, so which pack serves a procedure two offer, and the order the program links them in, are functions of the optimization state alone. -After version resolution selects the final package versions and checking -publishes their constants, caching check, run, and build commands prepare the URL -packages' host dev packs under `-dev/pkg/`. This includes `roc check`, -so a package checked when downloaded already has its dev packs when its first -app build starts. Preparation compiles each module's closed exports with the -ordinary dev pack policy; it neither writes local-module packs nor starts -background optimization. Existing packs are left alone, and missing or evicted -packs are rebuilt from the checked modules. A command with errors or -`--no-cache` prepares none. -Newly published index directories are registered with the command's cache -providers, so runtime lowering sees the new offers even if compile-time -evaluation already registered the store. Selected metadata and payloads retain -stable addresses as new offers arrive; artifacts already handed to evaluation -remain valid. +A caching `roc check` may deliberately prepare URL-package host dev packs for +independent module exports. It excludes modules whose checked context or +explicit visible dependency closure carries a platform/app relation, so a +paired platform entrypoint cannot reach into the application for cache warming. +Normal runtime builds and runs do not perform this extra compilation. Existing +independent package packs remain usable. Checks with errors and `--no-cache` +prepare none. The check reporter separates cache preparation/publication from +frontend time; runtime builds report compile-time and runtime cache publication +as explicit phases. Publication diagnostics count written/reused records and +actual bytes written separately from elapsed publication time. The wasm32 dev backend cannot splice dev code, so a wasm32 dev build reads and writes no dev packs and takes only object packs' entries diff --git a/src/backend/dev/PackFile.zig b/src/backend/dev/PackFile.zig index f6145a2ccd4..d7f5fd8772f 100644 --- a/src/backend/dev/PackFile.zig +++ b/src/backend/dev/PackFile.zig @@ -23,7 +23,7 @@ const Allocator = std.mem.Allocator; const magic = "RPCK"; /// Format version; bump whenever the encoding or artifact contents change. -pub const format_version: u32 = 13; +pub const format_version: u32 = 14; /// One specialization the pack can serve: its reservation-time key, the /// artifact holding its procedure, and the ownership signature and diff --git a/src/cli/main.zig b/src/cli/main.zig index 35ff8acd2a1..5cb5d559c57 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -6905,7 +6905,6 @@ fn lowerDiscoveredLir( }; const counts = try renderDrainedBuildEnvReports(ctx, build_env, display_path); - if (counts.errors == 0) try preparePackageDevPacks(ctx, build_env, if (object_cache) |cache| &cache.packs else null); const watch_inputs = try build_env.collectWatchInputStates(); errdefer compile.watch_inputs.deinit(ctx.gpa, watch_inputs); @@ -8543,6 +8542,13 @@ fn writePackageDevPacksToStore( std.debug.print("dev pack candidate {s} ({s})\n", .{ artifact.canonical_names.moduleNameText(artifact.module_identity.module_name), @tagName(placement.origin) }); } if (placement.origin != .pkg) continue; + if (artifact.checking_context_identity.platform_app_relation != null) continue; + const dependencies = try build_env.collectVisibleArtifacts(ctx.gpa, artifact); + defer ctx.gpa.free(dependencies); + const paired_dependency = for (dependencies) |dependency| { + if (dependency.checking_context_identity.platform_app_relation != null) break true; + } else false; + if (paired_dependency) continue; if (try store.hasIndexed(placement.origin, placement.identity, artifact.codeGenerationKey().bytes)) continue; const roots = try lir.PackProgram.closedExportRoots(ctx.gpa, artifact); defer ctx.gpa.free(roots); @@ -8553,7 +8559,7 @@ fn writePackageDevPacksToStore( var pack = try compileModulePack(ctx, build_env, root_artifact, imports, relations, artifact, roots, .dev, .lss, RocTarget.detectNative(), false); defer pack.deinit(); const set = &pack.compiled.artifacts.?; - try publishDevPack(store, placement.origin, placement.identity, artifact.codeGenerationKey().bytes, set, &pack.lowered); + try publishDevPack(build_env, store, placement.origin, placement.identity, artifact.codeGenerationKey().bytes, set, &pack.lowered); for ([_]?*pack_store.LoadedPacks{ offers, runtime_packs }) |maybe_packs| { const packs = maybe_packs orelse continue; try packs.offerIndexed(store, placement.origin, placement.identity); @@ -8561,24 +8567,14 @@ fn writePackageDevPacksToStore( } } -/// Write this build's packs into the object cache: the root module's pack -/// from the artifacts of the program just compiled, and a pack program for -/// every other module in view whose pack is not in the store yet, except the -/// app module when the platform is the root, since no other program imports -/// it. Packs are -/// filed by module identity and artifact key, so an unchanged module's pack -/// is found and left alone. +/// Publish reusable offers from the runtime compilation's own artifacts. fn writePacksToStore( ctx: *CliCtx, build_env: *BuildEnv, store: *const pack_store.Store, root_artifact: *const check.CheckedArtifact.CheckedModuleArtifact, - app_imports: []const check.CheckedArtifact.ImportedModuleView, - app_relations: []const check.CheckedArtifact.ImportedModuleView, app_lowered: *const lir.CheckedPipeline.LoweredProgram, app_artifacts: ?*const backend.dev.ProcArtifact.Set, - args: cli_args.BuildArgs, - target: RocTarget, ) CliMainError!void { if (dev_diagnostics.isSet(.pack_trace)) { // What this program lowered from source: every procedure by identity, @@ -8600,37 +8596,9 @@ fn writePacksToStore( } if (app_artifacts) |set| { if (build_env.packPlacementForArtifactKey(root_artifact.key)) |placement| { - try publishDevPack(store, placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes, set, app_lowered); + try publishDevPack(build_env, store, placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes, set, app_lowered); } } - // An app module is never imported, so its procedures only ever link into - // builds of that app, which read the pack written above from the - // program just compiled. A pack program of its own would only repeat it. - const app_key: ?check.CheckedArtifact.CheckedModuleArtifactKey = if (build_env.getAppSemanticData()) |app| - if (app.checked_artifact) |app_artifact| app_artifact.key else null - else - null; - const artifacts = try build_env.collectVisibleArtifacts(ctx.gpa, root_artifact); - defer ctx.gpa.free(artifacts); - for (artifacts) |artifact| { - if (app_key) |key| { - if (std.meta.eql(artifact.key, key)) continue; - } - const placement = build_env.packPlacementForArtifactKey(artifact.key) orelse continue; - const origin = placement.origin; - const identity = placement.identity; - if (try store.hasIndexed(origin, identity, artifact.codeGenerationKey().bytes)) continue; - const roots = try lir.PackProgram.closedExportRoots(ctx.gpa, artifact); - defer ctx.gpa.free(roots); - if (roots.len == 0) continue; - if (dev_diagnostics.isSet(.pack_trace)) { - std.debug.print("module pack {s}\n", .{artifact.canonical_names.moduleNameText(artifact.module_identity.module_name)}); - } - var pack = try compileModulePack(ctx, build_env, root_artifact, app_imports, app_relations, artifact, roots, args.opt, currentRuntimeSpecializationStrategy(args.specialization_strategy), target, false); - defer pack.deinit(); - const set = &(pack.compiled.artifacts orelse continue); - try publishDevPack(store, origin, identity, artifact.codeGenerationKey().bytes, set, &pack.lowered); - } } /// The optimization level whose object packs a dev build for `target` ranks @@ -9220,7 +9188,8 @@ fn publishCompileTimePack(ctx: *CliCtx, build_env: *BuildEnv) Allocator.Error!vo // its own program, and the pack offers the procedures of all of them. var combined = try completed.combinedArtifacts(ctx.gpa); defer combined.deinit(); - try parts.offers.publish(store, destination.placement.origin, destination.placement.identity, destination.key, &combined); + try parts.offers.publish(build_env, store, destination.placement.origin, destination.placement.identity, destination.key, &combined); + cache.packs.refreshIndex(); } /// File the discovery summaries of a compilation that reported no error beside @@ -9277,12 +9246,12 @@ fn packFileBytes( return offers.encode(allocator, set); } -fn publishDevPack(store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set, lowered: *const lir.CheckedPipeline.LoweredProgram) Allocator.Error!void { +fn publishDevPack(build_env: *const BuildEnv, store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set, lowered: *const lir.CheckedPipeline.LoweredProgram) Allocator.Error!void { const reads = if (lowered.spec_comptime_reads) |*reads| reads else base.invariant("a dev pack producer recorded compile-time reads", .{}); var offers = PackOffers.init(store.allocator, false); defer offers.deinit(); try offers.addProgram(lowered, reads); - try offers.publish(store, origin, identity, key, set); + try offers.publish(build_env, store, origin, identity, key, set); } /// The procedures a pack may offer, gathered from the programs it is written @@ -9308,7 +9277,7 @@ const PackOffers = struct { /// Every key gathered. A key two programs offer names one procedure, /// offered once, from the first program; identity keys are hashed apart /// from specialization keys, so the two kinds never share one. - keys: std.AutoHashMap([32]u8, void), + keys: std.AutoHashMap([32]u8, ?[32]u8), const Candidate = struct { key: [32]u8, @@ -9334,7 +9303,7 @@ const PackOffers = struct { .arena = base.SingleThreadArena.init(allocator), .evaluation = evaluation, .literal_converters = std.AutoHashMap(lir.ProcIdentity, void).init(allocator), - .keys = std.AutoHashMap([32]u8, void).init(allocator), + .keys = std.AutoHashMap([32]u8, ?[32]u8).init(allocator), }; } @@ -9358,7 +9327,9 @@ const PackOffers = struct { if (converts) try self.literal_converters.put(proc.identity, {}); } for (lowered.lir_result.spec_procs.items) |spec_proc| { - if ((try self.keys.getOrPut(spec_proc.key)).found_existing) continue; + const key_entry = try self.keys.getOrPut(spec_proc.key); + if (key_entry.found_existing) continue; + key_entry.value_ptr.* = store.getProcSpecImmediate(spec_proc.proc).cache_provider_module; try self.keyed.append(self.arena.allocator(), try self.candidate(lowered, spec_proc.proc, spec_proc.key, comptime_reads.get(spec_proc.key))); } const carriers = try lir.PackProgram.siteCodeCarriers(allocator, store); @@ -9373,7 +9344,9 @@ const PackOffers = struct { // pack carries with it. if (carries_site_code) continue; const key = postcheck.Common.procIdentityCacheKey(if (proc.code_key) |code| code else proc.identity.bytes); - if ((try self.keys.getOrPut(key)).found_existing) continue; + const key_entry = try self.keys.getOrPut(key); + if (key_entry.found_existing) continue; + key_entry.value_ptr.* = proc.cache_provider_module; try self.by_identity.append(self.arena.allocator(), try self.candidate(lowered, id, key, comptime_reads.fnReads(source_fn))); } } @@ -9484,10 +9457,23 @@ const PackOffers = struct { return backend.dev.PackFile.write(allocator, set, specs); } - fn publish(self: *const PackOffers, store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set) Allocator.Error!void { + fn publish(self: *const PackOffers, build_env: *const BuildEnv, store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set) Allocator.Error!void { const specs = try self.entries(store.allocator, set); defer store.allocator.free(specs); - store.writeIndexed(origin, identity, set, specs) catch |err| switch (err) { + const providers = try store.allocator.alloc(BuildEnv.PackPlacement, specs.len); + defer store.allocator.free(providers); + for (specs, providers) |spec, *provider| { + provider.* = .{ .origin = origin, .identity = identity }; + if (self.keys.get(spec.key).?) |module| { + // Builtin instantiations belong to the producer; every other + // stamped module has a checked provider in this compilation. + if (!std.mem.eql(u8, &module, &build_env.builtin_modules.checked_artifact.key.bytes)) { + provider.* = build_env.packPlacementForArtifactKey(.{ .bytes = module }) orelse + base.invariant("a cache offer's checked source module has a provider", .{}); + } + } + } + store.writeIndexedForProviders(origin, identity, set, specs, providers) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.PackWriteFailed => return, }; @@ -11310,7 +11296,6 @@ fn typeCheckBuildCommand( try finishFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), reporter, build_env.getTimingInfo()); const diag = try build_env.renderDiagnostics(ctx.io.stderr(), ctx.reportConfig(.stderr)); - if (diag.errors == 0 and !build_env.runtimeOutputCached()) try preparePackageDevPacks(ctx, build_env, null); const total_warning_count = diag.warnings + try optimizedDbgWarningsForBuild(ctx, build_env, args.opt); const resolved_targets_config = build_env.getPlatformTargetsConfig() orelse { try renderProblem(ctx, .{ @@ -11496,7 +11481,11 @@ fn rocBuildLlvm(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResult // A build files the code its compile-time evaluation ran for later // compilations as a check does, before the runtime program takes the // shared Solved program. - if (diag.errors == 0) try publishCompileTimeResults(ctx, &build_env, root_artifact); + if (diag.errors == 0) { + reporter.begin("Publishing Compile-Time Cache"); + try publishCompileTimeResults(ctx, &build_env, root_artifact); + reporter.end(); + } var lowered = try lowerBuildCommandToLir(ctx, args, &build_env, &reporter, root_artifact, imported_artifacts, relation_artifacts, .linked_output, target, args.synthetic_default_platform, null, .source_bodies); defer lowered.deinit(); @@ -11836,7 +11825,12 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu // A build files the code its compile-time evaluation ran for later // compilations as a check does, before the runtime program takes the // shared Solved program. - if (diag.errors == 0) try publishCompileTimeResults(ctx, &build_env, root_artifact); + if (diag.errors == 0) { + reporter.begin("Publishing Compile-Time Cache"); + try publishCompileTimeResults(ctx, &build_env, root_artifact); + if (loaded_packs) |packs| packs.refreshIndex(); + reporter.end(); + } var lowered = try lowerBuildCommandToLir(ctx, args, &build_env, &reporter, root_artifact, imported_artifacts, relation_artifacts, .linked_output, target, args.synthetic_default_platform, if (loaded_packs) |packs| packs.specCacheLookup() else null, nativeObjectCodeProvision(target, args.opt, loaded_packs != null, linked_stores != null)); defer lowered.deinit(); // The program links the objects defining the entries it took, and no @@ -11939,7 +11933,9 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu // and so skip its diagnostic, so a build with errors writes no packs. if (object_store != null and diag.errors == 0) { const store = object_store.?; - try writePacksToStore(ctx, &build_env, store, root_artifact, imported_artifacts, relation_artifacts, &lowered, if (object_compiler.captured_artifacts) |*set| set else null, args, target); + reporter.begin("Publishing Runtime Cache"); + try writePacksToStore(ctx, &build_env, store, root_artifact, &lowered, if (object_compiler.captured_artifacts) |*set| set else null); + reporter.end(); } const link_facts: ProgramOutput.LinkFacts = .{ @@ -11953,7 +11949,9 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu // linked pack objects from the object cache, so only a program without // either records its output. if (program_probe) |*probe| if (probe.key) |key| if (diag.errors == 0 and !uses_boxy and linked_objects.len == 0) { + reporter.begin("Publishing Output Cache"); try probe.record(key, obj_path, link_facts); + reporter.end(); }; reporter.begin("Preparing Link Inputs"); @@ -13692,7 +13690,7 @@ fn checkedRuntimeLoweringConfig( .scalarize_joins = optimizeLirForOpt(opt), .reuse_boxes = optimizeLirForOpt(opt), .thread_confined_rc = optimizeLirForOpt(opt), - .proc_debug_names = proc_debug_names, + .proc_debug_names = proc_debug_names or dev_diagnostics.isSet(.pack_trace), }, }; } @@ -18394,7 +18392,16 @@ test "timings display every Monotype graph counter" { /// Packages without a shorthand are labeled by their path, which is shown /// relative to `config`'s working directory. fn finishFrontEndPhase(gpa: Allocator, config: reporting.ReportingConfig, reporter: *progress.Reporter, timing: anytype) Allocator.Error!void { - reporter.endWithBreakdownExcluding(&frontEndBreakdown(timing), timing.program_finalization_ns); + return finishFrontEndPhaseExcluding(gpa, config, reporter, timing, 0); +} + +fn finishCheckFrontEndPhase(gpa: Allocator, config: reporting.ReportingConfig, reporter: *progress.Reporter, result: *const CheckResult) Allocator.Error!void { + try finishFrontEndPhaseExcluding(gpa, config, reporter, result.timing, result.cache_preparation_ns); + reporter.recordCompletedWithBreakdown("Preparing Cache", result.cache_preparation_ns, .{}, &.{}); +} + +fn finishFrontEndPhaseExcluding(gpa: Allocator, config: reporting.ReportingConfig, reporter: *progress.Reporter, timing: anytype, extra_excluded_ns: u64) Allocator.Error!void { + reporter.endWithBreakdownExcluding(&frontEndBreakdown(timing), timing.program_finalization_ns + extra_excluded_ns); const slowest = timing.slowest_type_checks.slice(); var names = std.heap.ArenaAllocator.init(gpa); defer names.deinit(); @@ -18757,6 +18764,7 @@ fn handleProcessFileError(err: ProcessFileError, stderr: anytype, path: []const /// Result from checking a file using BuildEnv const CheckResult = struct { + cache_preparation_ns: u64 = 0, reports: []DrainedReport, timing: CheckTimingInfo = if (builtin.target.cpu.arch == .wasm32) .{} else .{ .tokenize_parse_ns = 0, @@ -19071,6 +19079,7 @@ fn collectCheckResult( if (report.severity.isError()) error_count += 1 else warning_count += 1; } } + const cache_start = std.Io.Timestamp.now(ctx.io.std_io, .awake).nanoseconds; if (error_count == 0) { if (build_env.getExecutableRootSemanticData()) |root_semantic| if (root_semantic.checked_artifact) |root_artifact| { try publishCompileTimeResults(ctx, build_env, root_artifact); @@ -19078,6 +19087,7 @@ fn collectCheckResult( try preparePackageDevPacks(ctx, build_env, null); } + const cache_preparation_ns: u64 = @intCast(std.Io.Timestamp.now(ctx.io.std_io, .awake).nanoseconds - cache_start); var reports = try ctx.gpa.alloc(DrainedReport, drained.len); for (drained, 0..) |mod, i| { reports[i] = .{ @@ -19088,6 +19098,7 @@ fn collectCheckResult( const cache_stats = build_env.getBuildStats(); var result = CheckResult{ + .cache_preparation_ns = cache_preparation_ns, .reports = reports, .error_count = error_count, .warning_count = warning_count, @@ -19334,7 +19345,7 @@ fn rocCheck(ctx: *CliCtx, args_in: cli_args.CheckArgs, arg0: []const u8) RocChec if (builtin.target.cpu.arch == .wasm32) { reporter.end(); } else { - try finishFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), &reporter, check_result.timing); + try finishCheckFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), &reporter, check_result); } reporter.finish(); @@ -19367,7 +19378,7 @@ fn rocCheck(ctx: *CliCtx, args_in: cli_args.CheckArgs, arg0: []const u8) RocChec if (builtin.target.cpu.arch == .wasm32) { reporter.end(); } else { - try finishFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), &reporter, check_result.timing); + try finishCheckFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), &reporter, check_result); } reporter.finish(); @@ -19404,7 +19415,7 @@ fn rocCheck(ctx: *CliCtx, args_in: cli_args.CheckArgs, arg0: []const u8) RocChec if (builtin.target.cpu.arch == .wasm32) { reporter.end(); } else { - try finishFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), &reporter, check_result.timing); + try finishCheckFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), &reporter, &check_result); } reporter.finish(); diff --git a/src/cli/pack_index.zig b/src/cli/pack_index.zig index 3bf6a9131e6..e3f689b9a81 100644 --- a/src/cli/pack_index.zig +++ b/src/cli/pack_index.zig @@ -1,5 +1,5 @@ //! Persistent keyed dev offers. Metadata is a payload-free pack of one -//! producer-declared dependency closure; each artifact's bytes are addressed +//! producer-declared union of dependency closures; each artifact's bytes are addressed //! separately by SHA-256. No directory of historical packs is consulted. const std = @import("std"); const base = @import("base"); @@ -10,7 +10,9 @@ const PF = backend.dev.PackFile; const Allocator = std.mem.Allocator; /// Magic and schema version of an offer metadata record. -pub const magic = "ROFFER01"; +pub const magic = "ROFFER02"; +/// Shared producer metadata, independent of specialization offers and payloads. +pub const metadata_magic = "ROFMETA1"; /// Magic and schema version of an independently addressed payload record. pub const payload_magic = "RPAYLD01"; /// Optional cache records can fail validation or allocation. @@ -40,9 +42,9 @@ pub fn payload(allocator: Allocator, artifact: PA.Artifact) Allocator.Error![]u8 return bytes.toOwnedSlice(allocator); } -/// The producer supplies the exact splice closure, in original set order. +/// The producer supplies the union of offered splice closures, in original set order. /// Content naming uses the original bytes, never the stripped metadata. -pub fn encode(allocator: Allocator, set: *const PA.Set, spec: PF.SpecEntry, closure: []const u32, hashes: []const [32]u8, names: *const PA.ContentNames) Allocator.Error![]u8 { +pub fn encodeMetadata(allocator: Allocator, set: *const PA.Set, specs: []const PF.SpecEntry, closure: []const u32, hashes: []const [32]u8, names: *const PA.ContentNames) Allocator.Error![]u8 { var arena = base.SingleThreadArena.init(allocator); defer arena.deinit(); const scratch = arena.allocator(); @@ -63,13 +65,15 @@ pub fn encode(allocator: Allocator, set: *const PA.Set, spec: PF.SpecEntry, clos for (data) |*item| item.bytes = &.{}; artifact.data = data; } - var entry = spec; - entry.artifact = map[spec.artifact]; - std.debug.assert(entry.artifact != std.math.maxInt(u32)); - const metadata = try PF.writeWithNames(scratch, &.{ .arena = undefined, .artifacts = artifacts, .source_files = set.source_files }, &.{entry}, names); + const entries = try scratch.dupe(PF.SpecEntry, specs); + for (entries) |*entry| { + entry.artifact = map[entry.artifact]; + std.debug.assert(entry.artifact != std.math.maxInt(u32)); + } + const metadata = try PF.writeWithNames(scratch, &.{ .arena = undefined, .artifacts = artifacts, .source_files = set.source_files }, entries, names); var bytes = std.ArrayList(u8).empty; errdefer bytes.deinit(allocator); - try bytes.appendSlice(allocator, magic); + try bytes.appendSlice(allocator, metadata_magic); var count: [4]u8 = undefined; std.mem.writeInt(u32, &count, std.math.cast(u32, closure.len) orelse return error.OutOfMemory, .little); try bytes.appendSlice(allocator, &count); @@ -78,21 +82,21 @@ pub fn encode(allocator: Allocator, set: *const PA.Set, spec: PF.SpecEntry, clos return bytes.toOwnedSlice(allocator); } -/// An owned decoded offer and the explicit digests of its required payloads. -pub const Offer = struct { +/// Owned shared producer metadata and the explicit digests of its payloads. +pub const Metadata = struct { pack: PF.Pack, hashes: []const [32]u8, }; /// Own both the metadata and its decoding; reject foreign/corrupt optional data. -pub fn decode(allocator: Allocator, bytes: []const u8, key: [32]u8) Error!Offer { - if (bytes.len < 12 or !std.mem.eql(u8, bytes[0..8], magic)) return error.MalformedPack; +pub fn decodeMetadata(allocator: Allocator, bytes: []const u8) Error!Metadata { + if (bytes.len < 12 or !std.mem.eql(u8, bytes[0..8], metadata_magic)) return error.MalformedPack; const count = std.mem.readInt(u32, bytes[8..12], .little); if (count > (bytes.len - 12) / 32) return error.MalformedPack; var pack = try PF.read(allocator, bytes[12 + @as(usize, count) * 32 ..]); errdefer pack.deinit(); - if (pack.set.artifacts.len != count or pack.specs.len != 1 or !std.mem.eql(u8, &pack.specs[0].key, &key)) return error.MalformedPack; - if (pack.set.artifacts[pack.specs[0].artifact].kind != .proc) return error.MalformedPack; + if (pack.set.artifacts.len != count) return error.MalformedPack; + for (pack.specs) |spec| if (pack.set.artifacts[spec.artifact].kind != .proc) return error.MalformedPack; for (pack.set.artifacts) |artifact| { if (artifact.code.len != 0 or artifact.runtime_refs.len != 0) return error.MalformedPack; for (artifact.data) |item| if (item.bytes.len != 0) return error.MalformedPack; @@ -102,6 +106,29 @@ pub fn decode(allocator: Allocator, bytes: []const u8, key: [32]u8) Error!Offer return .{ .pack = pack, .hashes = hashes }; } +/// A keyed offer references one entry of shared immutable producer metadata. +pub const Offer = struct { + metadata: [32]u8, + spec: u32, + origin: enum(u8) { local, pkg }, + owner: [32]u8, + + pub fn encode(self: Offer) [77]u8 { + var bytes: [77]u8 = undefined; + bytes[0..8].* = magic.*; + bytes[8..40].* = self.metadata; + std.mem.writeInt(u32, bytes[40..44], self.spec, .little); + bytes[44] = @backingInt(self.origin); + bytes[45..77].* = self.owner; + return bytes; + } + + pub fn decode(bytes: []const u8) Error!Offer { + if (bytes.len != 77 or !std.mem.eql(u8, bytes[0..8], magic)) return error.MalformedPack; + return .{ .metadata = bytes[8..40].*, .spec = std.mem.readInt(u32, bytes[40..44], .little), .origin = std.enums.fromInt(@FieldType(Offer, "origin"), bytes[44]) orelse return error.MalformedPack, .owner = bytes[45..77].* }; + } +}; + /// Bytes are owned by the loader, and remain live until all its consumers end. pub fn materialize(artifact: *PA.Artifact, bytes: []const u8) Error!void { if (!std.mem.startsWith(u8, bytes, payload_magic)) return error.MalformedPack; diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 90bac9036de..1d0117f86eb 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -170,11 +170,21 @@ pub const Store = struct { /// offers. Writers never replace a shared manifest, so concurrent additions /// cannot lose one another. The caller publishes its completion marker last. pub fn writeIndexed(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, set: *const backend.dev.ProcArtifact.Set, specs: []const PackFile.SpecEntry) (Allocator.Error || error{PackWriteFailed})!void { + return self.writeIndexedForProviders(origin, identity, set, specs, null); + } + + /// Publish one producer's shared records and route each offer to its + /// explicitly stamped provider. Null routes every offer to the producer. + pub fn writeIndexedForProviders(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, set: *const backend.dev.ProcArtifact.Set, specs: []const PackFile.SpecEntry, providers: ?[]const compile.BuildEnv.PackPlacement) (Allocator.Error || error{PackWriteFailed})!void { const tracing = base.dev_diagnostics.isSet(.pack_stats); const start = if (tracing) std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds else 0; var metadata_bytes: u64 = 0; var payload_bytes: u64 = 0; - defer if (tracing) std.debug.print("pack index publication: offers={d} metadata_bytes={d} payload_bytes={d} wall_ns={d}\n", .{ specs.len, metadata_bytes, payload_bytes, std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds - start }); + var records_written: u64 = 0; + var records_reused: u64 = 0; + defer if (tracing) std.debug.print("pack index publication: offers={d} metadata_bytes={d} payload_bytes={d} records_written={d} records_reused={d} wall_ns={d}\n", .{ specs.len, metadata_bytes, payload_bytes, records_written, records_reused, std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds - start }); + if (providers) |locations| std.debug.assert(locations.len == specs.len); + if (specs.len == 0) return; const allocator = self.allocator; const dir = try self.identityDir(origin, identity); defer allocator.free(dir); @@ -182,34 +192,64 @@ pub const Store = struct { defer names.deinit(); var closure = try SpliceClosure.init(allocator, set); defer closure.deinit(); + const included = try allocator.alloc(bool, set.artifacts.len); + defer allocator.free(included); + @memset(included, false); + for (specs) |spec| for (try closure.of(spec.artifact)) |index| { + included[index] = true; + }; + var union_indices = std.ArrayList(u32).empty; + defer union_indices.deinit(allocator); + for (included, 0..) |include, index| if (include) { + try union_indices.append(allocator, @intCast(index)); + }; const hashes = try allocator.alloc([32]u8, set.artifacts.len); defer allocator.free(hashes); - const written = try allocator.alloc(bool, set.artifacts.len); - defer allocator.free(written); - @memset(written, false); - for (specs) |spec| { - const reached = try closure.of(spec.artifact); - // Preserve the original first-definition rule for symbolic refs. - std.mem.sort(u32, closure.order.items, {}, std.sort.asc(u32)); - for (reached) |index| { - if (written[index]) continue; - const bytes = try Index.payload(allocator, set.artifacts[index]); - defer allocator.free(bytes); - hashes[index] = Index.digest(bytes); - const name = std.fmt.bytesToHex(hashes[index], .lower) ++ ".rpa".*; - const path = try std.fs.path.join(allocator, &.{ dir, "payload", &name }); - defer allocator.free(path); - try self.publishIndexedPath(path, bytes); + var published = std.AutoHashMap([32]u8, void).init(allocator); + defer published.deinit(); + for (union_indices.items) |index| { + const bytes = try Index.payload(allocator, set.artifacts[index]); + defer allocator.free(bytes); + const hash = Index.digest(bytes); + hashes[index] = hash; + if ((try published.getOrPut(hash)).found_existing) continue; + const name = std.fmt.bytesToHex(hash, .lower) ++ ".rpa".*; + const path = try std.fs.path.join(allocator, &.{ dir, "payload", &name }); + defer allocator.free(path); + if (try self.publishIndexedPath(path, bytes)) { payload_bytes += bytes.len; - written[index] = true; + records_written += 1; + } else { + records_reused += 1; } - const bytes = try Index.encode(allocator, set, spec, reached, hashes, &names); - defer allocator.free(bytes); - const name = std.fmt.bytesToHex(Index.digest(bytes), .lower) ++ ".rpi".*; - const path = try std.fs.path.join(allocator, &.{ dir, "offers", &std.fmt.bytesToHex(spec.key, .lower), &name }); + } + const metadata = try Index.encodeMetadata(allocator, set, specs, union_indices.items, hashes, &names); + defer allocator.free(metadata); + const metadata_hash = Index.digest(metadata); + const metadata_name = std.fmt.bytesToHex(metadata_hash, .lower) ++ ".rpm".*; + const metadata_path = try std.fs.path.join(allocator, &.{ dir, "metadata", &metadata_name }); + defer allocator.free(metadata_path); + if (try self.publishIndexedPath(metadata_path, metadata)) { + metadata_bytes += metadata.len; + records_written += 1; + } else { + records_reused += 1; + } + for (specs, 0..) |spec, spec_index| { + const provider = if (providers) |locations| locations[spec_index] else compile.BuildEnv.PackPlacement{ .origin = origin, .identity = identity }; + const bytes = (Index.Offer{ .metadata = metadata_hash, .spec = @intCast(spec_index), .origin = switch (origin) { + .local => .local, + .pkg => .pkg, + }, .owner = identity }).encode(); + const name = std.fmt.bytesToHex(provider.identity, .lower) ++ "-".* ++ std.fmt.bytesToHex(Index.digest(&bytes), .lower) ++ ".rpi".*; + const path = try std.fs.path.join(allocator, &.{ self.root, @tagName(provider.origin), "offers", &std.fmt.bytesToHex(spec.key, .lower), &name }); defer allocator.free(path); - try self.publishIndexedPath(path, bytes); - metadata_bytes += bytes.len; + if (try self.publishIndexedPath(path, &bytes)) { + metadata_bytes += bytes.len; + records_written += 1; + } else { + records_reused += 1; + } } } @@ -230,12 +270,22 @@ pub const Store = struct { pub fn completeIndexed(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8) (Allocator.Error || error{PackWriteFailed})!void { const path = try self.filePath(origin, identity, key, ".rpd"); defer self.allocator.free(path); - try self.publishIndexedPath(path, "ROCINDEX01"); + _ = try self.publishIndexedPath(path, "ROCINDEX01"); } /// Replacing a corrupt optional record is repair, not an invariant failure. /// Identical concurrent publishers have identical content-addressed bytes. - fn publishIndexedPath(self: *const Store, path: []const u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + fn publishIndexedPath(self: *const Store, path: []const u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!bool { + // Verify immutable bytes before skipping a write. Corrupt records are + // repaired, and a vanished record is republished rather than trusted. + const existing = self.roc_ctx.readFile(path, self.allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => null, + }; + if (existing) |old| { + defer self.allocator.free(old); + if (std.mem.eql(u8, old, bytes)) return false; + } self.roc_ctx.makePath(std.fs.path.dirname(path).?) catch return error.PackWriteFailed; const staged = self.roc_ctx.writeTempFile(self.allocator, path, bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, @@ -244,6 +294,7 @@ pub const Store = struct { defer self.allocator.free(staged); defer self.roc_ctx.deleteFile(staged) catch {}; self.roc_ctx.rename(staged, path) catch return error.PackWriteFailed; + return true; } /// Stage `bytes` in a file only this writer owns, beside `path`, and @@ -637,7 +688,7 @@ test "the cache sweep finds and ages the packs the store writes" { var stats: compile.CacheCleanup.CleanupStats = .{}; compile.CacheCleanup.sweep(io, cache_root, limits, now_ns + 2 * compile.CacheCleanup.Config.LOCAL_PACK_MAX_AGE_NS, &stats); try std.testing.expectEqual(@as(u32, 1), stats.stale_namespaces_deleted); - try std.testing.expectEqual(@as(u32, 3), stats.cache_files_deleted); + try std.testing.expectEqual(@as(u32, 4), stats.cache_files_deleted); try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().access(io, stale_dir, .{})); try std.testing.expect(!try store.has(.local, local_identity, key)); try std.testing.expect(try store.has(.pkg, pkg_identity, key)); @@ -854,9 +905,27 @@ pub const LoadedPacks = struct { pack: u32, }; + const IndexedMetadata = struct { + pack: *PackFile.Pack, + hashes: []const [32]u8, + closure: SpliceClosure, + materialized: []bool, + }; + const MissKey = struct { + key: [32]u8, + takes: Common.SpecCacheTakes, + modules: [32]u8, + }; + const IndexedResult = enum { hit, miss, unavailable }; + allocator: Allocator, index_dirs: std.ArrayList([]const u8) = .empty, index_io: ?std.Io = null, + index_roots: std.ArrayList([]const u8) = .empty, + provider_ranks: std.StringHashMapUnmanaged(u32) = .empty, + metadata: std.AutoHashMapUnmanaged([32]u8, *IndexedMetadata) = .empty, + negative: std.AutoHashMapUnmanaged(MissKey, void) = .empty, + payloads: std.AutoHashMapUnmanaged([32]u8, []const u8) = .empty, index_stats: IndexStats = .{}, /// A completed-output probe has priority over procedure discovery. Until @@ -910,6 +979,11 @@ pub const LoadedPacks = struct { pub const IndexStats = struct { index_bytes: u64 = 0, + bucket_opens: u64 = 0, + bucket_absent: u64 = 0, + negative_hits: u64 = 0, + metadata_opens: u64 = 0, + metadata_bytes: u64 = 0, candidates: u64 = 0, duplicates: u64 = 0, payload_opens: u64 = 0, @@ -929,129 +1003,229 @@ pub const LoadedPacks = struct { }; pub fn addIndexDir(self: *LoadedPacks, io: std.Io, dir: []const u8) Allocator.Error!void { - for (self.index_dirs.items) |existing| if (std.mem.eql(u8, existing, dir)) return; + if (self.provider_ranks.contains(dir)) return; try self.index_dirs.ensureUnusedCapacity(self.allocator, 1); - self.index_dirs.appendAssumeCapacity(try self.allocator.dupe(u8, dir)); + try self.index_roots.ensureUnusedCapacity(self.allocator, 1); + const owned = try self.allocator.dupe(u8, dir); + errdefer self.allocator.free(owned); + try self.provider_ranks.put(self.allocator, owned, @intCast(self.index_dirs.items.len)); + self.index_dirs.appendAssumeCapacity(owned); + const root = std.fs.path.dirname(owned).?; + const known = for (self.index_roots.items) |existing| { + if (std.mem.eql(u8, existing, root)) break true; + } else false; + if (!known) self.index_roots.appendAssumeCapacity(root); self.index_io = io; - // Preserve the checked program's provider order: its root, then - // visible modules. This is semantic precedence, not filesystem order. + self.refreshIndex(); + } + + /// Start a fresh optional-cache snapshot after publication. This also + /// invalidates misses when an existing provider acquired new offers. + /// Other processes' later publications become visible at the next refresh + /// or in the next compilation; a miss never promises code availability. + pub fn refreshIndex(self: *LoadedPacks) void { + self.negative.clearRetainingCapacity(); } - /// Register freshly published offers after the checked program's existing - /// providers, preserving the same precedence as freshly produced full packs. + /// Register freshly published offers in the checked program's provider order. pub fn offerIndexed(self: *LoadedPacks, store: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8) Allocator.Error!void { self.ensureLoaded(); try store.loadIdentity(store.roc_ctx.std_io, self, origin, identity); + self.refreshIndex(); } - /// Read only the candidate bucket for this exact specialization. The same - /// keyed lookup serves both warm builds and reuse across source edits. - fn findIndexed(self: *LoadedPacks, key: [32]u8, takes: Common.SpecCacheTakes, modules: Common.ProgramModules) Allocator.Error!bool { - const io = self.index_io orelse return false; + fn loadMetadata(self: *LoadedPacks, io: std.Io, module_dir: []const u8, hash: [32]u8) Allocator.Error!?*IndexedMetadata { + if (self.metadata.get(hash)) |existing| return existing; const allocator = self.allocator; - var candidates_seen: usize = 0; - for (self.index_dirs.items) |module_dir| { - const bucket = try std.fs.path.join(allocator, &.{ module_dir, "offers", &std.fmt.bytesToHex(key, .lower) }); - defer allocator.free(bucket); - var dir = std.Io.Dir.cwd().openDir(io, bucket, .{ .iterate = true }) catch continue; - defer dir.close(io); - var candidates = std.ArrayList([]u8).empty; - defer { - for (candidates.items) |name| allocator.free(name); - candidates.deinit(allocator); + const name = std.fmt.bytesToHex(hash, .lower) ++ ".rpm".*; + const path = try std.fs.path.join(allocator, &.{ module_dir, "metadata", &name }); + defer allocator.free(path); + self.index_stats.metadata_opens += 1; + const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024 * 1024)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + self.index_stats.missing += 1; + return null; + }, + }; + defer allocator.free(bytes); + self.index_stats.index_bytes += bytes.len; + self.index_stats.metadata_bytes += bytes.len; + if (!std.mem.eql(u8, &Index.digest(bytes), &hash)) { + self.index_stats.corrupt += 1; + return null; + } + var decoded = Index.decodeMetadata(allocator, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + self.index_stats.corrupt += 1; + return null; + }, + }; + errdefer decoded.pack.deinit(); + const pack = try allocator.create(PackFile.Pack); + errdefer allocator.destroy(pack); + pack.* = decoded.pack; + const entry = try allocator.create(IndexedMetadata); + errdefer allocator.destroy(entry); + var closure = try SpliceClosure.init(allocator, &pack.set); + errdefer closure.deinit(); + const materialized = try allocator.alloc(bool, pack.set.artifacts.len); + errdefer allocator.free(materialized); + @memset(materialized, false); + entry.* = .{ .pack = pack, .hashes = decoded.hashes, .closure = closure, .materialized = materialized }; + try self.packs.ensureUnusedCapacity(allocator, 1); + try self.metadata.put(allocator, hash, entry); + self.packs.appendAssumeCapacity(pack); + return entry; + } + + /// Probe direct key buckets, then only candidates belonging to visible + /// providers. Provider rank remains root-first, independent of disk order. + fn findIndexed(self: *LoadedPacks, key: [32]u8, takes: Common.SpecCacheTakes, modules: Common.ProgramModules) Allocator.Error!IndexedResult { + const io = self.index_io orelse return .unavailable; + const allocator = self.allocator; + const Candidate = struct { + name: []u8, + bucket: []const u8, + rank: u32, + fn less(_: void, a: @This(), b: @This()) bool { + if (a.rank != b.rank) return a.rank < b.rank; + return std.mem.lessThan(u8, a.name, b.name); } + }; + var candidates = std.ArrayList(Candidate).empty; + var buckets = std.ArrayList([]u8).empty; + defer { + for (candidates.items) |candidate| allocator.free(candidate.name); + candidates.deinit(allocator); + for (buckets.items) |bucket| allocator.free(bucket); + buckets.deinit(allocator); + } + var cacheable = true; + for (self.index_roots.items) |index_root| { + try buckets.ensureUnusedCapacity(allocator, 1); + const bucket = try std.fs.path.join(allocator, &.{ index_root, "offers", &std.fmt.bytesToHex(key, .lower) }); + buckets.appendAssumeCapacity(bucket); + self.index_stats.bucket_opens += 1; + var dir = std.Io.Dir.cwd().openDir(io, bucket, .{ .iterate = true }) catch |err| { + if (err == error.FileNotFound) self.index_stats.bucket_absent += 1 else cacheable = false; + continue; + }; + defer dir.close(io); var it = dir.iterate(); - while (it.next(io) catch null) |entry| { - if (entry.kind != .file or entry.name.len != 68 or !std.mem.endsWith(u8, entry.name, ".rpi")) continue; + while (it.next(io) catch blk: { + cacheable = false; + break :blk null; + }) |entry| { + if (entry.kind != .file or entry.name.len != 133 or entry.name[64] != '-' or !std.mem.endsWith(u8, entry.name, ".rpi")) continue; + const module_dir = try std.fs.path.join(allocator, &.{ index_root, entry.name[0..64] }); + defer allocator.free(module_dir); + const rank = self.provider_ranks.get(module_dir) orelse continue; try candidates.ensureUnusedCapacity(allocator, 1); - candidates.appendAssumeCapacity(try allocator.dupe(u8, entry.name)); + candidates.appendAssumeCapacity(.{ .name = try allocator.dupe(u8, entry.name), .bucket = bucket, .rank = rank }); } - std.mem.sort([]u8, candidates.items, {}, nameLessThan); - for (candidates.items) |name| { - self.index_stats.candidates += 1; - if (candidates_seen != 0) self.index_stats.duplicates += 1; - candidates_seen += 1; - const bytes = dir.readFileAlloc(io, name, allocator, .limited(1024 * 1024 * 1024)) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => { - self.index_stats.missing += 1; - continue; - }, - }; - defer allocator.free(bytes); - self.index_stats.index_bytes += bytes.len; - const expected = std.fmt.bytesToHex(Index.digest(bytes), .lower); - if (!std.mem.eql(u8, name[0..64], &expected)) { - self.index_stats.corrupt += 1; + } + std.mem.sort(Candidate, candidates.items, {}, Candidate.less); + for (candidates.items, 0..) |candidate, candidate_index| { + self.index_stats.candidates += 1; + if (candidate_index != 0) self.index_stats.duplicates += 1; + const path = try std.fs.path.join(allocator, &.{ candidate.bucket, candidate.name }); + defer allocator.free(path); + const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + self.index_stats.missing += 1; + cacheable = false; continue; - } - var offer = Index.decode(allocator, bytes, key) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => { - self.index_stats.corrupt += 1; - continue; - }, - }; - var owned = true; - defer if (owned) offer.pack.deinit(); - // Metadata covers exactly this offer's explicit closure. - const present = try allocator.alloc(bool, offer.pack.set.source_files.len); + }, + }; + defer allocator.free(bytes); + self.index_stats.index_bytes += bytes.len; + if (!std.mem.eql(u8, candidate.name[65..129], &std.fmt.bytesToHex(Index.digest(bytes), .lower))) { + self.index_stats.corrupt += 1; + cacheable = false; + continue; + } + const offer = Index.Offer.decode(bytes) catch { + self.index_stats.corrupt += 1; + cacheable = false; + continue; + }; + const provider_dir = self.index_dirs.items[candidate.rank]; + const namespace = std.fs.path.dirname(std.fs.path.dirname(provider_dir).?).?; + const module_dir = try std.fs.path.join(allocator, &.{ namespace, @tagName(offer.origin), &std.fmt.bytesToHex(offer.owner, .lower) }); + defer allocator.free(module_dir); + const metadata = try self.loadMetadata(io, module_dir, offer.metadata) orelse { + cacheable = false; + continue; + }; + const pack = metadata.pack; + if (offer.spec >= pack.specs.len or !std.mem.eql(u8, &pack.specs[offer.spec].key, &key)) { + self.index_stats.corrupt += 1; + cacheable = false; + continue; + } + const spec = pack.specs[offer.spec]; + const program = ProgramPack.of(&pack.set, modules); + const absent = self.lines_outside.get(program) orelse blk: { + const present = try allocator.alloc(bool, pack.set.source_files.len); defer allocator.free(present); - for (offer.pack.set.source_files, present) |identity, *is_present| is_present.* = Common.programHasModule(modules, identity); - // Site-named symbolic callees are supplied by the current - // program. Preserve the same eligibility walk as full packs. - const absent = try backend.dev.ProcArtifact.reachingAbsentFiles(allocator, &offer.pack.set, present); - defer allocator.free(absent); - var eligible = !absent[offer.pack.specs[0].artifact]; - if (!eligible) { - self.index_stats.source_ineligible += 1; + for (pack.set.source_files, present) |identity, *is_present| is_present.* = Common.programHasModule(modules, identity); + const marks = try backend.dev.ProcArtifact.reachingAbsentFiles(allocator, &pack.set, present); + errdefer allocator.free(marks); + try self.lines_outside.put(allocator, program, marks); + break :blk marks; + }; + if (absent[spec.artifact]) { + self.index_stats.source_ineligible += 1; + continue; + } + if (takes.refusesDevCodeOfLinkedProcs()) { + self.ensureLinkedLoaded(); + const linked = self.dev_reaches_linked.get(&pack.set) orelse blk: { + const marks = try backend.dev.ProcArtifact.reachingIdentities(allocator, &pack.set, &self.linked_identities); + errdefer allocator.free(marks); + try self.dev_reaches_linked.put(allocator, &pack.set, marks); + break :blk marks; + }; + if (linked[spec.artifact]) { + self.index_stats.linked_ineligible += 1; continue; } - if (takes.refusesDevCodeOfLinkedProcs()) { - self.ensureLinkedLoaded(); - const linked = try backend.dev.ProcArtifact.reachingIdentities(allocator, &offer.pack.set, &self.linked_identities); - defer allocator.free(linked); - eligible = !linked[offer.pack.specs[0].artifact]; - if (!eligible) { - self.index_stats.linked_ineligible += 1; - continue; - } - } - self.index_stats.requested_artifacts += offer.hashes.len; - for (@constCast(offer.pack.set.artifacts), offer.hashes) |*artifact, hash| { - const payload_bytes = try self.readPayload(io, module_dir, hash) orelse { - eligible = false; - break; - }; - Index.materialize(artifact, payload_bytes) catch { - self.index_stats.corrupt += 1; - eligible = false; - break; - }; - self.index_stats.decoded_artifacts += 1; - } - if (!eligible) continue; - // Commit only after the complete closure is owned. Eviction can - // now remove every on-disk file without invalidating this hit. - const pack = try allocator.create(PackFile.Pack); - errdefer allocator.destroy(pack); - try self.packs.ensureUnusedCapacity(allocator, 1); - try self.artifacts.ensureUnusedCapacity(1); - try self.specs.ensureUnusedCapacity(1); - pack.* = offer.pack; - owned = false; - self.packs.appendAssumeCapacity(pack); - _ = self.specs.remove(key); - _ = self.artifacts.remove(key); - self.indexPack(pack) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => base.invariant("validated indexed offer has a procedure root", .{}), + } + const reached = try metadata.closure.of(spec.artifact); + self.index_stats.requested_artifacts += reached.len; + var complete = true; + for (reached) |index| { + if (metadata.materialized[index]) continue; + const payload_bytes = try self.readPayload(io, module_dir, metadata.hashes[index]) orelse { + complete = false; + cacheable = false; + break; }; - self.index_stats.served_artifacts += offer.hashes.len; - return true; + Index.materialize(&@constCast(pack.set.artifacts)[index], payload_bytes) catch { + self.index_stats.corrupt += 1; + complete = false; + cacheable = false; + break; + }; + metadata.materialized[index] = true; + self.index_stats.decoded_artifacts += 1; } + if (!complete) continue; + try self.artifacts.ensureUnusedCapacity(1); + try self.specs.ensureUnusedCapacity(1); + _ = self.specs.remove(key); + _ = self.artifacts.remove(key); + self.indexSpec(pack, spec) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => base.invariant("validated indexed metadata has a procedure root", .{}), + }; + self.index_stats.served_artifacts += reached.len; + return .hit; } - return false; + return if (cacheable) .miss else .unavailable; } fn readPayload(self: *LoadedPacks, io: std.Io, module_dir: []const u8, hash: [32]u8) Allocator.Error!?[]const u8 { @@ -1299,28 +1473,30 @@ pub const LoadedPacks = struct { } fn indexPack(self: *LoadedPacks, pack: *const PackFile.Pack) LoadError!void { - for (pack.specs) |spec| { - const root = pack.set.artifacts[spec.artifact]; - const identity = switch (root.kind) { - .proc => |identity| identity, - .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => return error.MalformedPack, - }; - if (self.specs.contains(spec.key)) continue; - try self.artifacts.ensureUnusedCapacity(1); - try self.specs.ensureUnusedCapacity(1); - self.artifacts.putAssumeCapacityNoClobber(spec.key, .{ .set = &pack.set, .index = spec.artifact }); - self.specs.putAssumeCapacityNoClobber(spec.key, .{ - .identity = identity.bytes, - .comptime_root_reads = spec.comptime_root_reads, - .rc_borrowed_params = spec.rc_borrowed_params, - .rc_ret_borrowed = spec.rc_ret_borrowed, - .rc_ret_lenders = spec.rc_ret_lenders, - .rc_read_only_params = spec.rc_read_only_params, - .rc_ret_unique = spec.rc_ret_unique, - .rc_ret_unique_fields = spec.rc_ret_unique_fields, - .rc_ret_conditions = spec.rc_ret_conditions, - }); - } + for (pack.specs) |spec| try self.indexSpec(pack, spec); + } + + fn indexSpec(self: *LoadedPacks, pack: *const PackFile.Pack, spec: PackFile.SpecEntry) LoadError!void { + const root = pack.set.artifacts[spec.artifact]; + const identity = switch (root.kind) { + .proc => |identity| identity, + .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => return error.MalformedPack, + }; + if (self.specs.contains(spec.key)) return; + try self.artifacts.ensureUnusedCapacity(1); + try self.specs.ensureUnusedCapacity(1); + self.artifacts.putAssumeCapacityNoClobber(spec.key, .{ .set = &pack.set, .index = spec.artifact }); + self.specs.putAssumeCapacityNoClobber(spec.key, .{ + .identity = identity.bytes, + .comptime_root_reads = spec.comptime_root_reads, + .rc_borrowed_params = spec.rc_borrowed_params, + .rc_ret_borrowed = spec.rc_ret_borrowed, + .rc_ret_lenders = spec.rc_ret_lenders, + .rc_read_only_params = spec.rc_read_only_params, + .rc_ret_unique = spec.rc_ret_unique, + .rc_ret_unique_fields = spec.rc_ret_unique_fields, + .rc_ret_conditions = spec.rc_ret_conditions, + }); } /// Index every loaded object pack, in load order: the first pack to @@ -1464,8 +1640,18 @@ pub const LoadedPacks = struct { pub fn deinit(self: *LoadedPacks) void { if (base.dev_diagnostics.isSet(.pack_stats)) { const stats = self.index_stats; - std.debug.print("pack index: index_bytes={d} candidates={d} duplicates={d} payload_opens={d} payload_files={d} payload_bytes={d} decoded_artifacts={d} requested_artifacts={d} served_artifacts={d} allocation_failures={d} requested={d} served={d} misses={d} source_ineligible={d} linked_ineligible={d} corrupt={d} missing={d}\n", .{ stats.index_bytes, stats.candidates, stats.duplicates, stats.payload_opens, stats.payload_files, stats.payload_bytes, stats.decoded_artifacts, stats.requested_artifacts, stats.served_artifacts, stats.allocation_failures, stats.requested, stats.served, stats.misses, stats.source_ineligible, stats.linked_ineligible, stats.corrupt, stats.missing }); + std.debug.print("pack index: index_bytes={d} bucket_opens={d} bucket_absent={d} negative_hits={d} metadata_opens={d} metadata_bytes={d} candidates={d} duplicates={d} payload_opens={d} payload_files={d} payload_bytes={d} decoded_artifacts={d} requested_artifacts={d} served_artifacts={d} allocation_failures={d} requested={d} served={d} misses={d} source_ineligible={d} linked_ineligible={d} corrupt={d} missing={d}\n", .{ stats.index_bytes, stats.bucket_opens, stats.bucket_absent, stats.negative_hits, stats.metadata_opens, stats.metadata_bytes, stats.candidates, stats.duplicates, stats.payload_opens, stats.payload_files, stats.payload_bytes, stats.decoded_artifacts, stats.requested_artifacts, stats.served_artifacts, stats.allocation_failures, stats.requested, stats.served, stats.misses, stats.source_ineligible, stats.linked_ineligible, stats.corrupt, stats.missing }); + } + self.negative.deinit(self.allocator); + self.provider_ranks.deinit(self.allocator); + self.index_roots.deinit(self.allocator); + var metadata = self.metadata.valueIterator(); + while (metadata.next()) |entry| { + entry.*.closure.deinit(); + self.allocator.free(entry.*.materialized); + self.allocator.destroy(entry.*); } + self.metadata.deinit(self.allocator); for (self.index_dirs.items) |dir| self.allocator.free(dir); self.index_dirs.deinit(self.allocator); var payloads = self.payloads.valueIterator(); @@ -1563,12 +1749,26 @@ pub const LoadedPacks = struct { eligible = false; } } - if (!eligible and !(self.findIndexed(key, takes, modules) catch blk: { - self.index_stats.allocation_failures += 1; - break :blk false; - })) { - self.index_stats.misses += 1; - return null; + if (!eligible) { + var hasher = std.crypto.hash.Blake3.init(.{}); + for (modules) |identity| hasher.update(&identity); + var module_digest: [32]u8 = undefined; + hasher.final(&module_digest); + const miss_key = MissKey{ .key = key, .takes = takes, .modules = module_digest }; + if (self.negative.contains(miss_key)) { + self.index_stats.negative_hits += 1; + self.index_stats.misses += 1; + return null; + } + const result = self.findIndexed(key, takes, modules) catch blk: { + self.index_stats.allocation_failures += 1; + break :blk IndexedResult.unavailable; + }; + if (result != .hit) { + if (result == .miss) self.negative.put(self.allocator, miss_key, {}) catch {}; + self.index_stats.misses += 1; + return null; + } } const hit = self.specs.get(key) orelse return null; self.hits += 1; @@ -1888,7 +2088,7 @@ test "indexed object cache reads only requested closure regardless of unrelated try std.testing.expectEqual(@as(u32, 9), hit.comptime_root_reads[0].root); try std.testing.expectEqualSlices(u32, &.{1}, hit.rc_ret_conditions); const located = first.artifacts.get(spec.key).?; - try std.testing.expectEqual(@as(usize, 3), located.set.artifacts.len); + try std.testing.expectEqual(@as(usize, 4), located.set.artifacts.len); try std.testing.expectEqualStrings("constant bytes", located.set.artifacts[located.index].data[0].bytes); try std.testing.expectEqual(@as(u32, 4), located.set.artifacts[located.index].lines[0].loc.line); try std.testing.expectEqualStrings(located.set.artifacts[1].data[0].name, located.set.artifacts[located.index].data[0].relocations[0].name); @@ -1943,6 +2143,7 @@ test "indexed object cache selects eligible provenance deterministically and reu try std.testing.expectEqual(@as(u64, 1), packs.index_stats.payload_opens); try std.testing.expect(packs.index_stats.source_ineligible > 0); try std.testing.expect(packs.specCacheLookup().lookup(spec.key, .dev_code, &.{@splat(3)}) == null); + _ = packs.specCacheLookup().lookup(spec.key, .dev_code, files[0..1]) orelse return error.TestUnexpectedResult; try std.testing.expect(packs.specCacheLookup().lookup(@splat(2), .dev_code, &files) == null); } } @@ -1978,6 +2179,10 @@ test "indexed object cache requires complete intact payloads and owns hits acros var repaired = try fixture.loader(); defer repaired.deinit(); _ = repaired.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + // Unavailable selected records are not negative results. Both loaders + // must see the repaired bytes without requiring a snapshot refresh. + _ = missing.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + _ = corrupt.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; try fixture.tmp.dir.deleteTree(std.testing.io, "local"); _ = pinned.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; const located = pinned.artifacts.get(spec.key).?; @@ -2069,7 +2274,7 @@ test "indexed object cache interrupted publication exposes no offer and concurre const thread_b = try std.Thread.spawn(.{}, Writer.run, .{&b}); defer thread_b.join(); // A concurrent reader sees a miss or a completely materialized hit. - // Misses are not memoized, so publication remains visible later. + // Publication becomes visible after the explicit snapshot refresh below. for (0..8) |_| { if (interrupted.specCacheLookup().lookup(@splat(1), .dev_code, &.{})) |hit| { try std.testing.expectEqual(lir.ProcIdentity.forTest(1).bytes, hit.identity); @@ -2077,6 +2282,7 @@ test "indexed object cache interrupted publication exposes no offer and concurre } } try std.testing.expect(!a.failed and !b.failed); + interrupted.refreshIndex(); for ([_]u8{ 1, 2 }) |key| { const hit = interrupted.specCacheLookup().lookup(@splat(key), .dev_code, &.{}) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(lir.ProcIdentity.forTest(key).bytes, hit.identity); @@ -2084,26 +2290,35 @@ test "indexed object cache interrupted publication exposes no offer and concurre try std.testing.expectEqual(@as(u64, 1), interrupted.index_stats.payload_opens); } -test "indexed object cache rejects corrupt metadata before reading payloads" { +test "indexed object cache rejects corrupt offers and shared metadata before reading payloads" { const F = IndexFixture; - var fixture: F = undefined; - try fixture.init(); - defer fixture.deinit(); - try fixture.publish(&.{F.artifact(1, "code")}, &.{F.spec(1, 0)}, &.{}); - const module_dir = try fixture.store.identityDir(.local, F.identity); - defer F.allocator.free(module_dir); - const bucket = try std.fs.path.join(F.allocator, &.{ module_dir, "offers", &std.fmt.bytesToHex(@as([32]u8, @splat(1)), .lower) }); - defer F.allocator.free(bucket); - var dir = try std.Io.Dir.cwd().openDir(std.testing.io, bucket, .{ .iterate = true }); - defer dir.close(std.testing.io); - var it = dir.iterate(); - const entry = (try it.next(std.testing.io)).?; - try dir.writeFile(std.testing.io, .{ .sub_path = entry.name, .data = "truncated metadata" }); - var packs = try fixture.loader(); - defer packs.deinit(); - try std.testing.expect(packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) == null); - try std.testing.expectEqual(@as(u64, 1), packs.index_stats.corrupt); - try std.testing.expectEqual(@as(u64, 0), packs.index_stats.payload_opens); + for ([_]bool{ false, true }) |shared_metadata| { + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const artifacts = [_]backend.dev.ProcArtifact.Artifact{F.artifact(1, "code")}; + const specs = [_]PackFile.SpecEntry{F.spec(1, 0)}; + try fixture.publish(&artifacts, &specs, &.{}); + const module_dir = try fixture.store.identityDir(.local, F.identity); + defer F.allocator.free(module_dir); + const bucket = if (shared_metadata) + try std.fs.path.join(F.allocator, &.{ module_dir, "metadata" }) + else + try std.fs.path.join(F.allocator, &.{ fixture.store.root, "local", "offers", &std.fmt.bytesToHex(@as([32]u8, @splat(1)), .lower) }); + defer F.allocator.free(bucket); + var dir = try std.Io.Dir.cwd().openDir(std.testing.io, bucket, .{ .iterate = true }); + defer dir.close(std.testing.io); + var it = dir.iterate(); + const entry = (try it.next(std.testing.io)).?; + try dir.writeFile(std.testing.io, .{ .sub_path = entry.name, .data = "truncated metadata" }); + var packs = try fixture.loader(); + defer packs.deinit(); + try std.testing.expect(packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 1), packs.index_stats.corrupt); + try std.testing.expectEqual(@as(u64, 0), packs.index_stats.payload_opens); + try fixture.publish(&artifacts, &specs, &.{}); + _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + } } test "indexed object cache duplicate eligible closures select by content independent of publication order" { @@ -2151,3 +2366,66 @@ test "indexed object cache defers discovery until a completed-output probe misse _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; try std.testing.expectEqual(@as(u64, 1), packs.index_stats.payload_opens); } + +test "indexed object cache misses probe keys once across many providers and refresh on publication" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var packs = try fixture.loader(); + defer packs.deinit(); + for (1..128) |i| try fixture.store.loadIdentity(std.testing.io, &packs, .local, @splat(@intCast(i))); + for (0..100) |_| try std.testing.expect(packs.specCacheLookup().lookup(@splat(250), .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 1), packs.index_stats.bucket_opens); + try std.testing.expectEqual(@as(u64, 99), packs.index_stats.negative_hits); + try fixture.publish(&.{F.artifact(1, "new code")}, &.{F.spec(250, 0)}, &.{}); + // An existing provider's new offer invalidates the snapshot, even though + // the provider set itself did not change. + try packs.offerIndexed(&fixture.store, .local, F.identity); + _ = packs.specCacheLookup().lookup(@splat(250), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u64, 2), packs.index_stats.bucket_opens); +} + +test "indexed object cache shares producer metadata and verifies immutable records before skipping writes" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var first = F.artifact(1, "caller"); + var second = F.artifact(2, "caller"); + first.refs = &.{.{ .site = 0, .form = .call, .target = 2, .delta = 0 }}; + second.refs = first.refs; + const artifacts = [_]backend.dev.ProcArtifact.Artifact{ first, second, F.artifact(3, "shared dependency") }; + const specs = [_]PackFile.SpecEntry{ F.spec(1, 0), F.spec(2, 1) }; + try fixture.publish(&artifacts, &specs, &.{}); + const RefuseWrites = struct { + fn rename(_: ?*anyopaque, _: std.Io, _: []const u8, _: []const u8) CoreCtx.RenameError!void { + return error.AccessDenied; + } + }; + fixture.store.roc_ctx.vtable.rename = RefuseWrites.rename; + try fixture.publish(&artifacts, &specs, &.{}); + var packs = try fixture.loader(); + defer packs.deinit(); + _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + _ = packs.specCacheLookup().lookup(@splat(2), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u64, 1), packs.index_stats.metadata_opens); + try std.testing.expectEqual(@as(u64, 2), packs.index_stats.payload_files); + try std.testing.expectEqual(@as(u64, 3), packs.index_stats.decoded_artifacts); + try std.testing.expectEqual(packs.artifacts.get(@splat(1)).?.set, packs.artifacts.get(@splat(2)).?.set); +} + +test "indexed object cache routes a package offer to producer storage outside its visible providers" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const provider: compile.BuildEnv.PackPlacement = .{ .origin = .pkg, .identity = @splat(99) }; + try fixture.store.writeIndexedForProviders(.local, F.identity, &.{ .arena = undefined, .artifacts = &.{F.artifact(1, "package code")} }, &.{F.spec(1, 0)}, &.{provider}); + var packs = LoadedPacks.init(F.allocator); + defer packs.deinit(); + try fixture.store.loadIdentity(std.testing.io, &packs, provider.origin, provider.identity); + try packs.indexPacks(); + _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualStrings("package code", packs.artifacts.get(@splat(1)).?.set.artifacts[0].code); +} diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index a06064d4b2a..761ca717b5a 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -8123,7 +8123,7 @@ const PackStoreFiles = struct { var walker = try cache.walk(allocator); defer walker.deinit(); while (try walker.next(io)) |entry| { - if (entry.kind != .file or !(std.mem.endsWith(u8, entry.basename, ".rpk") or std.mem.endsWith(u8, entry.basename, ".rpi") or std.mem.endsWith(u8, entry.basename, ".rpa") or std.mem.endsWith(u8, entry.basename, ".rpd"))) continue; + if (entry.kind != .file or !(std.mem.endsWith(u8, entry.basename, ".rpk") or std.mem.endsWith(u8, entry.basename, ".rpi") or std.mem.endsWith(u8, entry.basename, ".rpa") or std.mem.endsWith(u8, entry.basename, ".rpd") or std.mem.endsWith(u8, entry.basename, ".rpm"))) continue; const path = try allocator.dupe(u8, entry.path); result.paths.append(allocator, path) catch |err| { allocator.free(path); @@ -8670,9 +8670,9 @@ fn customPackageDevPacks( defer prepared.deinit(allocator); const commands = [_][]const []const u8{ &.{ "check", "--no-cache" }, - &.{"check"}, - &.{"check"}, - &.{ "build", "--opt=dev", out_arg }, + &.{ "check", "--timings" }, + &.{ "check", "--timings" }, + &.{ "build", "--opt=dev", "--timings", out_arg }, }; for (commands, 0..) |args, index| { const command_timeout = childCommandTimeoutMs(timer, timeout_ms) orelse @@ -8689,6 +8689,12 @@ fn customPackageDevPacks( if (files.paths.items.len != 0) return failureFromRun(allocator, timer, ran, "--no-cache check wrote an object pack"); continue; } + if (index != 0) { + const phase = if (index == 3) "Publishing Runtime Cache" else "Preparing Cache"; + if (std.mem.find(u8, ran.stdout, phase) == null and std.mem.find(u8, ran.stderr, phase) == null) { + return failureFromRun(allocator, timer, ran, "cache preparation/publication has no explicit timing phase"); + } + } if (index == 1) { if (std.mem.find(u8, ran.stderr, "package dev pack Numeric") == null) { var listing = std.ArrayList(u8).empty; @@ -9456,7 +9462,7 @@ fn customNativeBuildPackHits( .{ .roc_file = "test/cli/pack_values/PackValues.roc", .prefix = "values" }, .{ .roc_file = "test/cli/pack_constants/PackConstants.roc", .prefix = "constants" }, .{ .roc_file = "test/cli/pack_literal_roots/PackLiteralRoots.roc", .prefix = "literal_roots" }, - .{ .roc_file = comptime_app, .prefix = "comptime", .expect = .{ .edit_between_builds = true, .evaluator_artifacts = true } }, + .{ .roc_file = comptime_app, .prefix = "comptime", .expect = .{ .edit_between_builds = true, .source_edit = .{ .from = "Shapes.sides_of(\"hexagon\")", .to = "Shapes.sides_of(\"square\") + 2" }, .evaluator_artifacts = true } }, }; for (store_apps) |app| { if (storeBuildsBehaveIdentically(io, allocator, env, timer, timeout_ms, app.roc_file, warm_dir, app.prefix, app.expect)) |failure| return failure; @@ -9478,23 +9484,31 @@ fn customEarlyCtfeCache( .dir_name = "early_ctfe_cache", .platform = "test/fx/platform/main.roc", .platform_spelling = "../../fx/platform/main.roc", - .sources = &.{ "test/cli/pack_comptime/Shapes.roc", "test/cli/pack_comptime/PackComptime.roc" }, + .sources = &.{ "test/cli/pack_comptime/Shapes.roc", "test/cli/pack_comptime/PackComptime.roc", "test/cli/ctfe_cache_publication/Closed.roc" }, .app_name = "PackComptime.roc", }, &app)) |failure| return failure; const source = std.Io.Dir.cwd().readFileAlloc(io, app, allocator, .limited(1024 * 1024)) catch |err| return customInfraFailure(allocator, timer, "failed to read CTFE app: {}", .{err}); + // This loop has a reusable closed interface. The higher-order Shapes + // operations also exercised below can legitimately be withheld by the + // offer eligibility rules, so they cannot certify an early specialization hit. + const with_import = std.mem.replaceOwned(u8, allocator, source, "import Shapes", "import Shapes\nimport Closed") catch |err| + return customInfraFailure(allocator, timer, "failed to import the CTFE helper: {}", .{err}); + const with_helper = std.mem.replaceOwned(u8, allocator, with_import, "Shapes.sides_of(\"hexagon\")", "Closed.total(4)") catch |err| + return customInfraFailure(allocator, timer, "failed to select the CTFE helper: {}", .{err}); const with_debug = std.fmt.allocPrint( allocator, "{s}\nreplayed = {{\n dbg \"early ctfe replay\"\n hexagon_sides\n}}\nexpect replayed == hexagon_sides\n", - .{source}, + .{with_helper}, ) catch |err| return customInfraFailure(allocator, timer, "failed to allocate CTFE app: {}", .{err}); std.Io.Dir.cwd().writeFile(io, .{ .sub_path = app, .data = with_debug }) catch |err| return customInfraFailure(allocator, timer, "failed to write CTFE app: {}", .{err}); if (storeBuildsBehaveIdentically(io, allocator, env, timer, timeout_ms, app, env.dirs.work_dir, "early_ctfe", .{ .edit_between_builds = true, + .source_edit = .{ .from = "Closed.total(4)", .to = "Closed.total(3) + 3" }, .evaluator_artifacts = true, .monotype_body_reduction = true, - .pack_hit_proc = "Shapes.sides_of", + .pack_hit_proc = "Closed.total", .compile_debug = "[dbg] \"early ctfe replay\"", .stdout = "triangle:3 square:4 pentagon:5 hexagon:6 28 5 4.5\n", })) |failure| return failure; @@ -11087,6 +11101,7 @@ fn customCtfeCachePublication( &.{ "test", "check" }, &.{ "check", "test" }, }; + var named_hits: usize = 0; for (sequences, 0..) |commands, sequence| { var traced = CaseEnv{ .dirs = env.dirs, @@ -11123,6 +11138,8 @@ fn customCtfeCachePublication( const testing = std.mem.eql(u8, command, "test"); const llvm = !checking and !dev and !testing; const args: []const []const u8 = if (checking) &.{ "check", "--verbose", "--no-color" } else if (testing) &.{ "test", "--no-color" } else if (dev) &.{ "build", "--opt=dev", out_arg } else &.{ "build", out_arg }; + // Exercise procedure reuse; completed-output reuse has its own test. + if (dev) if (forgetRecordedProgramOutputs(io, allocator, &traced, timer)) |failure| return failure; // Optimized builds deliberately warn about the fixture's dbg; // code 2 is the documented warning exit, not a compilation crash. const run = switch (captureRocRun(io, allocator, &traced, timer, timeout_ms, .{ @@ -11136,12 +11153,16 @@ fn customCtfeCachePublication( if (std.mem.count(u8, run.stderr, "[dbg] \"CTFE publication observation\"") != 1) { return failureFromRun(allocator, timer, run, "command did not preserve its compile-time observation exactly once"); } - // Every command files the code its compile-time evaluation ran, - // so every later command of the sequence reuses the helpers. The - // unchanged repeat check reuses its checked modules instead. - if (step > 0 and !(sequence == 0 and step == 1)) { + // A demanded helper reuses its code. Reusing a completed value can + // avoid requesting that helper altogether, which also avoids its + // body. The unchanged repeat check reuses its checked modules. + // The test-plan consumer requires source bodies; its next check + // still verifies that evaluation published reusable native code. + if (step > 0 and !testing and !(sequence == 0 and step == 1)) { for ([_][]const u8{ "Closed.total", "Closed.checked_scale" }) |procedure| { + if (namedProcBody(run.stderr, procedure) == null) continue; if (!hasNamedPackHit(run.stderr, procedure)) return failureFromRun(allocator, timer, run, "warm finalization did not reuse its named CTFE helper"); + named_hits += 1; if (!llvm and namedProcBody(run.stderr, procedure) == true) return failureFromRun(allocator, timer, run, "warm native finalization reconstructed the CTFE helper body"); } } @@ -11250,6 +11271,7 @@ fn customCtfeCachePublication( } } } + if (named_hits == 0) return customFailure(allocator, timer, "no command reused a demanded CTFE helper", .{}); return null; } @@ -11426,6 +11448,9 @@ const StoreExpectations = struct { /// Append a comment to the app between the builds, so the second build /// checks it again instead of reusing its cached checked artifact. edit_between_builds: bool = false, + /// Change an evaluated expression as well as the comment. A comment alone + /// can reuse stored values and never demand the procedure being tested. + source_edit: ?struct { from: []const u8, to: []const u8 } = null, /// The compile-time evaluator spliced at least one entry. evaluator_artifacts: bool = false, /// Require less Monotype body construction, not merely less native emission. @@ -11490,7 +11515,12 @@ fn storeBuildsBehaveIdentically( if (last and expect.edit_between_builds) { const source = std.Io.Dir.cwd().readFileAlloc(io, roc_file, allocator, .limited(1024 * 1024)) catch |err| return customInfraFailure(allocator, timer, "failed to read {s}: {}", .{ roc_file, err }); - const edited = std.fmt.allocPrint(allocator, "{s}\n# Edited between builds, so this build checks the app again.\n", .{source}) catch |err| + const changed_source = if (expect.source_edit) |edit| + std.mem.replaceOwned(u8, allocator, source, edit.from, edit.to) catch |err| + return customInfraFailure(allocator, timer, "failed to edit the compile-time expression: {}", .{err}) + else + source; + const edited = std.fmt.allocPrint(allocator, "{s}\n# Edited between builds, so this build checks the app again.\n", .{changed_source}) catch |err| return customInfraFailure(allocator, timer, "failed to allocate the edited app: {}", .{err}); std.Io.Dir.cwd().writeFile(io, .{ .sub_path = roc_file, .data = edited }) catch |err| return customInfraFailure(allocator, timer, "failed to write {s}: {}", .{ roc_file, err }); diff --git a/src/lir/LIR.zig b/src/lir/LIR.zig index 6eadd95413d..c4f0ca1e9bc 100644 --- a/src/lir/LIR.zig +++ b/src/lir/LIR.zig @@ -2097,6 +2097,9 @@ pub const LirProcSpec = struct { /// (`SpecComptimeReads.fnReads`); null for a procedure no Monotype /// function names. source_monotype_fn: ?u32 = null, + /// Checked defining module for an app-independent direct template offer. + /// Null keeps generated, nested, and app-dependent code program-scoped. + cache_provider_module: ?[32]u8 = null, /// Set on an `external` proc whose cache entry is a linked object rather /// than spliced code: callers reach it through its C-ABI entry /// (`ProcIdentity.writeCAbiEntryName`), classified from its argument and diff --git a/src/postcheck/solved_lir_lower.zig b/src/postcheck/solved_lir_lower.zig index c99417f55e6..2f6c9c390cd 100644 --- a/src/postcheck/solved_lir_lower.zig +++ b/src/postcheck/solved_lir_lower.zig @@ -3343,6 +3343,10 @@ const Lowerer = struct { .cache_key = cached_key, .code_key = code_key, .source_monotype_fn = if (source_fn.source) |template| if (template.frozen_fn) |mono_fn| @backingInt(mono_fn) else null else null, + .cache_provider_module = if (keyed_offer) if (source_fn.source) |template| switch (template.fn_def) { + .local_template, .imported_template, .checked_generated => |owner| owner.artifact.bytes, + .nested, .local_hosted, .imported_hosted, .parser_runtime, .encoder_for_runtime => null, + } else null else null, .c_abi_entry = if (cached) |hit| hit.c_abi_entry else false, .rc_borrowed_params = if (cached) |hit| hit.rc_borrowed_params else 0, .rc_ret_borrowed = if (cached) |hit| hit.rc_ret_borrowed else false, diff --git a/test/cli/cache_work_bench.py b/test/cli/cache_work_bench.py new file mode 100644 index 00000000000..a793f00d2e2 --- /dev/null +++ b/test/cli/cache_work_bench.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Compare ReleaseFast compilers on synthetic dev-cache workloads (Linux). + +Pass --platform pointing to a built fx-open platform, and --work under TMPDIR. +The URL platform is seeded locally; no download or external project is used. +Each measured command is roc build --opt=dev. Logs retain phase timings and +cache counters. Optional strace runs are separate from wall-time measurements. +""" +import argparse +import os +from pathlib import Path +import re +import shutil +import statistics +import subprocess +import time + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("roc", type=Path) + p.add_argument("--platform", type=Path, required=True) + p.add_argument("--work", type=Path, required=True) + p.add_argument("--label", required=True) + p.add_argument("--modules", type=int, default=96) + p.add_argument("--functions", type=int, default=4) + p.add_argument("--repeats", type=int, default=3) + p.add_argument("--trace", action="store_true") + a = p.parse_args() + roc = a.roc.resolve() + work = a.work.resolve() + work.mkdir(parents=True, exist_ok=True) + source = work / "source" + source.mkdir(exist_ok=True) + package_hash = "FakeHashAbcDefGhiJkLmNoPqRsTuVwXyZ123456789o" + imports = "\n".join(f"import M{i}" for i in range(a.modules)) + calls = " +\n".join(f"M{i}.f{j}(n)" for i in range(a.modules) for j in range(a.functions)) + app = f'''app [main!] {{ pf: platform "https://example.com/bench/{package_hash}.tar.zst" }} +import pf.Stdout +{imports} +main! = |args| {{ + n = args.len().to_u64() + 10 + total = {calls} + Stdout.line!(total.to_str()) + Ok({{}}) +}} +''' + module_sources = {} + for i in range(a.modules): + functions = [] + for j in range(a.functions): + functions.append(f'''f{j} : U64 -> U64 + f{j} = |n| {{ + var $sum = 0 + var $index = 0 + while $index < n {{ + $sum = $sum + $index + {i * a.functions + j} + $index = $index + 1 + }} + $sum + }}''') + module_sources[source / f"M{i}.roc"] = f"M{i} := [].{{\n" + "\n".join(functions) + "\n}\n" + results = {} + for repeat in range(a.repeats): + run_dir = work / f"{a.label}-{repeat}" + run_dir.mkdir() + cache = run_dir / "cache" + shutil.copytree(a.platform, cache / "roc/packages" / package_hash) + env = dict(os.environ, ROC_CACHE_DIR=str(cache), XDG_CACHE_HOME=str(cache), ROC_PACK_STATS="1") + for phase in ("cold", "completed", "lookup", "edit", "wide_edit", "revert"): + for path, contents in module_sources.items(): + path.write_text(contents.replace("$sum + $index +", "$sum + $index + 1 +") if phase == "wide_edit" else contents) + (source / "main.roc").write_text(app.replace("+ 10", "+ 11") if phase == "edit" else app) + if phase in ("lookup", "revert"): + for f in cache.rglob("*.rpo"): + f.unlink() + cmd = [str(roc), "build", "--opt=dev", "--jobs=1", "--timings", str(source / "main.roc"), f"--output={run_dir / 'app'}"] + start = time.perf_counter_ns() + r = subprocess.run(["/usr/bin/time", "-f", "%M", "-o", str(run_dir / "rss"), *cmd], env=env, capture_output=True, text=True) + elapsed = (time.perf_counter_ns() - start) / 1e6 + (run_dir / f"{phase}.log").write_text(r.stdout + r.stderr) + if r.returncode: + raise RuntimeError(r.stdout + r.stderr) + n = 12 if phase == "edit" else 11 + count = a.modules * a.functions + expected = count * n * (n - 1) // 2 + n * count * (count - 1) // 2 + if phase == "wide_edit": + expected += n * count + output = subprocess.check_output([str(run_dir / "app")], text=True) + assert output == f"{expected}\n", output + stats = {} + publication = 0 + for line in r.stderr.splitlines(): + if line.startswith("pack index publication:"): + publication += int(re.search(r"wall_ns=(\d+)", line)[1]) + if line.startswith("pack index:"): + for k, v in re.findall(r"(\w+)=(\d+)", line): + stats[k] = stats.get(k, 0) + int(v) + rss = int((run_dir / "rss").read_text()) + results.setdefault(phase, []).append((elapsed, rss, publication / 1e6)) + files = [f for f in cache.rglob("*") if f.suffix in (".rpi", ".rpa", ".rpm")] + print(a.label, repeat, phase, f"wall_ms={elapsed:.3f} rss_kib={rss} publication_ms={publication / 1e6:.3f} records={len(files)} bytes={sum(f.stat().st_size for f in files)}", stats, flush=True) + if a.trace and repeat == 0: + (source / "main.roc").write_text(app.replace("+ 10", "+ 12")) + for path, contents in module_sources.items(): + path.write_text(contents.replace("$sum + $index +", "$sum + $index + 2 +")) + trace = subprocess.run(["strace", "-f", "-c", "-e", "trace=openat,mkdir,rename,renameat,renameat2,write", "-o", str(run_dir / "syscalls.txt"), *cmd], env=env, capture_output=True, text=True) + if trace.returncode: + raise RuntimeError(trace.stderr) + count = a.modules * a.functions + n = 13 + expected = count * n * (n - 1) // 2 + n * count * (count - 1) // 2 + 2 * n * count + assert subprocess.check_output([str(run_dir / "app")], text=True) == f"{expected}\n" + print((run_dir / "syscalls.txt").read_text(), flush=True) + for phase, rows in results.items(): + print(a.label, phase, "medians", *(f"{statistics.median(row[i] for row in rows):.3f}" for i in range(3)), flush=True) + + +if __name__ == "__main__": + main() diff --git a/test/cli/pack_index_bench.py b/test/cli/pack_index_bench.py index 8d01334b1d9..8e8df965315 100644 --- a/test/cli/pack_index_bench.py +++ b/test/cli/pack_index_bench.py @@ -97,11 +97,17 @@ def run(label, force_lookup=False, no_cache=False, expected="55\n"): baseline = run("lookup baseline", force_lookup=True) assert baseline["payload_files"] > 0 templates = sorted(cache.rglob("*.rpi")) - template = next(p for p in templates if struct.unpack_from(" 0) - metadata = template.read_bytes() - assert metadata[:8] == b"ROFFER01" - module = template.parent.parent.parent - payload_dir = module / "payload" + template = templates[0] + offer_template = template.read_bytes() + assert offer_template[:8] == b"ROFFER02" and len(offer_template) == 77 + namespace = template.parents[3] + origin = ("local", "pkg")[offer_template[44]] + owner = namespace / origin / offer_template[45:77].hex() + metadata_dir = owner / "metadata" + payload_dir = owner / "payload" + metadata = (metadata_dir / (offer_template[8:40].hex() + ".rpm")).read_bytes() + assert metadata[:8] == b"ROFMETA1" + assert struct.unpack_from(" 0 original_payload = (payload_dir / (metadata[12:44].hex() + ".rpa")).read_bytes() assert original_payload[:8] == b"RPAYLD01" code_len = struct.unpack_from(" Date: Sun, 25 Oct 2026 00:29:38 -0400 Subject: [PATCH 06/97] Validate completed build inputs before loading checked IR --- design.md | 87 +++++---- src/cli/main.zig | 245 +++++++++++++++++++++++--- src/cli/pack_store.zig | 54 +++++- src/cli/test/parallel_cli_runner.zig | 32 +++- src/compile/compile_build.zig | 233 +++++++++++++++++++++++- src/compile/completed_compilation.zig | 41 +++++ src/compile/coordinator.zig | 20 +++ 7 files changed, 640 insertions(+), 72 deletions(-) diff --git a/design.md b/design.md index 9ffcad5703b..83c895d3ecd 100644 --- a/design.md +++ b/design.md @@ -2702,45 +2702,54 @@ reference counts. #### Recorded Program Output A specialized native dev build records a completed compilation, an explicit -alternative to a live evaluation/lowering session. After the ordinary frontend -has resolved and content-validated the complete dependency closure, it probes -before platform evaluation or app/platform pairing. The identity covers the -compiler and record format, target/ABI/CPU contract, build output and codegen -policy, specialization strategy, synthetic header policy, source-pin validation -version, app/platform identities, every checked module identity, native source -paths (using the original path of staged app sources and logical module names -for compiler-owned platforms), and the -explicitly selected optimized object stores. Checked identities cover source, -transitive imports and imported assets; resolution and pin validation still run -on every invocation. Neither evaluated-root state nor historical dev-pack -population is an input to this identity. - -The record retains the original build's demanded checked roots, their transitive -reads, literal source/value identities, and observations, followed by the final -normalized Roc object, additional selected optimized objects, resolved target -configuration, hosted symbols, link roots and app exports. Checking owns its -ordinary diagnostics; the completed record owns finalization diagnostics and -compile-time debug observations. Diagnostics retain structured documents and -source identities, so replay uses current source paths and presentation options. -An unused root evaluated by another command never joins the recorded demand. - -An exact hit consumes this finished result: it neither creates a program session -nor marks prepared checked modules finalized. It performs no shared demand -specialization, CTFE, post-CTFE specialization, runtime lowering or object -emission, and does not load procedure packs to reconstruct the result. A miss -performs ordinary evaluation and normalization. Only a compilation with no -semantic errors publishes; warning-only successes are eligible. Boxy builds -are separate and this record never admits Boxy runtime dependencies into LSS. - -The length-prefixed binary record carries an integrity digest, and publication -uses a temporary file and atomic rename. Incompatible, truncated, corrupt or -missing records miss. Additional objects are retained in the same validated -record rather than referenced through scratch paths. Publication failure leaves -the live compilation intact. The record certifies compilation, not a linked -executable: every invocation revalidates current platform link inputs and links -the retained artifacts, even if an output executable already exists. Timings -separately report hits, bytes read/written, publication time and skipped producer -work. +alternative to a live evaluation/lowering session. After package resolution, +it can validate the completed record without loading canonicalized or checked +module IR. A domain-separated build-level placement pointer locates a candidate; +it uses the existing placement-pointer storage and is never evidence of validity. +There are no additional per-module manifest files. + +The existing completed-output record begins with a separately checksummed input +manifest, before its object payload. It records consumed source and asset hashes +using the same producer data as watch mode, the resolved package graph, import +path canonicalization and spelling checks, and the explicit optimized-object +selection. Validation reads source contents and compact metadata, never checked +IR. URL packages retain the resolver's immutable bundle contract. Compiler-owned +staging paths bind to named packages in the current invocation. The request +identity covers compiler compatibility, target/ABI/CPU, build output and codegen +policy, specialization strategy, synthetic headers, original source paths and +source-pin validation policy. Neither evaluated-root state nor historical +dev-pack population is an input. Package resolution and its policy validation +still run on every invocation. + +The record retains the original build's demanded checked roots, transitive reads, +literal source/value identities and observations, followed by the final normalized +Roc object, additional selected optimized objects, resolved target configuration, +hosted symbols, link roots and app exports. It also retains the complete ordered +frontend and finalization diagnostic documents. An early hit replays these with +current presentation options and rebound source paths. A later hit, after ordinary +frontend work, replays only finalization diagnostics; the frontend still owns its +reports on that path. An unused root evaluated by another command never joins the +recorded demand. Completed results remain usable after checked-IR cache eviction. + +An early hit creates no coordinator or program session and marks no checked +module finalized. It performs no shared demand specialization, CTFE, post-CTFE +specialization, runtime lowering or object emission, and loads no procedure packs +to reconstruct the result. A miss takes the ordinary pipeline, which can still +find a retained exact completed result after an edit is reverted. Only a +compilation with no semantic errors publishes; warning-only successes are +eligible. Boxy builds remain separate, and the record never admits Boxy runtime +dependencies into LSS. + +The length-prefixed binary record validates its manifest independently before +reading object bytes; the payload has its own integrity digest. Publication uses +a temporary file and atomic rename. Incompatible, truncated, corrupt or missing +records miss, and the build pointer is published only after the complete record. +Additional objects are retained in the record rather than referenced through +scratch paths. Publication failure leaves the live compilation intact. Every +invocation validates current platform link inputs and links the retained +artifacts, even if an executable already exists. Timings distinguish early hits, +checked-module loads and compilation, input validation time/bytes, manifest reads, +publication, and skipped producer work. ### Demand-Driven Compile-Time Evaluation diff --git a/src/cli/main.zig b/src/cli/main.zig index 6e180009e0e..6333476025b 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -11748,6 +11748,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu var build_env = try initBuildCommandEnv(ctx, args); defer build_env.deinit(); + if (!args.no_cache and args.opt == .dev and currentRuntimeSpecializationStrategy(args.specialization_strategy) == .lss) build_env.setWatchInputTracking(true); // Registered after build_env.deinit() so it runs first (LIFO), while build_env is still // valid: records discovered source inputs for `roc build --watch` on every exit path. defer writeBuildWatchInputsOnExit(ctx, args, &build_env); @@ -11868,14 +11869,30 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu .gpa = ctx.gpa, .build_env = &build_env, .store = store, + .manager = &cache_manager, .policy = programOutputPolicy(args, link_type), .linked_stores = if (linked_stores) |*stores| stores else null, }; }; - if (program_probe) |*probe| build_env.setRuntimeOutputProbe(probe.runtimeOutputProbe()); + if (program_probe) |*probe| { + try probe.initialize(args); + build_env.setRuntimeOutputProbe(probe.runtimeOutputProbe()); + build_env.diagnostic_observer = .{ .context = probe, .record = ProgramOutputProbe.observeDiagnostic }; + } build_env.setValidateTargetFilesForSelectedTarget(true); build_env.setDetailedLoweringTiming(args.timings); - const diag, const resolved_targets_config = try typeCheckBuildCommand(ctx, args, &build_env, &reporter); + const diag, const resolved_targets_config = early: { + if (program_probe) |*probe| { + reporter.begin("Validating Completed Inputs"); + const hit = try probe.early(); + reporter.end(); + if (hit) { + try build_env.validateDiscoveredPlatformTargetFilesForCurrentTarget(); + break :early .{ try probe.replayEarly(ctx), probe.found.?.outcome.targets.? }; + } + } + break :early try typeCheckBuildCommand(ctx, args, &build_env, &reporter); + }; if (build_env.runtimeOutputCached()) { const output = program_probe.?.found.?; @@ -12136,6 +12153,8 @@ fn linkNativeBuild( /// Final normalized compiler artifacts and their completed semantic outcome. /// Linking always validates and consumes the platform's current host inputs. const ProgramOutput = struct { + manifest: Manifest = .{}, + diagnostics: []const compile.completed_compilation.Diagnostic = &.{}, object: []const u8, additional_objects: []const []const u8 = &.{}, facts: LinkFacts, @@ -12146,8 +12165,26 @@ const ProgramOutput = struct { link_root_symbols: []const []const u8, app_export_symbols: []const []const u8, }; - const magic = "ROCCOMP1"; - const header_len = magic.len + 32; + const OptimizationInput = struct { + selection: pack_store.LinkedStores.Selection, + digest: [32]u8, + }; + const Manifest = struct { + request: [32]u8 = @splat(0), + inputs: ?compile.completed_compilation.Inputs = null, + optimizations: []const OptimizationInput = &.{}, + }; + const Payload = struct { + object: []const u8, + additional_objects: []const []const u8, + facts: LinkFacts, + outcome: compile.completed_compilation.Outcome, + diagnostics: []const compile.completed_compilation.Diagnostic, + }; + const magic = "ROCCOMP2"; + const header_len = magic.len + 8 + 32 + 32; + const manifest_digest_offset = magic.len + 8; + const payload_digest_offset = manifest_digest_offset + 32; fn encode(allocator: Allocator, output: ProgramOutput) Allocator.Error![]u8 { var out: std.Io.Writer.Allocating = .init(allocator); @@ -12155,24 +12192,58 @@ const ProgramOutput = struct { var header: [header_len]u8 = @splat(0); @memcpy(header[0..magic.len], magic); out.writer.writeAll(&header) catch return error.OutOfMemory; - compile.completed_compilation.write(ProgramOutput, &out.writer, output) catch return error.OutOfMemory; + compile.completed_compilation.write(Manifest, &out.writer, output.manifest) catch return error.OutOfMemory; + const manifest_end = out.written().len; + compile.completed_compilation.write(Payload, &out.writer, .{ + .object = output.object, + .additional_objects = output.additional_objects, + .facts = output.facts, + .outcome = output.outcome, + .diagnostics = output.diagnostics, + }) catch return error.OutOfMemory; const bytes = try out.toOwnedSlice(); - std.crypto.hash.Blake3.hash(bytes[header_len..], bytes[magic.len..header_len], .{}); + std.mem.writeInt(u64, bytes[magic.len..][0..8], manifest_end - header_len, .little); + std.crypto.hash.Blake3.hash(bytes[header_len..manifest_end], bytes[manifest_digest_offset..][0..32], .{}); + std.crypto.hash.Blake3.hash(bytes[manifest_end..], bytes[payload_digest_offset..][0..32], .{}); return bytes; } - fn decode(arena: Allocator, bytes: []const u8) Allocator.Error!?ProgramOutput { - if (bytes.len < header_len or !std.mem.eql(u8, bytes[0..magic.len], magic)) return null; + fn manifestEnd(header: []const u8) ?usize { + if (header.len < header_len or !std.mem.eql(u8, header[0..magic.len], magic)) return null; + const len = std.math.cast(usize, std.mem.readInt(u64, header[magic.len..][0..8], .little)) orelse return null; + return std.math.add(usize, header_len, len) catch null; + } + + fn readSection(comptime T: type, arena: Allocator, bytes: []const u8, expected_digest: *const [32]u8) Allocator.Error!?T { var digest: [32]u8 = undefined; - std.crypto.hash.Blake3.hash(bytes[header_len..], &digest, .{}); - if (!std.mem.eql(u8, &digest, bytes[magic.len..header_len])) return null; - var reader = compile.completed_compilation.Reader{ .bytes = bytes[header_len..], .arena = arena, .remaining = (bytes.len - header_len) *| 4 }; - const result = reader.read(ProgramOutput) catch |err| switch (err) { + std.crypto.hash.Blake3.hash(bytes, &digest, .{}); + if (!std.mem.eql(u8, &digest, expected_digest)) return null; + var reader = compile.completed_compilation.Reader{ .bytes = bytes, .arena = arena, .remaining = bytes.len *| 4 }; + const result = reader.read(T) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.InvalidRecord => return null, }; - if (reader.offset != reader.bytes.len) return null; - return result; + return if (reader.offset == bytes.len) result else null; + } + + fn decodeManifest(arena: Allocator, bytes: []const u8) Allocator.Error!?Manifest { + const end = manifestEnd(bytes) orelse return null; + if (end > bytes.len) return null; + return readSection(Manifest, arena, bytes[header_len..end], bytes[manifest_digest_offset..][0..32]); + } + + fn decode(arena: Allocator, bytes: []const u8) Allocator.Error!?ProgramOutput { + const manifest = try decodeManifest(arena, bytes) orelse return null; + const end = manifestEnd(bytes).?; + const payload = try readSection(Payload, arena, bytes[end..], bytes[payload_digest_offset..][0..32]) orelse return null; + return .{ + .manifest = manifest, + .object = payload.object, + .additional_objects = payload.additional_objects, + .facts = payload.facts, + .outcome = payload.outcome, + .diagnostics = payload.diagnostics, + }; } }; @@ -12186,6 +12257,9 @@ test "program output records round-trip and reject foreign bytes" { }; const bytes = try ProgramOutput.encode(std.testing.allocator, .{ .object = "object bytes", .facts = facts }); defer std.testing.allocator.free(bytes); + const prefix = bytes[0..ProgramOutput.manifestEnd(bytes).?]; + try std.testing.expect((try ProgramOutput.decodeManifest(arena.allocator(), prefix)) != null); + try std.testing.expect((try ProgramOutput.decode(arena.allocator(), prefix)) == null); const decoded = (try ProgramOutput.decode(arena.allocator(), bytes)).?; try std.testing.expectEqualStrings("object bytes", decoded.object); try std.testing.expectEqual(@as(usize, 2), decoded.facts.hosted_symbols.len); @@ -12228,8 +12302,10 @@ test "program output records preserve observations and reject corrupt artifacts bytes[ProgramOutput.header_len + 9] ^= 1; try std.testing.expect(try ProgramOutput.decode(arena.allocator(), bytes) == null); // Even a well-checksummed envelope cannot claim more object bytes than it contains. - @memset(bytes[ProgramOutput.header_len..][0..8], 255); - std.crypto.hash.Blake3.hash(bytes[ProgramOutput.header_len..], bytes[ProgramOutput.magic.len..ProgramOutput.header_len], .{}); + bytes[ProgramOutput.header_len + 9] ^= 1; + const payload_start = ProgramOutput.manifestEnd(bytes).?; + @memset(bytes[payload_start..][0..8], 255); + std.crypto.hash.Blake3.hash(bytes[payload_start..], bytes[ProgramOutput.payload_digest_offset..][0..32], .{}); try std.testing.expect(try ProgramOutput.decode(arena.allocator(), bytes) == null); } @@ -12244,6 +12320,16 @@ const ProgramOutputProbe = struct { store: *const pack_store.Store, policy: [32]u8, linked_stores: ?*const pack_store.LinkedStores, + manager: *CacheManager, + request: [32]u8 = @splat(0), + resolution: [32]u8 = @splat(0), + optimizations: []const ProgramOutput.OptimizationInput = &.{}, + diagnostics: std.ArrayList(compile.completed_compilation.Diagnostic) = .empty, + current_modules: []compile.completed_compilation.Module = &.{}, + early_hit: bool = false, + validation_ns: u64 = 0, + source_bytes_read: u64 = 0, + manifest_bytes_read: u64 = 0, key: ?[32]u8 = null, found: ?ProgramOutput = null, bytes_read: usize = 0, @@ -12251,6 +12337,97 @@ const ProgramOutputProbe = struct { publication_ns: u64 = 0, runtime_emissions: u64 = 0, + fn observeDiagnostic(context: *anyopaque, report: *const reporting.Report) Allocator.Error!void { + const self: *ProgramOutputProbe = @ptrCast(@alignCast(context)); + try self.diagnostics.append(self.arena, try compile.completed_compilation.copyDiagnostic(self.arena, report)); + } + + fn initialize(self: *ProgramOutputProbe, args: cli_args.BuildArgs) Allocator.Error!void { + const descriptor = .{ + .domain = @as([]const u8, "roc-completed-request-v2"), + .store = @as([]const u8, self.store.root), + .cwd = self.build_env.cwd, + .entry = args.synthetic_root_original_path orelse args.path, + .source_dir = args.source_dir_override, + .source_url = args.root_source_url, + .policy = self.policy, + .pin = self.build_env.source_pin_version, + }; + const bytes = try compile.completed_compilation.encode(self.arena, descriptor); + std.crypto.hash.Blake3.hash(bytes, &self.request, .{}); + self.resolution = try self.build_env.completionResolutionKey(self.arena); + } + + /// The placement pointer is only a candidate locator. Admission validates + /// the record's complete input manifest before reading any object bytes. + fn early(self: *ProgramOutputProbe) Allocator.Error!bool { + const started = std.Io.Timestamp.now(self.store.roc_ctx.std_io, .awake).nanoseconds; + defer self.validation_ns += @intCast(std.Io.Timestamp.now(self.store.roc_ctx.std_io, .awake).nanoseconds - started); + const key = self.manager.loadPlacementPointer(self.request) orelse return false; + const header = try self.store.readProgramPrefix(key, ProgramOutput.header_len, self.arena) orelse return false; + self.bytes_read += header.len; + self.manifest_bytes_read += header.len; + const end = ProgramOutput.manifestEnd(header) orelse return false; + const prefix = try self.store.readProgramPrefix(key, end, self.arena) orelse return false; + self.bytes_read += prefix.len; + self.manifest_bytes_read += prefix.len; + const manifest = try ProgramOutput.decodeManifest(self.arena, prefix) orelse return false; + if (!std.mem.eql(u8, &manifest.request, &self.request)) return false; + const inputs = manifest.inputs orelse return false; + if (!try self.build_env.validateCompletionInputs(self.arena, inputs, self.resolution, &self.source_bytes_read)) return false; + if (self.linked_stores) |stores| { + if (manifest.optimizations.len != 2) return false; + for (manifest.optimizations) |input| { + if (!std.mem.eql(u8, &input.digest, &try stores.selectionDigest(input.selection))) return false; + } + } else if (manifest.optimizations.len != 0) return false; + const bytes = try self.store.readProgram(key, self.arena) orelse return false; + self.bytes_read += bytes.len; + // A concurrent atomic replacement must not switch the validated + // manifest underneath this reader. + if (bytes.len < end or !std.mem.eql(u8, prefix, bytes[0..end])) return false; + self.found = try ProgramOutput.decode(self.arena, bytes); + const output = if (self.found) |*value| value else return false; + if (output.outcome.targets == null or inputs.module_paths.len != output.outcome.modules.len) return false; + for (output.diagnostics) |diagnostic| if (diagnostic.severity.isError()) return false; + // Resolve every diagnostic source before replay. No checked module + // or coordinator is constructed to stand in for this completed result. + self.current_modules = try self.arena.alloc(compile.completed_compilation.Module, inputs.module_paths.len); + for (inputs.module_paths, output.outcome.modules, self.current_modules) |path, old, *current| current.* = .{ + .key = old.key, + .path = try self.build_env.completionPath(self.arena, path) orelse return false, + }; + self.key = key; + self.early_hit = true; + self.build_env.recorded_completion = &output.outcome; + self.build_env.recorded_inputs = inputs; + return true; + } + + fn replayEarly(self: *ProgramOutputProbe, ctx: *CliCtx) Allocator.Error!CheckDiagnosticCounts { + const output = self.found.?; + for (output.outcome.observations) |message| { + ctx.coreCtx().writeStderr("[dbg] ") catch {}; + ctx.coreCtx().writeStderr(message) catch {}; + ctx.coreCtx().writeStderr("\n") catch {}; + } + var warnings: usize = 0; + const config = ctx.reportConfig(.stderr); + const palette = reporting.ColorUtils.getPaletteForConfig(config); + for (output.diagnostics) |diagnostic| { + var report = try reporting.Report.init(ctx.gpa, diagnostic.title, "", diagnostic.severity); + defer report.deinit(); + compile.completed_compilation.rebindElements(diagnostic.headline, output.outcome.modules, self.current_modules); + compile.completed_compilation.rebindElements(diagnostic.body, output.outcome.modules, self.current_modules); + try report.headline.elements.appendSlice(diagnostic.headline); + try report.document.elements.appendSlice(diagnostic.body); + if (compile.completed_compilation.rebindPath(diagnostic.file, output.outcome.modules, self.current_modules)) |path| try report.setFile(path); + reporting.renderReportToTerminal(&report, ctx.io.stderr(), palette, config) catch {}; + warnings += 1; + } + return .{ .errors = 0, .warnings = warnings }; + } + fn runtimeOutputProbe(self: *ProgramOutputProbe) compile.build.RuntimeOutputProbe { return .{ .context = self, .cached = cached }; } @@ -12261,7 +12438,8 @@ const ProgramOutputProbe = struct { const modules = try coord.compilationModules(self.gpa); defer self.gpa.free(modules); var hasher = std.crypto.hash.Blake3.init(.{}); - hasher.update("roc.completed-build.v1"); + hasher.update("roc.completed-build.v2"); + hasher.update(&self.request); hasher.update(&self.policy); const pin = coord.source_pin_version orelse ""; var pin_len: [8]u8 = undefined; @@ -12281,14 +12459,19 @@ const ProgramOutputProbe = struct { // Only explicitly selected optimized stores affect output. Dev packs, // checked values and this build's own cache writes do not change it. if (self.linked_stores) |stores| { - hasher.update(&try stores.stateDigest(self.build_env, root)); - hasher.update(&try stores.stateDigest(self.build_env, coord.appRootCheckedArtifact())); + const inputs = try self.arena.alloc(ProgramOutput.OptimizationInput, 2); + for ([_]*const check.CheckedArtifact.CheckedModuleArtifact{ root, coord.appRootCheckedArtifact() }, inputs) |artifact, *input| { + input.selection = try pack_store.LinkedStores.selectionFor(self.arena, self.build_env, artifact); + input.digest = try stores.selectionDigest(input.selection); + hasher.update(&input.digest); + } + self.optimizations = inputs; } var key: [32]u8 = undefined; hasher.final(&key); self.key = key; const bytes = try self.store.readProgram(key, self.arena) orelse return null; - self.bytes_read = bytes.len; + self.bytes_read += bytes.len; self.found = try ProgramOutput.decode(self.arena, bytes); const found = if (self.found) |*value| value else return null; // Every manifest member is validated by the current content-based @@ -12307,6 +12490,7 @@ const ProgramOutputProbe = struct { }; if (!known) return null; } + self.manager.storePlacementPointer(self.request, key); return &found.outcome; } @@ -12320,20 +12504,35 @@ const ProgramOutputProbe = struct { defer self.gpa.free(object); var outcome = self.build_env.coordinator.?.completion_outcome; outcome.targets = targets; - const bytes = try ProgramOutput.encode(self.gpa, .{ .object = object, .additional_objects = objects, .facts = facts, .outcome = outcome }); + const inputs = try self.build_env.captureCompletionInputs(self.arena, self.resolution, outcome.modules); + const bytes = try ProgramOutput.encode(self.gpa, .{ + .manifest = .{ .request = self.request, .inputs = inputs, .optimizations = self.optimizations }, + .diagnostics = self.diagnostics.items, + .object = object, + .additional_objects = objects, + .facts = facts, + .outcome = outcome, + }); defer self.gpa.free(bytes); self.store.writeProgram(key, bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.PackWriteFailed => return, }; self.bytes_written = bytes.len; + self.manager.storePlacementPointer(self.request, key); } }; /// These counters count producer work, independently of phase labels. fn recordCompletionCounters(reporter: *progress.Reporter, build_env: *BuildEnv, probe: *const ProgramOutputProbe, hit: bool) void { - const timing = build_env.coordinator.?.ctfe_timing.snapshot(); + const timing = if (build_env.coordinator) |coord| coord.ctfe_timing.snapshot() else eval.CompileTimeFinalization.TimingSnapshot{}; reporter.recordCounters("Completed compilation", &.{ + .{ .name = "Early completed result hits", .count = @intFromBool(probe.early_hit) }, + .{ .name = "Checked modules loaded", .count = build_env.getBuildStats().cache_hits }, + .{ .name = "Checked modules compiled", .count = build_env.getBuildStats().modules_compiled }, + .{ .name = "Manifest bytes read", .count = probe.manifest_bytes_read }, + .{ .name = "Input bytes validated", .count = probe.source_bytes_read }, + .{ .name = "Input validation ns", .count = probe.validation_ns }, .{ .name = "Completed result hits", .count = @intFromBool(hit) }, .{ .name = "Demand specialization passes", .count = timing.specialization_passes - timing.respecialization_passes }, .{ .name = "Post-CTFE specialization passes", .count = timing.respecialization_passes }, @@ -12342,7 +12541,7 @@ fn recordCompletionCounters(reporter: *progress.Reporter, build_env: *BuildEnv, .{ .name = "Runtime object emissions", .count = probe.runtime_emissions }, .{ .name = "Completed bytes read", .count = probe.bytes_read }, .{ .name = "Completed bytes written", .count = probe.bytes_written }, - .{ .name = "Completed publication ns", .count = probe.publication_ns + build_env.coordinator.?.completion_capture_ns }, + .{ .name = "Completed publication ns", .count = probe.publication_ns + if (build_env.coordinator) |coord| coord.completion_capture_ns else @as(u64, 0) }, }); } diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index f11eea4d051..ff5c27fb1e6 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -87,6 +87,28 @@ pub const Store = struct { }; } + /// Read a bounded prefix without reading the record's object payload. + pub fn readProgramPrefix(self: *const Store, key: [32]u8, len: usize, allocator: Allocator) Allocator.Error!?[]const u8 { + const path = try self.programPath(key); + defer self.allocator.free(path); + const stat = self.roc_ctx.stat(path) catch return null; + if (len > stat.size) return null; + const bytes = try allocator.alloc(u8, len); + errdefer allocator.free(bytes); + const read = self.roc_ctx.readFileInto(path, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + allocator.free(bytes); + return null; + }, + }; + if (read != len) { + allocator.free(bytes); + return null; + } + return bytes; + } + /// Atomically publish a completed compilation. Replacing an existing /// record also repairs corruption after a miss; no partial bytes are visible. pub fn writeProgram(self: *const Store, key: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { @@ -609,21 +631,39 @@ pub const LinkedStores = struct { /// carries its own, so the digest reads no object. pub fn stateDigest(self: *const LinkedStores, build_env: *compile.BuildEnv, root_artifact: *const check.CheckedArtifact.CheckedModuleArtifact) Allocator.Error![32]u8 { const allocator = self.stores[0].allocator; - const modules = try packageModules(allocator, build_env, root_artifact); - defer allocator.free(modules); + const selection = try selectionFor(allocator, build_env, root_artifact); + defer allocator.free(selection.modules); + return self.selectionDigest(selection); + } + + /// Optimized object identities selected from a checked root and its imports. + pub const Selection = struct { modules: []const PackageModule, optimized_identity: ?[32]u8 }; + + /// Capture the checked producer's selection for later validation without IR. + pub fn selectionFor(allocator: Allocator, build_env: *compile.BuildEnv, root: *const check.CheckedArtifact.CheckedModuleArtifact) Allocator.Error!Selection { + return .{ + .modules = try packageModules(allocator, build_env, root), + .optimized_identity = if (optimizedPackPlacement(build_env, root)) |placement| placement.identity else null, + }; + } + + /// Selection was emitted by the checked producer. Its keys let an early + /// completed-build lookup inspect current optimized stores without IR. + pub fn selectionDigest(self: *const LinkedStores, selection: Selection) Allocator.Error![32]u8 { + const allocator = self.stores[0].allocator; var hasher = std.crypto.hash.Blake3.init(.{}); hasher.update("roc.optimization-state.v1"); for (&self.stores) |*store| { - for (modules) |module| { + for (selection.modules) |module| { const path = try store.packagePackPath(module.identity, module.key); defer allocator.free(path); if (!store.roc_ctx.fileExists(path)) continue; hasher.update(path); hasher.update(&.{0}); } - const placement = optimizedPackPlacement(build_env, root_artifact) orelse continue; - const input_key = try store.optimizedInputKey(placement.identity) orelse continue; - const path = try store.optimizedPackPath(placement.identity); + const identity = selection.optimized_identity orelse continue; + const input_key = try store.optimizedInputKey(identity) orelse continue; + const path = try store.optimizedPackPath(identity); defer allocator.free(path); hasher.update(path); hasher.update(&.{0}); @@ -691,7 +731,7 @@ const ProgramPack = struct { /// A URL-package module in view of a program: its placement identity and the /// artifact key its object pack is filed under. -const PackageModule = struct { +pub const PackageModule = struct { identity: [32]u8, key: [32]u8, diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 216a718db42..0142175463c 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -10862,6 +10862,13 @@ fn customRecordedProgramOutput( if (completionCounter(output, name) != 0) return failureFromRun(allocator, timer, run, "completed compilation repeated compiler work"); } } + if (index == 1) { + if (completionCounter(output, "Early completed result hits") != 1) return failureFromRun(allocator, timer, run, "unchanged build loaded the frontend before finding its result"); + for ([_][]const u8{ "Checked modules loaded", "Checked modules compiled" }) |name| { + if (completionCounter(output, name) != 0) return failureFromRun(allocator, timer, run, "early hit consumed checked IR"); + } + if ((completionCounter(output, "Input bytes validated") orelse 0) == 0) return failureFromRun(allocator, timer, run, "early hit skipped source content validation"); + } if (std.mem.count(u8, run.stderr, "CTFE publication observation") != 1) return failureFromRun(allocator, timer, run, "completed compilation changed debug observation multiplicity"); if (runRawAndCheck(io, allocator, env, timer, timeout_ms, &.{exe}, env.dirs.work_dir, .{ .args = &.{}, @@ -10873,6 +10880,27 @@ fn customRecordedProgramOutput( return customInfraFailure(allocator, timer, "failed to allocate output: {}", .{err}); const out_arg = outputArg(allocator, exe) catch |err| return customInfraFailure(allocator, timer, "failed to allocate argument: {}", .{err}); + // The completed result remains usable after its checked IR was evicted. + { + var cache_dir = std.Io.Dir.cwd().openDir(io, cache, .{ .iterate = true }) catch |err| return customInfraFailure(allocator, timer, "open cache: {}", .{err}); + defer cache_dir.close(io); + var walker = cache_dir.walk(allocator) catch |err| return customInfraFailure(allocator, timer, "walk cache: {}", .{err}); + defer walker.deinit(); + while (walker.next(io) catch |err| return customInfraFailure(allocator, timer, "walk cache: {}", .{err})) |entry| { + if (entry.kind == .file and (std.mem.find(u8, entry.path, "/mod/") != null or std.mem.find(u8, entry.path, "\\mod\\") != null)) + cache_dir.deleteFile(io, entry.path) catch |err| return customInfraFailure(allocator, timer, "evict checked IR: {}", .{err}); + } + } + const evicted = switch (captureRocRun(io, allocator, &isolated, timer, timeout_ms, .{ + .args = &.{ "build", "--opt=dev", "--timings", "--no-color", out_arg }, + .roc_file = app, + .occurrences = &.{.{ .stream = .stderr, .text = "CTFE publication observation", .count = 1 }}, + })) { + .result => |run| run, + .failure => |failure| return failure, + }; + if (completionCounter(evicted.stderr, "Early completed result hits") != 1 or completionCounter(evicted.stderr, "Checked modules loaded") != 0 or completionCounter(evicted.stderr, "Checked modules compiled") != 0) + return failureFromRun(allocator, timer, evicted, "completed result depended on evicted checked IR"); // Check and test demand the unused failing root; build never does, even // after those commands have populated their caches. for ([_][]const []const u8{ &.{ "check", "--no-color" }, &.{ "test", "--opt=dev", "--no-color" }, &.{ "build", "--opt=dev", "--specialize=no", "--no-color", out_arg } }) |args| { @@ -10921,7 +10949,7 @@ fn customRecordedProgramOutput( .result => |run| run, .failure => |failure| return failure, }; - if (completionCounter(run.stderr, "Completed result hits") != index) return failureFromRun(allocator, timer, run, "warning-only compilation did not reuse completed result"); + if (completionCounter(run.stderr, "Early completed result hits") != index) return failureFromRun(allocator, timer, run, "warning-only compilation did not reuse completed result before frontend loading"); } // A headerless app stages its synthetic platform in a fresh directory. // Finalization owns this warning; replay must rebind it to the user file. @@ -10946,7 +10974,7 @@ fn customRecordedProgramOutput( .result => |run| run, .failure => |failure| return failure, }; - if (completionCounter(run.stderr, "Completed result hits") != index) return failureFromRun(allocator, timer, run, "staging paths invalidated the completed compilation"); + if (completionCounter(run.stderr, "Early completed result hits") != index) return failureFromRun(allocator, timer, run, "staging paths invalidated the early completed compilation"); if (std.mem.find(u8, run.stderr, "headerless.roc") == null) return failureFromRun(allocator, timer, run, "finalization warning lost its user source path"); if (runRawAndCheck(io, allocator, env, timer, timeout_ms, &.{exe}, env.dirs.work_dir, .{ .args = &.{}, .stdout_exact = "", .stderr_exact = "" })) |failure| return failure; } diff --git a/src/compile/compile_build.zig b/src/compile/compile_build.zig index a48b9cc9b5d..5546805896b 100644 --- a/src/compile/compile_build.zig +++ b/src/compile/compile_build.zig @@ -20,6 +20,7 @@ const check = @import("check"); const lir = @import("lir"); const unbundle = if (is_freestanding) struct {} else @import("unbundle"); const CoreCtx = @import("ctx").CoreCtx; +const completed_compilation = @import("completed_compilation.zig"); /// Probes a completed compilation after frontend validation, before any /// platform evaluation or app pairing. The returned outcome is an explicit @@ -298,6 +299,13 @@ pub const BuildEnv = struct { /// Optional source directory used to resolve imports from the root module. root_source_dir_override: ?[]const u8 = null, + /// An early completed result owns its outcome without creating a coordinator. + recorded_completion: ?*const completed_compilation.Outcome = null, + recorded_inputs: ?completed_compilation.Inputs = null, + diagnostic_observer: ?struct { + context: *anyopaque, + record: *const fn (*anyopaque, *const reporting.Report) Allocator.Error!void, + } = null, /// Source mapping for a temporary synthetic root created by the CLI for a /// headerless default app. These slices are borrowed from CLI-owned storage @@ -521,8 +529,152 @@ pub const BuildEnv = struct { self.cache_manager = manager; } + /// The resolver's complete current package graph, independent of checked IR. + pub fn completionResolutionKey(self: *BuildEnv, allocator: Allocator) Allocator.Error![32]u8 { + const Edge = struct { alias: []const u8, package: []const u8 }; + const PackageInput = struct { name: []const u8, root_file: completed_compilation.InputPath, kind: PackageKind, state: ?watch_inputs.State, edges: []Edge }; + const packages = try allocator.alloc(PackageInput, self.packages.count()); + var it = self.packages.iterator(); + var i: usize = 0; + while (it.next()) |entry| : (i += 1) { + const pkg = entry.value_ptr; + const edges = try allocator.alloc(Edge, pkg.shorthands.count()); + var aliases = pkg.shorthands.iterator(); + var j: usize = 0; + while (aliases.next()) |alias| : (j += 1) edges[j] = .{ .alias = alias.key_ptr.*, .package = alias.value_ptr.name }; + std.mem.sort(Edge, edges, {}, struct { + fn less(_: void, a: Edge, b: Edge) bool { + return std.mem.lessThan(u8, a.alias, b.alias); + } + }.less); + packages[i] = .{ .name = pkg.name, .root_file = try self.completionInputPath(allocator, pkg.root_file), .kind = pkg.kind, .state = pkg.root_file_state, .edges = edges }; + } + std.mem.sort(PackageInput, packages, {}, struct { + fn less(_: void, a: PackageInput, b: PackageInput) bool { + return std.mem.lessThan(u8, a.name, b.name); + } + }.less); + const bytes = try completed_compilation.encode(allocator, packages); + var key: [32]u8 = undefined; + std.crypto.hash.Blake3.hash(bytes, &key, .{}); + return key; + } + + /// Relocate only registered compiler-owned source directories. All other + /// paths are native compilation inputs, including paths through symlinks. + pub fn completionInputPath(self: *BuildEnv, allocator: Allocator, path: []const u8) Allocator.Error!completed_compilation.InputPath { + var owner: ?*const Package = null; + var it = self.packages.iterator(); + while (it.next()) |entry| { + const pkg = entry.value_ptr; + if (!std.mem.eql(u8, pkg.name, package_identity.synthetic_app_identity) and + !std.mem.eql(u8, pkg.name, package_identity.synthetic_platform_identity) and + compiler_platforms.fromIdentity(pkg.name) == null) continue; + if (isPathInDir(path, pkg.root_dir) and (owner == null or pkg.root_dir.len > owner.?.root_dir.len)) owner = pkg; + } + if (owner) |pkg| return .{ + .package = pkg.name, + .path = try std.fs.path.relative(allocator, self.cwd, null, pkg.root_dir, path), + }; + return .{ .path = path }; + } + + /// Bind a recorded input path to the current invocation's package graph. + pub fn completionPath(self: *BuildEnv, allocator: Allocator, path: completed_compilation.InputPath) Allocator.Error!?[]const u8 { + const name = path.package orelse return path.path; + const pkg = self.packages.get(name) orelse return null; + return try std.fs.path.resolve(allocator, &.{ pkg.root_dir, path.path }); + } + + /// Record the source, asset and import facts consumed by a successful build. + pub fn captureCompletionInputs(self: *BuildEnv, allocator: Allocator, resolution: [32]u8, modules: []const completed_compilation.Module) Allocator.Error!completed_compilation.Inputs { + const watched = try self.collectWatchInputStates(); + defer self.freeWatchInputStates(watched); + const files = try allocator.alloc(completed_compilation.FileInput, watched.len); + for (watched, files) |input, *out| out.* = .{ .path = try self.completionInputPath(allocator, try allocator.dupe(u8, input.path)), .state = input.state }; + var checks = std.ArrayList(completed_compilation.PathCheck).empty; + var seen_checks = std.StringHashMapUnmanaged([]const u8){}; + var directories = std.ArrayList(completed_compilation.DirectoryInput).empty; + var it = self.coordinator.?.packages.iterator(); + while (it.next()) |entry| { + const pkg = entry.value_ptr.*; + // URL bundles have already been admitted by the resolver's + // immutable bundle contract; only local inputs need revalidation. + if (pkg.url != null) continue; + for (pkg.source_path_checks.items) |input| { + if (seen_checks.get(input.path)) |previous| if (std.mem.eql(u8, previous, input.canonical)) continue; + try seen_checks.put(allocator, input.path, input.canonical); + try checks.append(allocator, .{ + .path = try self.completionInputPath(allocator, input.path), + .canonical = try self.completionInputPath(allocator, input.canonical), + }); + } + if (pkg.source_entries != null) { + const root = if (pkg.root_module_id) |id| pkg.getModule(id).?.source_dir_override orelse pkg.root_dir else pkg.root_dir; + var paths = std.ArrayList(completed_compilation.InputPath).empty; + for (pkg.source_path_checks.items) |input| { + if (input.spelling_checked) try paths.append(allocator, try self.completionInputPath(allocator, input.path)); + } + try directories.append(allocator, .{ .path = try self.completionInputPath(allocator, root), .entries = paths.items }); + } + } + const module_paths = try allocator.alloc(completed_compilation.InputPath, modules.len); + for (modules, module_paths) |module, *path| path.* = try self.completionInputPath(allocator, module.path); + return .{ .resolution = resolution, .files = files, .path_checks = checks.items, .directories = directories.items, .module_paths = module_paths }; + } + + /// Content and path validation performs no IR admission or relocation. + /// I/O failure makes this candidate unusable; ordinary compilation owns + /// the corresponding source diagnostic. + pub fn validateCompletionInputs(self: *BuildEnv, allocator: Allocator, inputs: completed_compilation.Inputs, resolution: [32]u8, bytes_read: *u64) Allocator.Error!bool { + if (!std.mem.eql(u8, &inputs.resolution, &resolution)) return false; + for (inputs.files) |input| { + const path = try self.completionPath(allocator, input.path) orelse return false; + const bytes = self.filesystem.readFile(path, self.gpa) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return false, + }; + defer self.gpa.free(bytes); + bytes_read.* += bytes.len; + switch (input.state) { + .hash => |hash| if (!std.mem.eql(u8, &hash, &watch_inputs.hashBytes(bytes))) return false, + .missing, .unreadable => return false, + } + } + for (inputs.path_checks) |input| { + const path = try self.completionPath(allocator, input.path) orelse return false; + const expected = try self.completionPath(allocator, input.canonical) orelse return false; + const canonical = self.filesystem.canonicalize(path, self.gpa) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return false, + }; + defer self.gpa.free(@constCast(canonical)); + if (!std.mem.eql(u8, canonical, expected)) return false; + } + for (inputs.directories) |input| { + const path = try self.completionPath(allocator, input.path) orelse return false; + const entries = self.filesystem.listDir(path, self.gpa) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return false, + }; + defer { + for (entries) |entry| self.gpa.free(entry.path); + self.gpa.free(entries); + } + var current = std.StringHashMapUnmanaged(void){}; + defer current.deinit(self.gpa); + for (entries) |entry| try current.put(self.gpa, entry.path, {}); + for (input.entries) |old| { + const expected = try self.completionPath(allocator, old) orelse return false; + if (!current.contains(expected)) return false; + } + } + return true; + } + /// Get the TargetsConfig from the platform package, if any. pub fn getPlatformTargetsConfig(self: *const BuildEnv) ?targets_config_mod.TargetsConfig { + if (self.recorded_completion) |outcome| return outcome.targets; if (self.coordinator) |coord| switch (coord.compilation_result) { .recorded => |outcome| return outcome.targets, .live => {}, @@ -945,6 +1097,7 @@ pub const BuildEnv = struct { /// Whether the driver is consuming a completed compilation instead of a /// live program session. No module evaluation state is changed by a hit. pub fn runtimeOutputCached(self: *const BuildEnv) bool { + if (self.recorded_completion != null) return true; const coordinator = self.coordinator orelse return false; return coordinator.compilation_result == .recorded; } @@ -2150,7 +2303,7 @@ pub const BuildEnv = struct { return error.MissingTargetFile; } - fn validateDiscoveredPlatformTargetFilesForCurrentTarget(self: *BuildEnv) BuildError!void { + pub fn validateDiscoveredPlatformTargetFilesForCurrentTarget(self: *BuildEnv) BuildError!void { var pkg_it = self.packages.iterator(); while (pkg_it.next()) |entry| { const pkg = entry.value_ptr; @@ -2583,6 +2736,21 @@ pub const BuildEnv = struct { base.invariant("build env invariant violated: collectWatchInputStates called without watch input tracking enabled", .{}); } + if (self.recorded_inputs) |recorded| { + const inputs = try self.gpa.alloc(watch_inputs.Input, recorded.files.len); + var initialized: usize = 0; + errdefer { + for (inputs[0..initialized]) |input| self.gpa.free(input.path); + self.gpa.free(inputs); + } + for (recorded.files, inputs) |file, *input| { + const path = try self.completionPath(self.gpa, file.path) orelse + base.invariant("validated completion lost its input package", .{}); + input.* = .{ .path = if (file.path.package != null) path else try self.gpa.dupe(u8, path), .state = file.state }; + initialized += 1; + } + return inputs; + } var inputs = std.ArrayList(watch_inputs.Input).empty; errdefer { for (inputs.items) |input| self.gpa.free(input.path); @@ -3439,6 +3607,7 @@ pub const BuildEnv = struct { for (drained) |mod| { for (mod.reports) |*report| { + if (self.diagnostic_observer) |observer| try observer.record(observer.context, report); if (report.severity.isError()) total_error_count += 1 else total_warning_count += 1; reporting.renderReportToTerminal(report, writer, palette, config) catch {}; } @@ -4469,3 +4638,65 @@ test "findModuleByQualifiedNameInPackage strictly preserves shorthand identity a const null_pkg_res = env.findModuleByQualifiedNameInPackage(null, "dep.RealModule"); try std.testing.expect(null_pkg_res == null); } + +test "completed input validation hashes contents and checks resolution without a coordinator" { + const testing = std.testing; + var arena = base.SingleThreadArena.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(testing.io, ".", allocator); + const path = try std.fs.path.join(allocator, &.{ root, "input.bin" }); + var env = try BuildEnv.init(testing.allocator, .single_threaded, 1, roc_target.RocTarget.detectNative(), root, testing.io); + defer env.deinit(); + const inputs = completed_compilation.Inputs{ + .resolution = @splat(1), + .files = &.{.{ .path = .{ .path = path }, .state = .{ .hash = watch_inputs.hashBytes("abc") } }}, + .path_checks = &.{}, + .directories = &.{.{ .path = .{ .path = root }, .entries = &.{.{ .path = path }} }}, + .module_paths = &.{}, + }; + var bytes: u64 = 0; + try tmp.dir.writeFile(testing.io, .{ .sub_path = "input.bin", .data = "abc" }); + try testing.expect(try env.validateCompletionInputs(allocator, inputs, @splat(1), &bytes)); + try testing.expectEqual(@as(u64, 3), bytes); + try testing.expect(env.coordinator == null); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "unrelated-output", .data = "output" }); + try testing.expect(try env.validateCompletionInputs(allocator, inputs, @splat(1), &bytes)); + try testing.expect(!try env.validateCompletionInputs(allocator, inputs, @splat(2), &bytes)); + // Equal size and a readable path are insufficient evidence of freshness. + try tmp.dir.writeFile(testing.io, .{ .sub_path = "input.bin", .data = "xyz" }); + try testing.expect(!try env.validateCompletionInputs(allocator, inputs, @splat(1), &bytes)); + try tmp.dir.deleteFile(testing.io, "input.bin"); + try testing.expect(!try env.validateCompletionInputs(allocator, inputs, @splat(1), &bytes)); +} + +test "completed input validation rejects changed symlink identity with identical contents" { + const testing = std.testing; + var arena = base.SingleThreadArena.init(testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "first.roc", .data = "same" }); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "second.roc", .data = "same" }); + tmp.dir.symLink(testing.io, "first.roc", "Import.roc", .{}) catch return error.SkipZigTest; + const root = try tmp.dir.realPathFileAlloc(testing.io, ".", allocator); + const path = try std.fs.path.join(allocator, &.{ root, "Import.roc" }); + const canonical = try tmp.dir.realPathFileAlloc(testing.io, "Import.roc", allocator); + var env = try BuildEnv.init(testing.allocator, .single_threaded, 1, roc_target.RocTarget.detectNative(), root, testing.io); + defer env.deinit(); + const inputs = completed_compilation.Inputs{ + .resolution = @splat(1), + .files = &.{.{ .path = .{ .path = path }, .state = .{ .hash = watch_inputs.hashBytes("same") } }}, + .path_checks = &.{.{ .path = .{ .path = path }, .canonical = .{ .path = canonical } }}, + .directories = &.{}, + .module_paths = &.{}, + }; + var bytes: u64 = 0; + try testing.expect(try env.validateCompletionInputs(allocator, inputs, @splat(1), &bytes)); + try tmp.dir.deleteFile(testing.io, "Import.roc"); + try tmp.dir.symLink(testing.io, "second.roc", "Import.roc", .{}); + try testing.expect(!try env.validateCompletionInputs(allocator, inputs, @splat(1), &bytes)); +} diff --git a/src/compile/completed_compilation.zig b/src/compile/completed_compilation.zig index bba1d7dee21..5de848b599b 100644 --- a/src/compile/completed_compilation.zig +++ b/src/compile/completed_compilation.zig @@ -12,6 +12,47 @@ pub const Module = struct { identity_path: []const u8 = "", }; +/// Compiler-owned staging paths are relative to an explicitly named package. +/// Ordinary source paths retain the spelling used by the compilation. +pub const InputPath = struct { + package: ?[]const u8 = null, + path: []const u8, +}; +/// A source or asset and the content state consumed by compilation. +pub const FileInput = struct { path: InputPath, state: @import("watch_inputs.zig").State }; +/// A source path and its canonical identity accepted during import resolution. +pub const PathCheck = struct { path: InputPath, canonical: InputPath }; +/// Exact directory-entry spellings accepted by import resolution. Unrelated +/// files and build outputs in the same directory are not compilation inputs. +pub const DirectoryInput = struct { path: InputPath, entries: []const InputPath }; +/// Inputs sufficient to validate a completed compilation without checked IR. +pub const Inputs = struct { + resolution: [32]u8, + files: []const FileInput, + path_checks: []const PathCheck, + directories: []const DirectoryInput, + /// Same order as Outcome.modules, for diagnostic path rebinding. + module_paths: []const InputPath, +}; + +/// Capture documents before their rendering owner releases them. The returned +/// record is independent of both checked IR and the report's arena. +pub fn copyDiagnostic(arena: Allocator, report: *const reporting.Report) Allocator.Error!Diagnostic { + const bytes = try encode(arena, Diagnostic{ + .module = @splat(0), + .title = report.title, + .severity = report.severity, + .file = report.file, + .headline = report.headline.elements.items, + .body = report.document.elements.items, + }); + var reader = Reader{ .bytes = bytes, .arena = arena, .remaining = bytes.len * 4 }; + return reader.read(Diagnostic) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidRecord => @import("base").invariant("completion diagnostic writer produced invalid bytes", .{}), + }; +} + /// A report a module produced, kept as typed document items so its paths /// can be rebound to the consuming invocation's source paths. pub const Diagnostic = struct { diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index c8174d79096..97ad8f5aadc 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -993,6 +993,10 @@ pub const PublicModuleTarget = struct { /// State of a package in the workspace pub const PackageState = struct { + /// Canonical paths accepted while resolving imports. Recorded at the + /// validation boundary, never reconstructed from the solved program. + pub const SourcePathCheck = struct { path: []const u8, canonical: []const u8, spelling_checked: bool = false }; + source_path_checks: std.ArrayList(SourcePathCheck) = .empty, /// Package name (alias in workspace) name: []const u8, /// Root directory for the package @@ -1043,6 +1047,11 @@ pub const PackageState = struct { } pub fn deinit(self: *PackageState, gpa: Allocator) void { + for (self.source_path_checks.items) |entry| { + gpa.free(entry.path); + gpa.free(entry.canonical); + } + self.source_path_checks.deinit(gpa); if (comptime trace_build) { std.debug.print("[PKG DEINIT] {s}: deiniting {} modules\n", .{ self.name, self.modules.items.len }); } @@ -5133,6 +5142,16 @@ pub const Coordinator = struct { if (!pathIsWithinRoot(canonical_path, canonical_root)) return .outside_package; + if (self.track_watch_inputs) { + for ([_][]const u8{ package_root, path }, [_][]const u8{ canonical_root, canonical_path }) |input, canonical_input| { + const owned_input = try self.gpa.dupe(u8, input); + errdefer self.gpa.free(owned_input); + const owned_canonical = try self.gpa.dupe(u8, canonical_input); + errdefer self.gpa.free(owned_canonical); + try pkg.source_path_checks.append(self.gpa, .{ .path = owned_input, .canonical = owned_canonical }); + } + } + const lexical_path = try std.fs.path.resolve(self.gpa, &.{path}); defer self.gpa.free(lexical_path); if (!pathsHaveExactSpelling(canonical_path, lexical_path)) { @@ -5151,6 +5170,7 @@ pub const Coordinator = struct { } } if (!has_exact_spelling) return .incorrect_spelling; + if (self.track_watch_inputs) pkg.source_path_checks.items[pkg.source_path_checks.items.len - 1].spelling_checked = true; } if (pkg.physical_module_paths.get(canonical_path)) |existing| { From 58b0bff88a856a97d055f27e64592e72896b5ebd Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 01:23:02 -0400 Subject: [PATCH 07/97] Switch on the offer bucket open error instead of comparing it --- src/cli/pack_store.zig | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 1d0117f86eb..836eb08ee65 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -1108,9 +1108,15 @@ pub const LoadedPacks = struct { const bucket = try std.fs.path.join(allocator, &.{ index_root, "offers", &std.fmt.bytesToHex(key, .lower) }); buckets.appendAssumeCapacity(bucket); self.index_stats.bucket_opens += 1; - var dir = std.Io.Dir.cwd().openDir(io, bucket, .{ .iterate = true }) catch |err| { - if (err == error.FileNotFound) self.index_stats.bucket_absent += 1 else cacheable = false; - continue; + var dir = std.Io.Dir.cwd().openDir(io, bucket, .{ .iterate = true }) catch |err| switch (err) { + error.FileNotFound => { + self.index_stats.bucket_absent += 1; + continue; + }, + else => { + cacheable = false; + continue; + }, }; defer dir.close(io); var it = dir.iterate(); From 7ad9163dae2e8cf9d324a74d99d9007d1e197e96 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 02:39:50 -0400 Subject: [PATCH 08/97] Report recorded pack links for completed dev builds and count the exact dbg observation line --- src/cli/main.zig | 56 +++++++++++++++++++--------- src/cli/test/parallel_cli_runner.zig | 2 +- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/cli/main.zig b/src/cli/main.zig index 6333476025b..97849d570d2 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -11914,6 +11914,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu backend.writeFileWindowsAvSafe(ctx.io.std_io, path, object) catch return error.NativeCompilationFailed; try extra_objects.append(ctx.arena, path); } + if (loaded_packs) |packs| printPackStats(packs, output.pack_links); recordCompletionCounters(&reporter, &build_env, &program_probe.?, true); try linkNativeBuild(ctx, &reporter, args, target, link_type, final_output_path, platform_dir, resolved_targets_config, build_scratch_dir, obj_path, extra_objects.items, output.facts); return finishBuildCommand(ctx, args, &build_env, &reporter, final_output_path, diag, timer_start_ns); @@ -11933,22 +11934,8 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu // The program links the objects defining the entries it took, and no // other. if (loaded_packs) |packs| packs.settleLinkedObjects(lowered.lir_result.store.getProcSpecs()); - // Cache statistics are diagnostic output. Ordinary successful builds must - // leave stderr empty so build runners do not report false warnings. - if (loaded_packs != null and dev_diagnostics.isSet(.pack_stats)) { - const packs = loaded_packs.?.*; - var external_procs: usize = 0; - for (lowered.lir_result.store.getProcSpecs()) |proc| { - if (proc.external) external_procs += 1; - } - // The compile-time evaluator has already spliced its entries; the - // object compiler asks for the runtime program's afterwards. - var linked_procs: usize = 0; - for (lowered.lir_result.store.getProcSpecs()) |proc| { - if (proc.c_abi_entry) linked_procs += 1; - } - std.debug.print("pack hits: {d} external procs: {d} linked procs: {d} evaluator artifacts: {d} packs loaded: {d} keys: {d}\n", .{ packs.hits, external_procs, linked_procs, packs.artifacts_served, packs.packs.items.len, packs.specs.count() }); - } + const pack_links = programPackLinks(lowered.lir_result.store.getProcSpecs()); + if (loaded_packs) |packs| printPackStats(packs, pack_links); const entrypoints = try nativeBuildEntrypoints(ctx, root_artifact, &lowered); defer ctx.gpa.free(entrypoints); @@ -12044,7 +12031,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu // The record retains every selected object. LSS records never carry // Boxy runtime inputs; those remain exclusive to the ordinary Boxy path. if (program_probe) |*probe| if (probe.key) |key| if (diag.errors == 0 and !uses_boxy) { - try probe.record(key, obj_path, linked_objects, link_facts, resolved_targets_config); + try probe.record(key, obj_path, linked_objects, link_facts, pack_links, resolved_targets_config); }; if (program_probe) |*probe| recordCompletionCounters(&reporter, &build_env, probe, false); @@ -12158,6 +12145,7 @@ const ProgramOutput = struct { object: []const u8, additional_objects: []const []const u8 = &.{}, facts: LinkFacts, + pack_links: PackLinks = .{}, outcome: compile.completed_compilation.Outcome = .{}, const LinkFacts = struct { @@ -12165,6 +12153,12 @@ const ProgramOutput = struct { link_root_symbols: []const []const u8, app_export_symbols: []const []const u8, }; + /// How many of the lowered program's procedures come from outside its + /// Roc object, and how many of those linked pack objects define. + const PackLinks = struct { + external_procs: u64 = 0, + linked_procs: u64 = 0, + }; const OptimizationInput = struct { selection: pack_store.LinkedStores.Selection, digest: [32]u8, @@ -12178,6 +12172,7 @@ const ProgramOutput = struct { object: []const u8, additional_objects: []const []const u8, facts: LinkFacts, + pack_links: PackLinks, outcome: compile.completed_compilation.Outcome, diagnostics: []const compile.completed_compilation.Diagnostic, }; @@ -12198,6 +12193,7 @@ const ProgramOutput = struct { .object = output.object, .additional_objects = output.additional_objects, .facts = output.facts, + .pack_links = output.pack_links, .outcome = output.outcome, .diagnostics = output.diagnostics, }) catch return error.OutOfMemory; @@ -12241,6 +12237,7 @@ const ProgramOutput = struct { .object = payload.object, .additional_objects = payload.additional_objects, .facts = payload.facts, + .pack_links = payload.pack_links, .outcome = payload.outcome, .diagnostics = payload.diagnostics, }; @@ -12494,7 +12491,7 @@ const ProgramOutputProbe = struct { return &found.outcome; } - fn record(self: *ProgramOutputProbe, key: [32]u8, obj_path: []const u8, objects: []const []const u8, facts: ProgramOutput.LinkFacts, targets: roc_target.TargetsConfig) CliMainError!void { + fn record(self: *ProgramOutputProbe, key: [32]u8, obj_path: []const u8, objects: []const []const u8, facts: ProgramOutput.LinkFacts, pack_links: ProgramOutput.PackLinks, targets: roc_target.TargetsConfig) CliMainError!void { const started = std.Io.Timestamp.now(self.store.roc_ctx.std_io, .awake).nanoseconds; defer self.publication_ns = @intCast(std.Io.Timestamp.now(self.store.roc_ctx.std_io, .awake).nanoseconds - started); const object = self.store.roc_ctx.readFile(obj_path, self.gpa) catch |err| switch (err) { @@ -12511,6 +12508,7 @@ const ProgramOutputProbe = struct { .object = object, .additional_objects = objects, .facts = facts, + .pack_links = pack_links, .outcome = outcome, }); defer self.gpa.free(bytes); @@ -12523,6 +12521,28 @@ const ProgramOutputProbe = struct { } }; +/// The procedures of a lowered program that its Roc object does not define: +/// external ones, and of those the ones linked pack objects define. The +/// compile-time evaluator has already spliced its own entries; the object +/// compiler asks for the runtime program's afterwards. +fn programPackLinks(procs: []const lir.LIR.LirProcSpec) ProgramOutput.PackLinks { + var links: ProgramOutput.PackLinks = .{}; + for (procs) |proc| { + if (proc.external) links.external_procs += 1; + if (proc.c_abi_entry) links.linked_procs += 1; + } + return links; +} + +/// Print this session's pack statistics with the program's pack links, +/// which a completed compilation carries from the build that lowered it. +/// Cache statistics are diagnostic output. Ordinary successful builds must +/// leave stderr empty so build runners do not report false warnings. +fn printPackStats(packs: *const pack_store.LoadedPacks, links: ProgramOutput.PackLinks) void { + if (!dev_diagnostics.isSet(.pack_stats)) return; + std.debug.print("pack hits: {d} external procs: {d} linked procs: {d} evaluator artifacts: {d} packs loaded: {d} keys: {d}\n", .{ packs.hits, links.external_procs, links.linked_procs, packs.artifacts_served, packs.packs.items.len, packs.specs.count() }); +} + /// These counters count producer work, independently of phase labels. fn recordCompletionCounters(reporter: *progress.Reporter, build_env: *BuildEnv, probe: *const ProgramOutputProbe, hit: bool) void { const timing = if (build_env.coordinator) |coord| coord.ctfe_timing.snapshot() else eval.CompileTimeFinalization.TimingSnapshot{}; diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 0142175463c..4d5e56a1019 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -11305,7 +11305,7 @@ fn customCtfeCachePublication( .result => |run| run, .failure => |failure| return failure, }; - if (std.mem.count(u8, run.stderr, "CTFE publication observation") != 1) { + if (std.mem.count(u8, run.stderr, "[dbg] \"CTFE publication observation\"") != 1) { return failureFromRun(allocator, timer, run, "command did not preserve its compile-time observation exactly once"); } // Every command files the code its compile-time evaluation ran, From 3a3002b220a6a7a9b9c85807961e37f4e442e668 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 08:20:16 -0400 Subject: [PATCH 09/97] Trace the compile-time pack a command publishes --- src/cli/main.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/main.zig b/src/cli/main.zig index 5cb5d559c57..4d57f26a992 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -9188,6 +9188,7 @@ fn publishCompileTimePack(ctx: *CliCtx, build_env: *BuildEnv) Allocator.Error!vo // its own program, and the pack offers the procedures of all of them. var combined = try completed.combinedArtifacts(ctx.gpa); defer combined.deinit(); + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("compile-time pack {d} artifacts\n", .{combined.artifacts.len}); try parts.offers.publish(build_env, store, destination.placement.origin, destination.placement.identity, destination.key, &combined); cache.packs.refreshIndex(); } From fd3fd525e417f02539e412cc8af13f9480e0e19b Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 08:20:16 -0400 Subject: [PATCH 10/97] Have the shared object cache primer compile the Lib procedures it offers --- src/cli/test/parallel_cli_runner.zig | 16 +++++++++------- test/cli/issue_11710_shared_object_cache/Lib.roc | 2 +- .../issue_11710_shared_object_cache/Primer.roc | 16 +++++++++++++--- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 761ca717b5a..acfdec4b682 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -10060,13 +10060,15 @@ fn customIssue11673CallableCache( } // Repro for https://github.com/roc-lang/roc/issues/11710: Primer and Repro -// import Lib and build under one object cache. Primer never calls Lib, so the -// pack its build writes for Lib comes from Lib's own pack program, which lowers -// Lib's closed exports alone; Repro's program joins Lib's function values into -// different callable sets. Each cached procedure's identity must not depend on -// the callable sets its value joins, or Repro's build finds one specialization -// key naming two procedure identities. Repro gets no pack hits without Primer, -// so its hits here come from Primer's pack. +// import Lib and build under one object cache. A runtime build offers only the +// procedures its own program compiled, routing each app-independent Lib +// procedure to Lib's provider, so Primer calls Lib.parse and Lib.query_pairs +// on a path its run never takes. Primer's program shapes Lib's function values +// differently from Repro's, which calls Lib.parse alone. Each cached +// procedure's identity must not depend on the callable sets its value joins, +// or Repro's build finds one specialization key naming two procedure +// identities. Repro gets no pack hits without Primer, so its hits here come +// from the Lib offers Primer's build published. fn customIssue11710SharedObjectCache( io: std.Io, allocator: Allocator, diff --git a/test/cli/issue_11710_shared_object_cache/Lib.roc b/test/cli/issue_11710_shared_object_cache/Lib.roc index 1edf3026342..cfb0a0f188d 100644 --- a/test/cli/issue_11710_shared_object_cache/Lib.roc +++ b/test/cli/issue_11710_shared_object_cache/Lib.roc @@ -1,5 +1,5 @@ # Reduced from basic-cli's Url module. Its helpers are stubs, but its function -# values must keep this shape: Lib's pack program and Repro.roc's program join +# values must keep this shape: Primer.roc's and Repro.roc's programs join # them into different callable sets. Lib :: { scheme : [Http, Https], diff --git a/test/cli/issue_11710_shared_object_cache/Primer.roc b/test/cli/issue_11710_shared_object_cache/Primer.roc index 439f0b7402a..03e81c7f3cd 100644 --- a/test/cli/issue_11710_shared_object_cache/Primer.roc +++ b/test/cli/issue_11710_shared_object_cache/Primer.roc @@ -1,10 +1,20 @@ -# Imports Lib without calling it. Building this app caches Lib's procedures. +# Calls Lib.parse and Lib.query_pairs on a path its run never takes. Building +# this app caches the Lib procedures its program compiled. app [main!] { pf: platform "../../fx-open/platform/main.roc" } import pf.Stdout import Lib -main! = |_args| { - Stdout.line!("primer") +main! = |args| { + message = + if List.len(args) > 1 { + match Lib.parse(Str.join_with(args, " ")) { + Ok(url) => Str.join_with(Lib.query_pairs(url).map(|(name, _)| name), "&") + Err(_) => "invalid" + } + } else { + "primer" + } + Stdout.line!(message) Ok({}) } From cde23a986c14bae0b6f7492e4f2d95930651a093 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 09:28:26 -0400 Subject: [PATCH 11/97] Name dev-backend code excerpts and arranged images in design vocabulary design.md's dev pack sections named ProcArtifact, RuntimeRef, canonicalize, and assembleCanonical, which the semantic audit bans in design prose. The lifted machine-code ranges are now excerpts (CodeExcerpt), the fixed image layout is arranged (arrange, assembleArranged, ArrangedImage), and the Boxy runtime table numbers are RuntimeNumber. ROC_DEV_ARTIFACT_ROUNDTRIP becomes ROC_DEV_EXCERPT_ROUNDTRIP. The rest of the audit's design.md findings are reworded without changing their meaning. --- build.zig | 2 +- design.md | 84 +-- .../dev/{ProcArtifact.zig => CodeExcerpt.zig} | 694 +++++++++--------- src/backend/dev/HostSplice.zig | 20 +- src/backend/dev/LirCodeGen.zig | 184 ++--- src/backend/dev/NativeProcCompiler.zig | 52 +- src/backend/dev/ObjectFileCompiler.zig | 168 ++--- src/backend/dev/ObjectWriter.zig | 8 +- src/backend/dev/PackFile.zig | 222 +++--- src/backend/dev/Relocation.zig | 12 +- src/backend/dev/RunImage.zig | 6 +- src/backend/dev/StaticStringData.zig | 2 +- src/backend/dev/mod.zig | 12 +- src/backend/llvm/MonoLlvmCodeGen.zig | 2 +- src/base/dev_diagnostics.zig | 4 +- src/cli/main.zig | 152 ++-- src/cli/pack_store.zig | 82 +-- src/cli/test/parallel_cli_runner.zig | 36 +- src/compile/test/ctfe_code_identity_test.zig | 16 +- src/eval/compile_time_finalization.zig | 48 +- src/lir/lir_image.zig | 10 +- src/lir/mod.zig | 2 +- src/lir/runtime_tables.zig | 14 +- 23 files changed, 916 insertions(+), 916 deletions(-) rename src/backend/dev/{ProcArtifact.zig => CodeExcerpt.zig} (84%) diff --git a/build.zig b/build.zig index 2062b719291..f2c25818fbb 100755 --- a/build.zig +++ b/build.zig @@ -1914,7 +1914,7 @@ pub fn build(b: *std.Build) void { \\/// Whether this compiler honors the developer-diagnostic environment \\/// variables (`ROC_LIR_DUMP`, `ROC_SPEC_CENSUS`, `ROC_PACK_TRACE`, \\/// `ROC_PACK_STATS`, `ROC_CTFE_DEMAND_TRACE`, `ROC_DEV_PACK_OBJECTS`, - \\/// `ROC_DEV_PACK_HITS`, `ROC_DEV_ARTIFACT_ROUNDTRIP`, + \\/// `ROC_DEV_PACK_HITS`, `ROC_DEV_EXCERPT_ROUNDTRIP`, \\/// `ROC_TEST_LLVM_OBJECT_COMPILE_COUNT_FILE` and `ROC_BACKGROUND_OBJECTS`). Each executable reads its \\/// own optimize mode: a Debug compiler always does, and any other build \\/// only with `-Ddev-diagnostics`, so a release compiler reads none of diff --git a/design.md b/design.md index 3b88c98dc4a..ab818155dad 100644 --- a/design.md +++ b/design.md @@ -676,8 +676,8 @@ A pack's line entries index the pack's own source-file table, which names each file by its module's content identity in the order the lines first use it. Splicing binds each to the receiving program's file for the same module identity, so DWARF line programs cover spliced procedures exactly as they do -compiled ones. Each procedure artifact also carries its procedure's location -(`Artifact.loc`, `LirStore.procLoc`) in the same table, which names the +compiled ones. Each procedure excerpt also carries its procedure's location +(`Excerpt.loc`, `LirStore.procLoc`) in the same table, which names the procedure's debug entry wherever it lands. A procedure's identity does not depend on where it sits in its file, so an entry could be served across an edit of a module its code was lowered from; its lines would then name a module @@ -751,13 +751,13 @@ object packs alone, never of which dev packs exist. A dev build reads object packs by exact path, never by listing a directory: for the target's preferred level (`speed` natively, `size` on wasm32) and then the other, the pack of each URL-package module in view -under its artifact key, in module identity order, then the optimized pack +under its checked module cache key, in module identity order, then the optimized pack of its program. The first pack to offer a key or a procedure serves it, so which pack serves a procedure two offer, and the order the program links them in, are functions of the optimization state alone. After version resolution selects the final package versions and checking -publishes their constants, caching check, run, and build commands prepare the URL +outputs their constants, caching check, run, and build commands prepare the URL packages' host dev packs under `-dev/pkg/`. This includes `roc check`, so a package checked when downloaded already has its dev packs when its first app build starts. Preparation compiles each module's closed exports with the @@ -767,7 +767,7 @@ packs are rebuilt from the checked modules. A command with errors or `--no-cache` prepares none. New packs are also offered directly to the command's cache providers, so runtime lowering can use them even if compile-time evaluation already loaded -the store. Decoded packs retain stable addresses as new offers arrive; artifacts +the store. Decoded packs retain stable addresses as new offers arrive; excerpts already handed to evaluation remain valid. The wasm32 dev backend cannot splice dev code, so a wasm32 dev build @@ -875,7 +875,7 @@ leave the type's structure alone. ### Optimized Builds Feed Dev Builds -An optimized build of a program, once its executable is written and when it +An optimized build of a program, once its executable binary is written and when it reports no error, files the object packs a later dev build links in place of compiling the code they hold (`writeOptimizedPacks`), under its own level's store: the pack of every URL-package module in view the store lacks, and the @@ -912,23 +912,23 @@ package's pack, and a program with no candidate gets an empty pack. `roc run --opt=dev` links the root's pack too, beside its URL packages' packs, and keeps the objects it loaded for its whole hot reload session. -### Canonical Dev Images +### Deterministic Dev Images -A build's executable is a function of its source, its command line, and its +A build's executable binary is a function of its source, its command line, and its optimization state (the object packs it links); which dev packs the object cache holds, and in which order or on which worker a procedure was compiled, must change nothing in it. The dev backend's object is therefore laid out -canonically (`ObjectFileCompiler.compileWithCodeGen`). The code generated for -the program, compiled now or spliced from dev packs, is lifted into artifacts -(`ProcArtifact.extract`); a linked program keeps only the artifacts its roots +in a fixed order (`ObjectFileCompiler.compileWithCodeGen`). The code generated for +the program, compiled now or spliced from dev packs, is lifted into excerpts +(`CodeExcerpt.extract`); a linked program keeps only the excerpts its roots reach, the procedures its entry wrappers call and the host-visible constants with what they name (`retainReachable`), since a program that took an entry lowers some procedures (the members of an entry's lambda sets) only so the entry could call them; and the remaining set is placed again into the code -generator the object is encoded from (`canonicalize`, `assembleCanonical`): +generator the object is encoded from (`arrange`, `assembleArranged`): - procedures in identity order, then Boxy thunks, then refcount helpers by - name, each message pool run right after the one artifact whose code + name, each message pool run right after the one excerpt whose code addresses it; - every constant code names, and every constant a host-visible constant names, under its content name (`ContentNames`), so a constant this program @@ -936,10 +936,10 @@ generator the object is encoded from (`canonicalize`, `assembleCanonical`): entry are one symbol, in name order; host-visible constants keep their names and only they are global; - a symbol and a debug entry for every procedure (`roc__p{identity}`, with its - artifact's location) and every helper with a frame, whether the program + excerpt's location) and every helper with a frame, whether the program declares it or only spliced code reaches it. -A procedure's artifact is the same whether this program compiled it or a +A procedure's excerpt is the same whether this program compiled it or a pack carried it, so the object is the same however each procedure was provided. @@ -947,14 +947,14 @@ The Boxy runtime reads its sidecar tables by numbers the code passes it: layout indices and the starts of runs of erased argument layouts. A program numbers both in the order its lowering reached them, which depends on which procedures it lowered and which a pack served, so code loads each such number -with a fixed-width instruction sequence and records it as a runtime reference -(`RuntimeRef`) that its artifact carries. Once the image's code is placed, -each number is given its canonical one in the order the placed code first -loads it, and every load is patched to it (`LirCodeGen.renumberRuntimeRefs`, +with a fixed-width instruction sequence and records it as a runtime number +(`RuntimeNumber`) that its excerpt carries. Once the image's code is placed, +each number is given its arranged one in the order the placed code first +loads it, and every load is patched to it (`LirCodeGen.renumberRuntimeNumbers`, `RuntimeTables.Numbering`); the fixed layouts keep their indices. The sidecar linked beside the object holds exactly what that order names, then every layout those and the Boxy tables reach, in the order a walk from them first -meets each (`LirImage.buildCanonicalSidecarBlob`). Code that loads a runtime +meets each (`LirImage.buildArrangedSidecarBlob`). Code that loads a runtime table number is never offered by a pack, since the number is its program's own. No code passes the runtime a procedure's index: it is the program's own numbering too. @@ -2818,7 +2818,7 @@ module is written to the checked module cache again only when this compilation evaluated a root it lacked. A module is cached only when checking and evaluation add no reports beyond those present before checked-cache lookup. Earlier parsing and canonicalization reports are reproduced before that lookup, -so they do not prevent reuse and are not emitted again by the checked artifact. +so they do not prevent reuse and are not emitted again by the checked module data. An entry therefore never holds a new checking or evaluation diagnostic that a later compilation's evaluation could contradict. @@ -4545,10 +4545,10 @@ A module completes with failure only where no checked output can exist for it with failure), and the module where that failure originates always carries an error-severity report. A build without error reports has therefore completed every module successfully, and successful completion always -includes the module's checked artifact. Post-build consumers of checked modules +includes the module's CheckedModule. Post-build consumers of checked modules (documentation, public API extraction) run only for builds without error reports, and they read checked modules through `ModuleState.checkedModule`, -whose artifact is not optional. +whose CheckedModule is not optional. Workers carry source-read and type-check operational errors through the existing result channel as explicit stage/error data. The coordinator preserves the @@ -4850,7 +4850,7 @@ Type-bearing body columns carry the app substitution; unchanged body columns remain borrowed. Unchanged columns borrow the exact platform identified by the key. Only a completed composition without new checking or evaluation diagnostics may be -cached; already-replayed earlier-stage reports do not prevent publication. A hit +cached; already-replayed earlier-stage reports do not prevent caching. A hit consumes its stored outcomes and replays its recorded debug observations without evaluation. The canonicalized module cache is a separate, non-checked boundary; the rules @@ -4868,16 +4868,16 @@ The coordinator captures each module's report count immediately before checked cache lookup, after parsing and canonicalization reports have been appended. Checking (including the deferred import drain) and evaluation only append to that same report list. `BuildEnv` moves those reports into its output sink only -after finalization or when aborting. Solver-store staging and final publication +after finalization or when aborting. Solver-store staging and the final cache write share the condition that its count has not changed. Staging serializes the `ModuleEnv` while its solver store is live, then releases the store before evaluation; -final publication additionally requires completed evaluation and, for a reopened +the final cache write additionally requires completed evaluation and, for a reopened cache entry, newly completed roots. A later report makes a staged part ineligible and it is discarded. Failed staging is an ordinary cache-write failure: the -compilation continues and releases solver types, but cannot publish a checked +compilation continues and releases solver types, but cannot write a checked entry without the staged environment. The empty replacement store is never serialized as a checked result. Pairings retain their separate ownership and -publication rules, and abandoned staged parts are discarded during cleanup. +cache-write rules, and abandoned staged parts are discarded during cleanup. `ModuleEnv` contains `CommonEnv.strings`, a `base.StringLiteral.Store`. That store is part of the checked module cache data. A cache hit materializes it as a @@ -7607,7 +7607,7 @@ renames every type that holds a variable. A definition's own quantified variable (the implicitly open tail of `dec : Str -> Try([A, B], [InvalidJson(Str)])`) is therefore a fresh, unquantified variable inside the contract, which reads at its default. A generated call's dispatch-target record was written against -the originals, so the contract publishes its renaming (`generated_codec_renamings`) +the originals, so the contract outputs its renaming (`generated_codec_renamings`) and the call's evidence-edge substitution names the renamed copies. The substitution then describes the same types as the call's callable; it never names a variable of the defining scheme that the contract's own types do not @@ -11439,7 +11439,7 @@ requirements transitively through helper definitions without turning ordinary same-rank creation sites into schemes or reconstructing ownership from solved type shape. -A boundary whose root does not generalize publishes no scheme, and no use of +A boundary whose root does not generalize outputs no scheme, and no use of its monomorphic binding instantiates one, so `retireNonGeneralizedTypeSchemes` hands each requirement it captured to the innermost enclosing root still prospective, exactly as a use's copy would; every use shares the binding's one @@ -16162,9 +16162,9 @@ The demand of a variable is one of three values, the same scale extension, and a variable the scheme's root type does not mention. Checking computes the demand vector of every template once, when the checked -artifact is published (`TemplateDemandPass`), and stores it beside the +module data is written (`TemplateDemandPass`), and stores it beside the template's `scheme_vars` (`CheckedProcedureTemplateTable.templateDemands`), so a -cached artifact carries it and no later stage recomputes it. Each body is +cached CheckedModule carries it and no later stage recomputes it. Each body is walked once into a base vector and a set of edges: a direct call or a dispatch that checking resolved to one procedure template relates each caller variable that the callee slot's checked substitution mentions to that @@ -16254,7 +16254,7 @@ nothing behind it. A shaped specialization's procedure identity renders its holes as holes, so the object cache keys it by its demand-shaped types with no further change, and the specialization set stays a function of the source tree: the demand -vector comes from the checked artifact and the substitution from the request. +vector comes from the CheckedModule and the substitution from the request. ### Procedure Aliases @@ -20198,7 +20198,7 @@ order exactly as `y` and `z` do. A guard on `base + limit` established once therefore bounds every later `base + offset` whose `offset` a loop head keeps below `limit`, which is the shape of a word-at-a-time compare over two cursors into one buffer. Every use of a sum also relates it to each other sum whose -operands the path's facts bound one by one: from `a <= p + c1` and +operands the path's known orderings bound one by one: from `a <= p + c1` and `b <= q + c2`, `a + b <= p + q + c1 + c2`, and the same with the orderings reversed. A guard on `current + limit` thereby bounds a read at `candidate + len` when the candidate is at or before the current position and @@ -21750,7 +21750,7 @@ a time, callees first, and each component outputs its parameter benefits (its parameter containers, projected containers rooted at its parameters, complete field and tag payload reads of its parameters, and forwarding to beneficial callee positions) before any caller is solved. Procedures sharing statements form one -node of that graph. A benefit once published keeps classifying its positions +node of that graph. A benefit once recorded keeps classifying its positions as consuming operands. A recursive component whose own new benefits make a further refcounted argument a consuming operand retracts its committed plans and solves again; the consuming operands only grow, so this terminates, and a @@ -22289,12 +22289,12 @@ against the borrow typing rules: refinement is bounded by the name count; balance divergence across mode-identical entries is itself a finding—per-iteration accumulation), so certification of every procedure runs to completion -- an intermediate aggregate projection needed to claim a selected field +- an intermediate aggregate field read needed to claim a selected field keeps its carrier local across inner-join frames as well as join summaries. Retaining only the anonymous value preserves a liveness proof but loses the exact ownership path when the enclosing loop is summarized again. Copying reachable frame values therefore also copies the carrier bindings of their - payload sources, including projections defined inside an exited region. + payload sources, including field reads defined inside an exited region. - distinct borrow-lender and holder proofs remain separate at every join; a group-count threshold must never discard provenance and manufacture a borrowed entry with no owner. Valid incoming paths with different owners @@ -23015,9 +23015,9 @@ all argument registers and introduce no new callee-save or frame requirements. ## LLVM Value Representation Aggregate transport preserves storage identity. Records, tuples, and inline tag -payloads use the same memory-view plan: an immutable alias or projection names +payloads use the same memory-view plan: an immutable alias or field read names its ultimate storage owner and a committed byte offset. LLVM does not rebuild -these aliases or projections field by field. The existing per-proc +these aliases or field reads field by field. The existing per-proc value planner uses the shared LIR access inventory and liveness solution to prove these views. Join parameters, explicit mutations, and uninitialized storage remain snapshots. A single static definition may execute repeatedly: @@ -23050,7 +23050,7 @@ view, or its own fixed-size entry-block slot, or SSA, a sequence of LLVM values. Only a layout with a scalar carrier can be SSA: Bool as `i1`, an integer as `iN`, Dec as `i128`, F32 and F64, and a SIMD vector as its LLVM vector type. Strings, lists, boxes, pointers, records, tag unions, and callables stay in memory, because their consumers (RC -helpers, builtins, field projections, host boundaries) take addresses. The +helpers, builtins, field reads, host boundaries) take addresses. The backend does not split aggregates into components: Record Component Bindings and Join-Parameter Scalarization already give LIR one scalar local per component wherever a program reads an aggregate component-wise, and those locals become @@ -24102,7 +24102,7 @@ continuing that program rather than preparing its own. ### Runtime Programs Read Stored Values -A build's executable is a function of its source, its command line, and its +A build's executable binary is a function of its source, its command line, and its optimization state; whether the checked module cache already held a module's compile-time values is none of these. A compilation that evaluates a compile-time root stores the completed value in its module's constant @@ -24127,7 +24127,7 @@ the first evaluation already reported, report nowhere. A literal root is not stored by any compilation, so both kinds of compilation read literal roots' completed values from the stages that evaluated them, the same way. The evaluation's own stages, with their specializations' compile-time -reads, are what the compile-time object pack publishes. A compilation that +reads, are what the compile-time object pack holds. A compilation that evaluated no compile-time root its program demands continues its one specialization, which is already that program. diff --git a/src/backend/dev/ProcArtifact.zig b/src/backend/dev/CodeExcerpt.zig similarity index 84% rename from src/backend/dev/ProcArtifact.zig rename to src/backend/dev/CodeExcerpt.zig index f0dca817214..78e24f1c805 100644 --- a/src/backend/dev/ProcArtifact.zig +++ b/src/backend/dev/CodeExcerpt.zig @@ -1,13 +1,13 @@ -//! Per-region artifacts of dev-backend machine code. +//! Per-region excerpts of dev-backend machine code. //! //! The code generator emits one buffer for a whole program, but it keeps every //! reference from that buffer into itself symbolic until a final patch pass //! and logs each range it emits with its producer (`LirCodeGen.CodeRegion`, -//! `LirCodeGen.CodeRef`). An artifact is one such range lifted out of the +//! `LirCodeGen.CodeRef`). An excerpt is one such range lifted out of the //! buffer: its bytes, set-local references (region index plus delta), stable //! references to not-yet-emitted producers, named relocations, source lines, //! and frame metadata. Procedures and refcount helpers are named by content -//! (`ProcIdentity`, `roc__rc_*`), so an artifact set from one program can be +//! (`ProcIdentity`, `roc__rc_*`), so an excerpt set from one program can be //! placed into another program's buffer with `append`, then resolved after //! all required producers have landed. `assemble` performs both steps. //! Source-file indices are in the set's own file domain: `Set.source_files` @@ -18,7 +18,7 @@ //! versions that program does not have. //! //! `verifyRoundTrip` is the gate for the format: a program assembled from its -//! own artifacts must produce the same bytes, relocations, and unwind records +//! own excerpts must produce the same bytes, relocations, and unwind records //! as compiling it directly. const std = @import("std"); @@ -68,26 +68,26 @@ pub const LineEntry = LirCodeGenMod.LineEntry; /// Region-relative failure sites (`LirCodeGen.FailureSite`). pub const FailureSite = LirCodeGenMod.FailureSite; -/// A reference from an artifact's bytes to a location inside another artifact. +/// A reference from an excerpt's bytes to a location inside another excerpt. pub const Reference = struct { - /// Offset of the CALL/BL or ADR/LEA instruction within the artifact. + /// Offset of the CALL/BL or ADR/LEA instruction within the excerpt. site: u32, form: Form, - /// Index of the target artifact within the set. + /// Index of the target excerpt within the set. target: u32, - /// Offset within the target artifact the reference resolves to. + /// Offset within the target excerpt the reference resolves to. delta: u32, veneer: ?u32 = null, }; -/// A runtime table number the artifact's code loads, with the offset of its -/// patchable load relative to the artifact. -pub const RuntimeReference = struct { +/// A runtime table number the excerpt's code loads, with the offset of its +/// patchable load relative to the excerpt. +pub const RuntimeNumberLoad = struct { site: u32, - ref: RelocationMod.RuntimeRef, + ref: RelocationMod.RuntimeNumber, }; -/// A relocation against a named symbol, with its offset relative to the artifact. +/// A relocation against a named symbol, with its offset relative to the excerpt. pub const NamedRelocation = struct { offset: u32, name: []const u8, @@ -100,8 +100,8 @@ pub const NamedRelocation = struct { }, }; -/// Frame metadata of a procedure-shaped artifact, re-recorded as an unwind -/// entry wherever the artifact lands. +/// Frame metadata of a procedure-shaped excerpt, re-recorded as an unwind +/// entry wherever the excerpt lands. pub const Frame = struct { prologue_size: u32, stack_alloc: u32, @@ -111,7 +111,7 @@ pub const Frame = struct { uses_frame_pointer: bool, }; -/// What an artifact holds. Procedures, helpers, and thunks are named by +/// What an excerpt holds. Procedures, helpers, and thunks are named by /// content; the rest are program-local ranges that only ever travel with /// their own program. pub const Kind = union(enum) { @@ -121,13 +121,13 @@ pub const Kind = union(enum) { boxy_thunk: lir.ProcIdentity, entrypoint, message_pool_run, - /// Never lifted as a standalone artifact: islands belong to placement. + /// Never lifted as a standalone excerpt: islands belong to placement. /// Reserved in-body island bytes travel with their owning procedure and /// are repatched there. The tag preserves pack numbering. branch_island, }; -/// A readonly datum the artifact's relocations name and its program +/// A readonly datum the excerpt's relocations name and its program /// defined: carried so a program that lacks it can define it. pub const DataItem = struct { name: []const u8, @@ -159,10 +159,10 @@ pub const DataRelocation = struct { pub const content_data_prefix = lir.Program.content_data_symbol_prefix; /// One lifted region of machine code. -pub const Artifact = struct { +pub const Excerpt = struct { kind: Kind, code: []const u8, - /// Offset within `code` that references to this artifact resolve to. + /// Offset within `code` that references to this excerpt resolve to. entry: u32, frame: ?Frame, refs: []const Reference, @@ -171,8 +171,8 @@ pub const Artifact = struct { failure_sites: []const FailureSite = &.{}, relocations: []const NamedRelocation, /// The Boxy runtime table numbers the code loads, in the producing - /// program's numbering until a canonical image renumbers them. - runtime_refs: []const RuntimeReference = &.{}, + /// program's numbering until a arranged image renumbers them. + runtime_numbers: []const RuntimeNumberLoad = &.{}, data: []const DataItem, /// The source location a procedure's debug entry names: its procedure's /// location (`LirStore.procLoc`) in the set's source-file domain, the @@ -180,12 +180,12 @@ pub const Artifact = struct { loc: base.SourceLoc = .none, }; -/// An ordered set of artifacts covering one code buffer. Order is emission +/// An ordered set of excerpts covering one code buffer. Order is emission /// order, which `assemble` reproduces. pub const Set = struct { arena: collections.SingleThreadArena, - artifacts: []const Artifact, - /// The module content identity of each source-file index the artifacts' + excerpts: []const Excerpt, + /// The module content identity of each source-file index the excerpts' /// lines use (`LirStore.sourceFileModuleIdentity`). source_files: []const [32]u8 = &.{}, @@ -193,10 +193,10 @@ pub const Set = struct { self.arena.deinit(); } - /// The index of the artifact defining `target`, a symbolic reference's + /// The index of the excerpt defining `target`, a symbolic reference's /// target, or null when this set does not define it. pub fn definitionOf(self: *const Set, target: @FieldType(SymbolicReference, "target")) ?u32 { - for (self.artifacts, 0..) |candidate, candidate_index| { + for (self.excerpts, 0..) |candidate, candidate_index| { const matches = switch (target) { .proc => |identity| candidate.kind == .proc and std.meta.eql(identity, candidate.kind.proc), .boxy_thunk => |identity| candidate.kind == .boxy_thunk and std.meta.eql(identity, candidate.kind.boxy_thunk), @@ -208,7 +208,7 @@ pub const Set = struct { } }; -/// A fragment owns its requirement list in the same arena as its artifacts. +/// A fragment owns its requirement list in the same arena as its excerpts. pub const Fragment = struct { set: Set, required_helpers: []const u64, @@ -341,7 +341,7 @@ pub fn combine(allocator: Allocator, sets: []const *const Set) Allocator.Error!S var arena = collections.SingleThreadArena.init(allocator); errdefer arena.deinit(); const a = arena.allocator(); - var artifacts = std.ArrayList(Artifact).empty; + var excerpts = std.ArrayList(Excerpt).empty; var source_files = std.ArrayList([32]u8).empty; for (sets) |set| { const file_map = try a.alloc(u32, set.source_files.len); @@ -353,11 +353,11 @@ pub fn combine(allocator: Allocator, sets: []const *const Set) Allocator.Error!S break :index @intCast(source_files.items.len - 1); }; } - try copyArtifacts(a, &artifacts, set, file_map); + try copyExcerpts(a, &excerpts, set, file_map); } - const owned = try artifacts.toOwnedSlice(a); + const owned = try excerpts.toOwnedSlice(a); const owned_source_files = try source_files.toOwnedSlice(a); - return .{ .arena = arena, .artifacts = owned, .source_files = owned_source_files }; + return .{ .arena = arena, .excerpts = owned, .source_files = owned_source_files }; } /// Deep-copy sets compiled from programs of one Solved program, which name a @@ -368,9 +368,9 @@ pub fn combine(allocator: Allocator, sets: []const *const Set) Allocator.Error!S pub fn combineDistinct(allocator: Allocator, sets: []const *const Set) Allocator.Error!Set { const source_files: []const [32]u8 = if (sets.len == 0) &.{} else sets[0].source_files; if (@import("builtin").mode == .debug) for (sets) |set| { - if (set.source_files.len != source_files.len) base.invariant("{s}", .{"combined artifact sets index different source-file tables"}); + if (set.source_files.len != source_files.len) base.invariant("{s}", .{"combined excerpt sets index different source-file tables"}); for (set.source_files, source_files) |left, right| { - if (!std.mem.eql(u8, &left, &right)) base.invariant("{s}", .{"combined artifact sets index different source-file tables"}); + if (!std.mem.eql(u8, &left, &right)) base.invariant("{s}", .{"combined excerpt sets index different source-file tables"}); } }; var arena = collections.SingleThreadArena.init(allocator); @@ -406,7 +406,7 @@ pub fn combineDistinct(allocator: Allocator, sets: []const *const Set) Allocator }; var placed = std.HashMap(Named, u32, NamedContext, std.hash_map.default_max_load_percentage).init(allocator); defer placed.deinit(); - // Where each set's artifacts land, and which of them are copied. + // Where each set's excerpts land, and which of them are copied. const maps = try allocator.alloc([]u32, sets.len); var mapped: usize = 0; defer { @@ -421,12 +421,12 @@ pub fn combineDistinct(allocator: Allocator, sets: []const *const Set) Allocator } var next: u32 = 0; for (sets, maps, kept) |set, *map, *flags| { - map.* = try allocator.alloc(u32, set.artifacts.len); + map.* = try allocator.alloc(u32, set.excerpts.len); mapped += 1; - flags.* = try allocator.alloc(bool, set.artifacts.len); + flags.* = try allocator.alloc(bool, set.excerpts.len); kept_count += 1; - for (set.artifacts, map.*, flags.*) |artifact, *index, *keep| { - const named: ?Named = switch (artifact.kind) { + for (set.excerpts, map.*, flags.*) |excerpt, *index, *keep| { + const named: ?Named = switch (excerpt.kind) { .proc => |identity| .{ .proc = identity }, .boxy_thunk => |identity| .{ .thunk = identity }, .rc_helper => |name| .{ .helper = name }, @@ -446,7 +446,7 @@ pub fn combineDistinct(allocator: Allocator, sets: []const *const Set) Allocator next += 1; } } - var artifacts = try std.ArrayList(Artifact).initCapacity(a, next); + var excerpts = try std.ArrayList(Excerpt).initCapacity(a, next); for (sets, maps, kept) |set, map, flags| { // A constant's program-local name belongs to the program that made // the set, and another set's program can give the same name to a @@ -454,30 +454,30 @@ pub fn combineDistinct(allocator: Allocator, sets: []const *const Set) Allocator // before the sets meet. var names = try ContentNames.init(allocator, set); defer names.deinit(); - for (set.artifacts, flags) |source, keep| { + for (set.excerpts, flags) |source, keep| { if (!keep) continue; - const first: u32 = @intCast(artifacts.items.len); - try copyArtifacts(a, &artifacts, &.{ .arena = undefined, .artifacts = &.{source} }, null); - const refs: []Reference = @constCast(artifacts.items[first].refs); + const first: u32 = @intCast(excerpts.items.len); + try copyExcerpts(a, &excerpts, &.{ .arena = undefined, .excerpts = &.{source} }, null); + const refs: []Reference = @constCast(excerpts.items[first].refs); for (refs, source.refs) |*ref, original| ref.target = map[original.target]; - try nameDataByContent(a, &artifacts.items[first], &names); + try nameDataByContent(a, &excerpts.items[first], &names); } } // Both slices come from the arena before it is copied into the set. - const owned_artifacts = try artifacts.toOwnedSlice(a); + const owned_excerpts = try excerpts.toOwnedSlice(a); const owned_source_files = try a.dupe([32]u8, source_files); - return .{ .arena = arena, .artifacts = owned_artifacts, .source_files = owned_source_files }; + return .{ .arena = arena, .excerpts = owned_excerpts, .source_files = owned_source_files }; } -/// Replace every program-local constant name `artifact` carries or refers +/// Replace every program-local constant name `excerpt` carries or refers /// to with its content name (`ContentNames`), as a pack names it. -fn nameDataByContent(a: Allocator, artifact: *Artifact, names: *const ContentNames) Allocator.Error!void { - for (@constCast(artifact.relocations)) |*relocation| { +fn nameDataByContent(a: Allocator, excerpt: *Excerpt, names: *const ContentNames) Allocator.Error!void { + for (@constCast(excerpt.relocations)) |*relocation| { if (!names.renames(relocation.name)) continue; relocation.name = try a.dupe(u8, names.of(relocation.name)); relocation.scope = .shared; } - for (@constCast(artifact.data)) |*item| { + for (@constCast(excerpt.data)) |*item| { if (names.renames(item.name)) { item.name = try a.dupe(u8, names.of(item.name)); item.program_local_name = false; @@ -496,47 +496,47 @@ fn copySet(allocator: Allocator, set: *const Set) Allocator.Error!Set { var arena = collections.SingleThreadArena.init(allocator); errdefer arena.deinit(); const a = arena.allocator(); - var artifacts = std.ArrayList(Artifact).empty; - try copyArtifacts(a, &artifacts, set, null); - const owned = try artifacts.toOwnedSlice(a); - return .{ .arena = arena, .artifacts = owned }; + var excerpts = std.ArrayList(Excerpt).empty; + try copyExcerpts(a, &excerpts, set, null); + const owned = try excerpts.toOwnedSlice(a); + return .{ .arena = arena, .excerpts = owned }; } -/// Append deep copies of `set`'s artifacts, rebasing set-local references +/// Append deep copies of `set`'s excerpts, rebasing set-local references /// and, given a `file_map`, carrying line file indices through it. -fn copyArtifacts(a: Allocator, artifacts: *std.ArrayList(Artifact), set: *const Set, file_map: ?[]const u32) Allocator.Error!void { - const first: u32 = @intCast(artifacts.items.len); - for (set.artifacts) |source| { - var artifact = source; - artifact.kind = switch (source.kind) { +fn copyExcerpts(a: Allocator, excerpts: *std.ArrayList(Excerpt), set: *const Set, file_map: ?[]const u32) Allocator.Error!void { + const first: u32 = @intCast(excerpts.items.len); + for (set.excerpts) |source| { + var excerpt = source; + excerpt.kind = switch (source.kind) { .rc_helper => |name| .{ .rc_helper = try a.dupe(u8, name) }, .proc, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => source.kind, }; - artifact.code = try a.dupe(u8, source.code); + excerpt.code = try a.dupe(u8, source.code); const refs = try a.dupe(Reference, source.refs); for (refs) |*ref| ref.target += first; - artifact.refs = refs; + excerpt.refs = refs; const symbolic_refs = try a.dupe(SymbolicReference, source.symbolic_refs); for (symbolic_refs) |*ref| switch (ref.target) { .rc_helper => |name| ref.target = .{ .rc_helper = try a.dupe(u8, name) }, .proc, .boxy_thunk => {}, }; - artifact.symbolic_refs = symbolic_refs; + excerpt.symbolic_refs = symbolic_refs; const lines = try a.dupe(LineEntry, source.lines); if (file_map) |map| for (lines) |*line| { line.loc = rebindLoc(line.loc, map); }; - artifact.lines = lines; - if (file_map) |map| artifact.loc = rebindLoc(source.loc, map); - artifact.failure_sites = try a.dupe(FailureSite, source.failure_sites); + excerpt.lines = lines; + if (file_map) |map| excerpt.loc = rebindLoc(source.loc, map); + excerpt.failure_sites = try a.dupe(FailureSite, source.failure_sites); const relocations = try a.dupe(NamedRelocation, source.relocations); for (relocations) |*relocation| relocation.name = try a.dupe(u8, relocation.name); - artifact.relocations = relocations; - artifact.runtime_refs = try a.dupe(RuntimeReference, source.runtime_refs); + excerpt.relocations = relocations; + excerpt.runtime_numbers = try a.dupe(RuntimeNumberLoad, source.runtime_numbers); const data = try a.alloc(DataItem, source.data.len); for (source.data, data) |item, *owned| owned.* = try cloneData(a, item); - artifact.data = data; - try artifacts.append(a, artifact); + excerpt.data = data; + try excerpts.append(a, excerpt); } } @@ -549,7 +549,7 @@ pub fn rebindLoc(loc: base.SourceLoc, file_map: []const u32) base.SourceLoc { return rebound; } -/// Why a code buffer could not be lifted into artifacts. +/// Why a code buffer could not be lifted into excerpts. pub const ExtractError = Allocator.Error || error{ /// Two producer regions overlap. Embedded branch islands are not /// independent producers and are retained with their owning region. @@ -558,11 +558,11 @@ pub const ExtractError = Allocator.Error || error{ UncoveredCode, /// A reference resolves to an offset inside no region. DanglingReference, - /// A relocation kind an artifact cannot carry. + /// A relocation kind an excerpt cannot carry. UnsupportedRelocation, }; -/// Content names for the internal constants an artifact set carries. A +/// Content names for the internal constants an excerpt set carries. A /// constant is a node in a graph of readonly data: its bytes plus pointer /// relocations to other constants, literal backings, procedures, or refcount /// helpers. Two programs that hold the same constant must name it the same @@ -591,11 +591,11 @@ pub const ContentNames = struct { pub fn init(allocator: Allocator, set: *const Set) Allocator.Error!ContentNames { var self = ContentNames{ .allocator = allocator, .arena = collections.SingleThreadArena.init(allocator) }; errdefer self.deinit(); - for (set.artifacts) |artifact| { - for (artifact.data) |item| try self.items.put(allocator, item.name, item); + for (set.excerpts) |excerpt| { + for (excerpt.data) |item| try self.items.put(allocator, item.name, item); } - for (set.artifacts) |artifact| { - for (artifact.data) |item| try self.nameItem(item); + for (set.excerpts) |excerpt| { + for (excerpt.data) |item| try self.nameItem(item); } return self; } @@ -797,7 +797,7 @@ pub const ContentNames = struct { }; /// Immutable data catalog prepared once by the coordinator. Worker lookups -/// borrow it read-only; retained artifacts copy only their reachable closure. +/// borrow it read-only; retained excerpts copy only their reachable closure. pub const PreparedData = struct { arena: collections.SingleThreadArena, items: std.StringHashMapUnmanaged(DataItem), @@ -861,7 +861,7 @@ pub const PreparedData = struct { } }; -/// Lift every region of a finished code generator's buffer into an artifact set. +/// Lift every region of a finished code generator's buffer into an excerpt set. pub fn extract( comptime CG: type, allocator: Allocator, @@ -934,7 +934,7 @@ pub fn extractPrepared( if (covered != code.len) return error.UncoveredCode; const unwind = codegen.getUnwindFunctions(); - const relocations = try codegen.artifactRelocations(allocator); + const relocations = try codegen.excerptRelocations(allocator); defer allocator.free(relocations); // Each symbol's name, copied into the set once however many relocations // name it, by symbol id. @@ -944,18 +944,18 @@ pub fn extractPrepared( const refs = codegen.codeRefs(); for (relocations) |relocation| switch (relocation) { .local_data => return error.UnsupportedRelocation, - .retired, .linked_function, .linked_data, .runtime_ref => {}, + .retired, .linked_function, .linked_data, .runtime_number => {}, }; - // Standalone branch islands belong to this placement, not the artifact + // Standalone branch islands belong to this placement, not the excerpt // set. Embedded reservations stay inside their owning bytes but every // reference still records its logical target for final placement. - const artifact_of_region = try allocator.alloc(?u32, regions.len); - defer allocator.free(artifact_of_region); - var artifact_count: u32 = 0; + const excerpt_of_region = try allocator.alloc(?u32, regions.len); + defer allocator.free(excerpt_of_region); + var excerpt_count: u32 = 0; for (regions, 0..) |region, index| { - artifact_of_region[index] = if (regionHasArtifact(region.kind)) artifact_count else null; - if (regionHasArtifact(region.kind)) artifact_count += 1; + excerpt_of_region[index] = if (regionHasExcerpt(region.kind)) excerpt_count else null; + if (regionHasExcerpt(region.kind)) excerpt_count += 1; } // Each reference and relocation belongs to the one region its offset @@ -974,16 +974,16 @@ pub fn extractPrepared( var message_runs = try MessageRuns.init(CG, allocator, regions); defer message_runs.deinit(allocator); - const artifacts = try arena_allocator.alloc(Artifact, artifact_count); + const excerpts = try arena_allocator.alloc(Excerpt, excerpt_count); for (regions, 0..) |region, region_index| { - const index = artifact_of_region[region_index] orelse continue; + const index = excerpt_of_region[region_index] orelse continue; const kind: Kind = switch (region.kind) { .proc => |proc_id| .{ .proc = proc_specs[@backingInt(proc_id)].identity }, .rc_helper => |key| .{ .rc_helper = try LirCodeGenMod.compiledRcHelperSymbolName(arena_allocator, layout_store, key) }, .boxy_thunk => |proc_id| .{ .boxy_thunk = proc_specs[@backingInt(proc_id)].identity }, .entrypoint => .entrypoint, .message_pool_run => .message_pool_run, - .branch_island, .hosted_stub => unreachable, // regionHasArtifact is false for these, so artifact_of_region skipped them above + .branch_island, .hosted_stub => unreachable, // regionHasExcerpt is false for these, so excerpt_of_region skipped them above .spliced_proc => |identity| .{ .proc = identity }, .spliced_boxy_thunk => |identity| .{ .boxy_thunk = identity }, .spliced_helper => .{ .rc_helper = try arena_allocator.dupe(u8, codegen.splicedHelperName(region.start + region.entry) orelse return error.DanglingReference) }, @@ -1003,7 +1003,7 @@ pub fn extractPrepared( // Code whose identity names it reaches a procedure whose identity // names its template by site (`LIR.LirProcSpec.code_key`) by that - // identity, never by a copy of its code: a program the artifact is + // identity, never by a copy of its code: a program the excerpt is // spliced into calls its own code of the procedure. const region_names_code = switch (region.kind) { .proc => |proc_id| proc_specs[@backingInt(proc_id)].code_key == null, @@ -1044,7 +1044,7 @@ pub fn extractPrepared( .offset => |offset| offset, }; const target_region = regionContaining(CG, regions, target_offset) orelse return error.DanglingReference; - const target_index = artifact_of_region[target_region] orelse return error.DanglingReference; + const target_index = excerpt_of_region[target_region] orelse return error.DanglingReference; if (region_names_code) site: { const target_proc = switch (regions[target_region].kind) { .proc => |proc_id| proc_id, @@ -1078,12 +1078,12 @@ pub fn extractPrepared( } var region_relocations = std.ArrayList(NamedRelocation).empty; - var region_runtime_refs = std.ArrayList(RuntimeReference).empty; + var region_runtime_numbers = std.ArrayList(RuntimeNumberLoad).empty; for (relocation_groups.of(region_index)) |relocation_index| { const relocation = relocations[relocation_index]; const offset = relocation.getOffset(); - if (relocation == .runtime_ref) { - try region_runtime_refs.append(arena_allocator, .{ .site = @intCast(offset - region.start), .ref = relocation.runtime_ref.ref }); + if (relocation == .runtime_number) { + try region_runtime_numbers.append(arena_allocator, .{ .site = @intCast(offset - region.start), .ref = relocation.runtime_number.ref }); continue; } try region_relocations.append(arena_allocator, switch (relocation) { @@ -1099,12 +1099,12 @@ pub fn extractPrepared( .scope = codegen.symbolScope(data.symbol), .kind = .{ .data = data.kind }, }, - .retired, .local_data, .runtime_ref => unreachable, // the switch above skipped or rejected these kinds, and the loop took runtime references + .retired, .local_data, .runtime_number => unreachable, // the switch above skipped or rejected these kinds, and the loop took runtime references }); } // Every data item the region names, then every item those name, so - // an artifact carries the whole constant graph it points into. + // an excerpt carries the whole constant graph it points into. const region_data = try captureData(allocator, arena_allocator, region_relocations.items, prepared); // Lines and failure sites are in code offset order, so each region's @@ -1122,13 +1122,13 @@ pub fn extractPrepared( if (site.offset > region.end) break; try failure_sites.append(arena_allocator, .{ .offset = @intCast(site.offset - region.start), .meaning = site.meaning }); } - const artifact_code = try arena_allocator.dupe(u8, code[region.start..region.end]); - codegen.normalizeArtifactCode(region.start, artifact_code); - for (region_refs.items) |ref| CG.normalizeArtifactRefSite(artifact_code, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); - for (symbolic_refs.items) |ref| CG.normalizeArtifactRefSite(artifact_code, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); - artifacts[index] = .{ + const excerpt_code = try arena_allocator.dupe(u8, code[region.start..region.end]); + codegen.normalizeExcerptCode(region.start, excerpt_code); + for (region_refs.items) |ref| CG.normalizeExcerptRefSite(excerpt_code, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); + for (symbolic_refs.items) |ref| CG.normalizeExcerptRefSite(excerpt_code, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); + excerpts[index] = .{ .kind = kind, - .code = artifact_code, + .code = excerpt_code, .entry = @intCast(region.entry), .frame = frame, .refs = try region_refs.toOwnedSlice(arena_allocator), @@ -1136,7 +1136,7 @@ pub fn extractPrepared( .lines = try lines.toOwnedSlice(arena_allocator), .failure_sites = try failure_sites.toOwnedSlice(arena_allocator), .relocations = try region_relocations.toOwnedSlice(arena_allocator), - .runtime_refs = try region_runtime_refs.toOwnedSlice(arena_allocator), + .runtime_numbers = try region_runtime_numbers.toOwnedSlice(arena_allocator), .data = region_data, .loc = switch (region.kind) { // A procedure placed from another set carries the location @@ -1148,7 +1148,7 @@ pub fn extractPrepared( }; } - return .{ .arena = arena, .artifacts = artifacts }; + return .{ .arena = arena, .excerpts = excerpts }; } fn refSite(ref: anytype) ?usize { @@ -1158,7 +1158,7 @@ fn refSite(ref: anytype) ?usize { fn relocationOffset(relocation: anytype) ?usize { return switch (relocation) { .retired => null, - .linked_function, .linked_data, .runtime_ref => @intCast(relocation.getOffset()), + .linked_function, .linked_data, .runtime_number => @intCast(relocation.getOffset()), .local_data => unreachable, // extraction rejected these kinds before grouping relocations }; } @@ -1368,10 +1368,10 @@ fn appendDataTargets(allocator: Allocator, pending: *std.ArrayList([]const u8), } } -/// Whether a region lifts into an artifact. Branch islands belong to one +/// Whether a region lifts into an excerpt. Branch islands belong to one /// placement of the code, and a hosted stub exists only in the evaluator's /// image. -fn regionHasArtifact(kind: anytype) bool { +fn regionHasExcerpt(kind: anytype) bool { return switch (kind) { .branch_island, .hosted_stub => false, .proc, .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .spliced_proc, .spliced_boxy_thunk, .spliced_helper => true, @@ -1409,13 +1409,13 @@ pub const ImageRoots = struct { procs: []const lir.ProcIdentity, data: []const []const u8, /// Further names code and constants relocate to that name a helper - /// artifact, by the helper's artifact name. + /// excerpt, by the helper's excerpt name. helper_aliases: *const std.StringHashMap([]const u8), /// Every Boxy thunk is reached, from the program's descriptor sidecar. all_thunks: bool, }; -/// The artifacts of `set` that the image's roots reach: through +/// The excerpts of `set` that the image's roots reach: through /// references, through relocations to a procedure's or helper's symbol, and /// through the constants code names, whose relocations reach further code. /// Code a program declares but nothing reaches (a lambda-set member lowered @@ -1432,7 +1432,7 @@ pub fn retainReachable(allocator: Allocator, set: *const Set, catalog: *const Pr defer by_proc.deinit(); var by_thunk = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator); defer by_thunk.deinit(); - for (set.artifacts, 0..) |artifact, index| switch (artifact.kind) { + for (set.excerpts, 0..) |excerpt, index| switch (excerpt.kind) { .proc => |identity| { try by_proc.put(identity, @intCast(index)); try by_name.put(try identity.symbolName(names_allocator), @intCast(index)); @@ -1446,42 +1446,42 @@ pub fn retainReachable(allocator: Allocator, set: *const Set, catalog: *const Pr if (by_name.get(alias.value_ptr.*)) |index| try by_name.put(alias.key_ptr.*, index); } - const reached = try allocator.alloc(bool, set.artifacts.len); + const reached = try allocator.alloc(bool, set.excerpts.len); defer allocator.free(reached); @memset(reached, false); - var artifact_work = std.ArrayList(u32).empty; - defer artifact_work.deinit(allocator); + var excerpt_work = std.ArrayList(u32).empty; + defer excerpt_work.deinit(allocator); var data_work = std.ArrayList([]const u8).empty; defer data_work.deinit(allocator); var data_seen = std.StringHashMap(void).init(allocator); defer data_seen.deinit(); - for (roots.procs) |identity| if (by_proc.get(identity)) |index| try artifact_work.append(allocator, index); + for (roots.procs) |identity| if (by_proc.get(identity)) |index| try excerpt_work.append(allocator, index); for (roots.data) |name| try data_work.append(allocator, name); if (roots.all_thunks) { var thunks = by_thunk.valueIterator(); - while (thunks.next()) |index| try artifact_work.append(allocator, index.*); + while (thunks.next()) |index| try excerpt_work.append(allocator, index.*); } - while (artifact_work.items.len != 0 or data_work.items.len != 0) { - while (artifact_work.pop()) |index| { + while (excerpt_work.items.len != 0 or data_work.items.len != 0) { + while (excerpt_work.pop()) |index| { if (reached[index]) continue; reached[index] = true; - const artifact = set.artifacts[index]; - for (artifact.refs) |ref| try artifact_work.append(allocator, ref.target); - for (artifact.symbolic_refs) |ref| { + const excerpt = set.excerpts[index]; + for (excerpt.refs) |ref| try excerpt_work.append(allocator, ref.target); + for (excerpt.symbolic_refs) |ref| { const target = switch (ref.target) { .proc => |identity| by_proc.get(identity), .boxy_thunk => |identity| by_thunk.get(identity), .rc_helper => |name| by_name.get(name), }; - if (target) |target_index| try artifact_work.append(allocator, target_index); + if (target) |target_index| try excerpt_work.append(allocator, target_index); } - for (artifact.relocations) |relocation| try data_work.append(allocator, relocation.name); - for (artifact.data) |item| try data_work.append(allocator, item.name); + for (excerpt.relocations) |relocation| try data_work.append(allocator, relocation.name); + for (excerpt.data) |item| try data_work.append(allocator, item.name); } while (data_work.pop()) |name| { if (by_name.get(name)) |index| { - try artifact_work.append(allocator, index); + try excerpt_work.append(allocator, index); continue; } if ((try data_seen.getOrPut(name)).found_existing) continue; @@ -1490,7 +1490,7 @@ pub fn retainReachable(allocator: Allocator, set: *const Set, catalog: *const Pr } } - const new_index = try allocator.alloc(u32, set.artifacts.len); + const new_index = try allocator.alloc(u32, set.excerpts.len); defer allocator.free(new_index); var kept: u32 = 0; for (reached, new_index) |is_reached, *index| { @@ -1500,76 +1500,76 @@ pub fn retainReachable(allocator: Allocator, set: *const Set, catalog: *const Pr var arena = collections.SingleThreadArena.init(allocator); errdefer arena.deinit(); const a = arena.allocator(); - const artifacts = try a.alloc(Artifact, kept); + const excerpts = try a.alloc(Excerpt, kept); var written: usize = 0; - for (set.artifacts, reached) |source, is_reached| { + for (set.excerpts, reached) |source, is_reached| { if (!is_reached) continue; - var artifact = source; - artifact.kind = switch (source.kind) { + var excerpt = source; + excerpt.kind = switch (source.kind) { .rc_helper => |name| .{ .rc_helper = try a.dupe(u8, name) }, .proc, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => source.kind, }; - artifact.code = try a.dupe(u8, source.code); + excerpt.code = try a.dupe(u8, source.code); const refs = try a.dupe(Reference, source.refs); for (refs) |*ref| ref.target = new_index[ref.target]; - artifact.refs = refs; + excerpt.refs = refs; const symbolic_refs = try a.dupe(SymbolicReference, source.symbolic_refs); for (symbolic_refs) |*ref| switch (ref.target) { .rc_helper => |name| ref.target = .{ .rc_helper = try a.dupe(u8, name) }, .proc, .boxy_thunk => {}, }; - artifact.symbolic_refs = symbolic_refs; - artifact.lines = try a.dupe(LineEntry, source.lines); - artifact.failure_sites = try a.dupe(FailureSite, source.failure_sites); + excerpt.symbolic_refs = symbolic_refs; + excerpt.lines = try a.dupe(LineEntry, source.lines); + excerpt.failure_sites = try a.dupe(FailureSite, source.failure_sites); const relocations = try a.dupe(NamedRelocation, source.relocations); for (relocations) |*relocation| relocation.name = try a.dupe(u8, relocation.name); - artifact.relocations = relocations; - artifact.runtime_refs = try a.dupe(RuntimeReference, source.runtime_refs); + excerpt.relocations = relocations; + excerpt.runtime_numbers = try a.dupe(RuntimeNumberLoad, source.runtime_numbers); const data = try a.alloc(DataItem, source.data.len); for (source.data, data) |item, *owned| owned.* = try cloneData(a, item); - artifact.data = data; - artifacts[written] = artifact; + excerpt.data = data; + excerpts[written] = excerpt; written += 1; } // Allocated before the arena is copied into the result, which must own // every node. const source_files = try a.dupe([32]u8, set.source_files); - return .{ .arena = arena, .artifacts = artifacts, .source_files = source_files }; + return .{ .arena = arena, .excerpts = excerpts, .source_files = source_files }; } -/// A program's machine code in its one layout: every artifact of the code +/// A program's machine code in its one layout: every excerpt of the code /// its procedures, helpers, and thunks are, compiled for this program or /// spliced from an object-cache entry, in an order and under names that -/// depend only on what each artifact is (design.md "Canonical Dev Images"). +/// depend only on what each excerpt is (design.md "Deterministic Dev Images"). /// `data` holds every constant the code names, and every constant a /// host-visible constant names, under its content name, sorted by name. -pub const CanonicalImage = struct { +pub const ArrangedImage = struct { set: Set, - data: []const CanonicalDatum, + data: []const ArrangedDatum, - pub fn deinit(self: *CanonicalImage) void { + pub fn deinit(self: *ArrangedImage) void { self.set.deinit(); } }; -/// One constant of a canonical image, and whether the host sees it under its +/// One constant of a arranged image, and whether the host sees it under its /// own name. -pub const CanonicalDatum = struct { +pub const ArrangedDatum = struct { item: DataItem, host_visible: bool, }; -/// Lay `set` out canonically (`CanonicalImage`). `host_visible` are the +/// Lay `set` out canonically (`ArrangedImage`). `host_visible` are the /// constants the host binds by name, with every constant they name; their /// names are kept, and every other constant is renamed by content /// (`ContentNames`), so a constant this program named for itself and the /// same constant carried by a spliced entry are one symbol. /// -/// Artifacts are ordered by what they are: procedures by identity, then Boxy +/// Excerpts are ordered by what they are: procedures by identity, then Boxy /// thunks by identity, then refcount helpers by name. A message pool run -/// belongs to the one artifact whose code addresses it and follows that -/// artifact, in the order the artifact first addresses its runs. -pub fn canonicalize(allocator: Allocator, set: *const Set, host_visible: []const DataItem) Allocator.Error!CanonicalImage { +/// belongs to the one excerpt whose code addresses it and follows that +/// excerpt, in the order the excerpt first addresses its runs. +pub fn arrange(allocator: Allocator, set: *const Set, host_visible: []const DataItem) Allocator.Error!ArrangedImage { var all_items = std.ArrayList(DataItem).empty; defer all_items.deinit(allocator); var collected = std.StringHashMap(void).init(allocator); @@ -1583,55 +1583,55 @@ pub fn canonicalize(allocator: Allocator, set: *const Set, host_visible: []const if ((try collected.getOrPut(item.name)).found_existing) continue; try all_items.append(allocator, item); } - for (set.artifacts) |artifact| for (artifact.data) |item| { + for (set.excerpts) |excerpt| for (excerpt.data) |item| { if ((try collected.getOrPut(item.name)).found_existing) continue; try all_items.append(allocator, item); }; var names = try ContentNames.initItems(allocator, all_items.items); defer names.deinit(); - // The owner of each message pool run: the first artifact whose code + // The owner of each message pool run: the first excerpt whose code // addresses it. - const owner_of = try allocator.alloc(?u32, set.artifacts.len); + const owner_of = try allocator.alloc(?u32, set.excerpts.len); defer allocator.free(owner_of); @memset(owner_of, null); - for (set.artifacts, 0..) |artifact, index| for (artifact.refs) |ref| { - if (set.artifacts[ref.target].kind != .message_pool_run) continue; + for (set.excerpts, 0..) |excerpt, index| for (excerpt.refs) |ref| { + if (set.excerpts[ref.target].kind != .message_pool_run) continue; if (owner_of[ref.target] == null) owner_of[ref.target] = @intCast(index); }; var heads = std.ArrayList(u32).empty; defer heads.deinit(allocator); var unowned_runs = std.ArrayList(u32).empty; defer unowned_runs.deinit(allocator); - for (set.artifacts, 0..) |artifact, index| switch (artifact.kind) { + for (set.excerpts, 0..) |excerpt, index| switch (excerpt.kind) { .message_pool_run => if (owner_of[index] == null) try unowned_runs.append(allocator, @intCast(index)), .proc, .boxy_thunk, .rc_helper, .entrypoint, .branch_island => try heads.append(allocator, @intCast(index)), }; std.mem.sort(u32, heads.items, set, canonicalBefore); std.mem.sort(u32, unowned_runs.items, set, struct { fn lessThan(owner: *const Set, a: u32, b: u32) bool { - return std.mem.lessThan(u8, owner.artifacts[a].code, owner.artifacts[b].code); + return std.mem.lessThan(u8, owner.excerpts[a].code, owner.excerpts[b].code); } }.lessThan); // `order[new] = old`. var order = std.ArrayList(u32).empty; defer order.deinit(allocator); - const placed = try allocator.alloc(bool, set.artifacts.len); + const placed = try allocator.alloc(bool, set.excerpts.len); defer allocator.free(placed); @memset(placed, false); for (heads.items) |head| { try order.append(allocator, head); - for (set.artifacts[head].refs) |ref| { - if (set.artifacts[ref.target].kind != .message_pool_run or owner_of[ref.target] != head) continue; + for (set.excerpts[head].refs) |ref| { + if (set.excerpts[ref.target].kind != .message_pool_run or owner_of[ref.target] != head) continue; if (placed[ref.target]) continue; placed[ref.target] = true; try order.append(allocator, ref.target); } } try order.appendSlice(allocator, unowned_runs.items); - std.debug.assert(order.items.len == set.artifacts.len); - const position = try allocator.alloc(u32, set.artifacts.len); + std.debug.assert(order.items.len == set.excerpts.len); + const position = try allocator.alloc(u32, set.excerpts.len); defer allocator.free(position); for (order.items, 0..) |old, new| position[old] = @intCast(new); @@ -1639,15 +1639,15 @@ pub fn canonicalize(allocator: Allocator, set: *const Set, host_visible: []const errdefer arena.deinit(); const a = arena.allocator(); // Every constant under its content name, renamed once: a constant one - // name names is one constant, which each artifact carrying it and the + // name names is one constant, which each excerpt carrying it and the // image's data share. var renamed_items = std.StringHashMap(DataItem).init(allocator); defer renamed_items.deinit(); try renamed_items.ensureTotalCapacity(@intCast(all_items.items.len)); for (all_items.items) |item| renamed_items.putAssumeCapacityNoClobber(item.name, try renamedData(a, item, &names)); - const artifacts = try a.alloc(Artifact, set.artifacts.len); - for (order.items, artifacts) |old, *out| { - const source = set.artifacts[old]; + const excerpts = try a.alloc(Excerpt, set.excerpts.len); + for (order.items, excerpts) |old, *out| { + const source = set.excerpts[old]; out.* = source; out.kind = switch (source.kind) { .rc_helper => |name| .{ .rc_helper = try a.dupe(u8, name) }, @@ -1665,7 +1665,7 @@ pub fn canonicalize(allocator: Allocator, set: *const Set, host_visible: []const out.symbolic_refs = symbolic_refs; out.lines = try a.dupe(LineEntry, source.lines); out.failure_sites = try a.dupe(FailureSite, source.failure_sites); - out.runtime_refs = try a.dupe(RuntimeReference, source.runtime_refs); + out.runtime_numbers = try a.dupe(RuntimeNumberLoad, source.runtime_numbers); const relocations = try a.dupe(NamedRelocation, source.relocations); for (relocations) |*relocation| { const renamed = names.renames(relocation.name); @@ -1674,11 +1674,11 @@ pub fn canonicalize(allocator: Allocator, set: *const Set, host_visible: []const } out.relocations = relocations; const data = try a.alloc(DataItem, source.data.len); - for (source.data, data) |item, *owned| owned.* = renamed_items.get(item.name) orelse unreachable; // `all_items` holds every artifact's data by name + for (source.data, data) |item, *owned| owned.* = renamed_items.get(item.name) orelse unreachable; // `all_items` holds every excerpt's data by name out.data = data; } - const data = try a.alloc(CanonicalDatum, all_items.items.len); + const data = try a.alloc(ArrangedDatum, all_items.items.len); var data_len: usize = 0; var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); @@ -1691,15 +1691,15 @@ pub fn canonicalize(allocator: Allocator, set: *const Set, host_visible: []const } // The loop above keeps one datum per name, so ordering by name alone is // total and an unstable sort orders them exactly as a stable one would. - std.mem.sortUnstable(CanonicalDatum, data[0..data_len], {}, struct { - fn lessThan(_: void, lhs: CanonicalDatum, rhs: CanonicalDatum) bool { + std.mem.sortUnstable(ArrangedDatum, data[0..data_len], {}, struct { + fn lessThan(_: void, lhs: ArrangedDatum, rhs: ArrangedDatum) bool { return std.mem.lessThan(u8, lhs.item.name, rhs.item.name); } }.lessThan); const source_files = try a.dupe([32]u8, set.source_files); return .{ - .set = .{ .arena = arena, .artifacts = artifacts, .source_files = source_files }, + .set = .{ .arena = arena, .excerpts = excerpts, .source_files = source_files }, .data = data[0..data_len], }; } @@ -1727,11 +1727,11 @@ fn renamedData(a: Allocator, item: DataItem, names: *const ContentNames) Allocat }; } -/// The canonical order of two head artifacts: by kind, then by what names -/// the artifact. +/// The canonical order of two head excerpts: by kind, then by what names +/// the excerpt. fn canonicalBefore(set: *const Set, a: u32, b: u32) bool { - const lhs = set.artifacts[a].kind; - const rhs = set.artifacts[b].kind; + const lhs = set.excerpts[a].kind; + const rhs = set.excerpts[b].kind; const lhs_rank = canonicalRank(lhs); const rhs_rank = canonicalRank(rhs); if (lhs_rank != rhs_rank) return lhs_rank < rhs_rank; @@ -1741,7 +1741,7 @@ fn canonicalBefore(set: *const Set, a: u32, b: u32) bool { .rc_helper => |name| std.mem.lessThan(u8, name, rhs.rc_helper), // Only one placement's own code has these kinds, which name nothing; // the code bytes order them. - .entrypoint, .branch_island, .message_pool_run => std.mem.lessThan(u8, set.artifacts[a].code, set.artifacts[b].code), + .entrypoint, .branch_island, .message_pool_run => std.mem.lessThan(u8, set.excerpts[a].code, set.excerpts[b].code), }; } @@ -1756,12 +1756,12 @@ fn canonicalRank(kind: Kind) u8 { }; } -/// Place every artifact of a canonical image's set into an open code +/// Place every excerpt of a arranged image's set into an open code /// generator, in set order, and resolve every reference. A procedure or thunk /// the program declares lands as that procedure, and a helper whose key the /// program knows as that helper; any other lands as spliced code, as a -/// splice places it. Returns each artifact's start; the caller owns it. -pub fn assembleCanonical( +/// splice places it. Returns each excerpt's start; the caller owns it. +pub fn assembleArranged( comptime CG: type, allocator: Allocator, codegen: *CG, @@ -1769,10 +1769,10 @@ pub fn assembleCanonical( procs_by_identity: *const std.AutoHashMap(lir.ProcIdentity, lir.LIR.LirProcSpecId), helper_keys: *const HelperKeys, ) Allocator.Error![]usize { - const starts = try allocator.alloc(usize, set.artifacts.len); + const starts = try allocator.alloc(usize, set.excerpts.len); errdefer allocator.free(starts); - for (set.artifacts, starts) |artifact, *start| { - const kind: CG.CodeRegionKind = switch (artifact.kind) { + for (set.excerpts, starts) |excerpt, *start| { + const kind: CG.CodeRegionKind = switch (excerpt.kind) { .proc => |identity| if (procs_by_identity.get(identity)) |proc_id| .{ .proc = proc_id } else .{ .spliced_proc = identity }, .boxy_thunk => |identity| if (procs_by_identity.get(identity)) |proc_id| .{ .boxy_thunk = proc_id } else .{ .spliced_boxy_thunk = identity }, .rc_helper => |name| if (helper_keys.get(name)) |key| .{ .rc_helper = key } else .spliced_helper, @@ -1780,10 +1780,10 @@ pub fn assembleCanonical( .message_pool_run => .{ .message_pool_run = 0 }, .branch_island => .branch_island, }; - start.* = try codegen.appendAssembledRegion(artifact.code, kind, artifact.entry, artifact.frame); - try appendMetadata(CG, codegen, artifact, start.*, .same_domain); - try appendRuntimeRefs(CG, codegen, artifact, start.*); - for (artifact.relocations) |relocation| { + start.* = try codegen.appendAssembledRegion(excerpt.code, kind, excerpt.entry, excerpt.frame); + try appendMetadata(CG, codegen, excerpt, start.*, .same_domain); + try appendRuntimeNumbers(CG, codegen, excerpt, start.*); + for (excerpt.relocations) |relocation| { const symbol = try codegen.internSymbolName(relocation.name, relocation.scope); const offset: u64 = start.* + relocation.offset; try codegen.appendAssembledRelocation(switch (relocation.kind) { @@ -1792,10 +1792,10 @@ pub fn assembleCanonical( }); } } - for (set.artifacts, starts) |artifact, start| { - for (artifact.refs) |ref| { + for (set.excerpts, starts) |excerpt, start| { + for (excerpt.refs) |ref| { const target_start = starts[ref.target]; - const target: CG.CodeRefTarget = switch (set.artifacts[ref.target].kind) { + const target: CG.CodeRefTarget = switch (set.excerpts[ref.target].kind) { .proc => |identity| if (procs_by_identity.get(identity)) |proc_id| .{ .proc = proc_id } else .{ .offset = target_start + ref.delta }, .boxy_thunk => |identity| if (procs_by_identity.get(identity)) |proc_id| .{ .boxy_thunk = proc_id } else .{ .offset = target_start + ref.delta }, .rc_helper => |name| if (helper_keys.get(name)) |key| .{ .rc_helper = key } else .{ .offset = target_start + ref.delta }, @@ -1817,20 +1817,20 @@ pub fn assembleCanonical( return starts; } -/// Why an artifact set could not be placed into a program. +/// Why an excerpt set could not be placed into a program. pub const AssembleError = Allocator.Error || error{ - /// A procedure or thunk artifact names an identity the program has no proc for. + /// A procedure or thunk excerpt names an identity the program has no proc for. UnknownProcIdentity, - /// A helper artifact names a helper the program has no key for. + /// A helper excerpt names a helper the program has no key for. UnknownRcHelper, }; /// Keys of the refcount helpers a program can name, by symbol name. A program -/// assembling artifacts from its own compilation fills this from the original +/// assembling excerpts from its own compilation fills this from the original /// code generator's helpers. pub const HelperKeys = std.StringHashMap(u64); -/// Append every artifact of `set` to an open code generator in set order and +/// Append every excerpt of `set` to an open code generator in set order and /// re-resolve every reference to where its target landed. pub fn assemble( comptime CG: type, @@ -1874,11 +1874,11 @@ pub fn appendPrepared( procs_by_identity: *const std.AutoHashMap(lir.ProcIdentity, lir.LIR.LirProcSpecId), helper_keys: *const HelperKeys, ) AssembleError!void { - const starts = try allocator.alloc(usize, set.artifacts.len); + const starts = try allocator.alloc(usize, set.excerpts.len); defer allocator.free(starts); - for (set.artifacts, 0..) |artifact, index| { - const kind: CG.CodeRegionKind = switch (artifact.kind) { + for (set.excerpts, 0..) |excerpt, index| { + const kind: CG.CodeRegionKind = switch (excerpt.kind) { .proc => |identity| .{ .proc = procs_by_identity.get(identity) orelse return error.UnknownProcIdentity }, .rc_helper => |name| .{ .rc_helper = helper_keys.get(name) orelse return error.UnknownRcHelper }, .boxy_thunk => |identity| .{ .boxy_thunk = procs_by_identity.get(identity) orelse return error.UnknownProcIdentity }, @@ -1886,10 +1886,10 @@ pub fn appendPrepared( .message_pool_run => .{ .message_pool_run = 0 }, .branch_island => .branch_island, }; - starts[index] = try codegen.appendAssembledRegion(artifact.code, kind, artifact.entry, artifact.frame); - try appendMetadata(CG, codegen, artifact, starts[index], .same_domain); - try appendRuntimeRefs(CG, codegen, artifact, starts[index]); - for (artifact.relocations) |relocation| { + starts[index] = try codegen.appendAssembledRegion(excerpt.code, kind, excerpt.entry, excerpt.frame); + try appendMetadata(CG, codegen, excerpt, starts[index], .same_domain); + try appendRuntimeNumbers(CG, codegen, excerpt, starts[index]); + for (excerpt.relocations) |relocation| { const symbol = try codegen.internSymbolName(relocation.name, relocation.scope); const offset: u64 = starts[index] + relocation.offset; try codegen.appendAssembledRelocation(switch (relocation.kind) { @@ -1899,10 +1899,10 @@ pub fn appendPrepared( } } - for (set.artifacts, 0..) |artifact, index| { - for (artifact.refs) |ref| { - const target_artifact = set.artifacts[ref.target]; - const target: CG.CodeRefTarget = switch (target_artifact.kind) { + for (set.excerpts, 0..) |excerpt, index| { + for (excerpt.refs) |ref| { + const target_excerpt = set.excerpts[ref.target]; + const target: CG.CodeRefTarget = switch (target_excerpt.kind) { .proc => |identity| .{ .proc = procs_by_identity.get(identity) orelse return error.UnknownProcIdentity }, .rc_helper => |name| .{ .rc_helper = helper_keys.get(name) orelse return error.UnknownRcHelper }, .boxy_thunk => |identity| .{ .boxy_thunk = procs_by_identity.get(identity) orelse return error.UnknownProcIdentity }, @@ -1924,7 +1924,7 @@ pub fn appendPrepared( } } -/// How an appended artifact's lines reach the receiving program: in the same +/// How an appended excerpt's lines reach the receiving program: in the same /// source-file domain, or carried through a file map, which omits the lines /// of files the receiving program does not have. const LineBinding = union(enum) { @@ -1932,44 +1932,44 @@ const LineBinding = union(enum) { file_map: []const ?u32, }; -/// Record the runtime table numbers an artifact placed at `start` loads. -fn appendRuntimeRefs(comptime CG: type, codegen: *CG, artifact: Artifact, start: usize) Allocator.Error!void { - for (artifact.runtime_refs) |runtime_ref| { - try codegen.appendAssembledRelocation(.{ .runtime_ref = .{ .offset = start + runtime_ref.site, .ref = runtime_ref.ref } }); +/// Record the runtime table numbers an excerpt placed at `start` loads. +fn appendRuntimeNumbers(comptime CG: type, codegen: *CG, excerpt: Excerpt, start: usize) Allocator.Error!void { + for (excerpt.runtime_numbers) |runtime_number| { + try codegen.appendAssembledRelocation(.{ .runtime_number = .{ .offset = start + runtime_number.site, .ref = runtime_number.ref } }); } } -fn appendMetadata(comptime CG: type, codegen: *CG, artifact: Artifact, start: usize, lines: LineBinding) Allocator.Error!void { - switch (artifact.kind) { +fn appendMetadata(comptime CG: type, codegen: *CG, excerpt: Excerpt, start: usize, lines: LineBinding) Allocator.Error!void { + switch (excerpt.kind) { .proc => |identity| { - try codegen.registerAssembledProc(identity, start + artifact.entry); + try codegen.registerAssembledProc(identity, start + excerpt.entry); try codegen.registerAssembledProcLoc(identity, switch (lines) { - .same_domain => artifact.loc, + .same_domain => excerpt.loc, .file_map => |file_map| loc: { - if (!artifact.loc.hasLocation()) break :loc artifact.loc; - var bound = artifact.loc; - bound.file = file_map[artifact.loc.file] orelse + if (!excerpt.loc.hasLocation()) break :loc excerpt.loc; + var bound = excerpt.loc; + bound.file = file_map[excerpt.loc.file] orelse base.invariant("a spliced procedure's location names a module the program lacks", .{}); break :loc bound; }, }); }, - .rc_helper => |name| try codegen.registerSplicedHelper(name, start + artifact.entry), - .boxy_thunk => |identity| try codegen.registerAssembledThunk(identity, start + artifact.entry), + .rc_helper => |name| try codegen.registerSplicedHelper(name, start + excerpt.entry), + .boxy_thunk => |identity| try codegen.registerAssembledThunk(identity, start + excerpt.entry), .entrypoint, .message_pool_run, .branch_island => {}, } - for (artifact.symbolic_refs) |ref| { + for (excerpt.symbolic_refs) |ref| { var rebased = ref; rebased.site = @intCast(start + ref.site); rebased.veneer = if (ref.veneer) |offset| @intCast(start + offset) else null; try codegen.queueAssembledSymbolicRef(rebased); } - for (artifact.refs) |ref| { + for (excerpt.refs) |ref| { if (ref.form == .call) { try codegen.registerAssembledRefVeneer(start + ref.site, if (ref.veneer) |veneer| start + veneer else null); } } - for (artifact.lines) |line| { + for (excerpt.lines) |line| { const loc = switch (lines) { .same_domain => line.loc, .file_map => |file_map| loc: { @@ -1985,18 +1985,18 @@ fn appendMetadata(comptime CG: type, codegen: *CG, artifact: Artifact, start: us }; try codegen.appendAssembledLineEntry(.{ .offset = @intCast(start + line.offset), .loc = loc, .region = line.region }); } - for (artifact.failure_sites) |site| { + for (excerpt.failure_sites) |site| { try codegen.appendAssembledFailureSite(.{ .offset = @intCast(start + site.offset), .meaning = site.meaning }); } } -/// Why a program and its reassembled artifacts disagree. +/// Why a program and its reassembled excerpts disagree. pub const RoundTripError = ExtractError || AssembleError || error{ /// The assembled program differs from the compiled one. RoundTripMismatch, }; -/// Assemble `original`'s artifacts into `fresh` (an open code generator built +/// Assemble `original`'s excerpts into `fresh` (an open code generator built /// with the same inputs) and check that the two agree on code bytes, /// relocations, unwind records, and region layout. pub fn verifyRoundTrip( @@ -2065,17 +2065,17 @@ pub fn verifyRoundTrip( std.debug.print("ROUNDTRIP source line mismatch\n", .{}); return error.RoundTripMismatch; } - if (artifactRegionCount(CG, original.codeRegions()) != artifactRegionCount(CG, fresh.codeRegions())) { + if (excerptRegionCount(CG, original.codeRegions()) != excerptRegionCount(CG, fresh.codeRegions())) { std.debug.print("ROUNDTRIP region count mismatch: original {d}, fresh {d}\n", .{ original.codeRegions().len, fresh.codeRegions().len }); return error.RoundTripMismatch; } } -/// Regions that become artifacts: everything but branch islands, which each +/// Regions that become excerpts: everything but branch islands, which each /// placement lays out for itself. -fn artifactRegionCount(comptime CG: type, regions: []const CG.CodeRegion) usize { +fn excerptRegionCount(comptime CG: type, regions: []const CG.CodeRegion) usize { var count: usize = 0; - for (regions) |region| count += @intFromBool(regionHasArtifact(region.kind)); + for (regions) |region| count += @intFromBool(regionHasExcerpt(region.kind)); return count; } @@ -2105,7 +2105,7 @@ fn relocationEql(comptime CG: type, original: *CG, fresh: *CG, lhs: IndexedReloc std.mem.eql(u8, original.symbolName(function.symbol), fresh.symbolName(rhs.linked_function.symbol)), .linked_data => |data| data.offset == rhs.linked_data.offset and data.kind == rhs.linked_data.kind and std.mem.eql(u8, original.symbolName(data.symbol), fresh.symbolName(rhs.linked_data.symbol)), - .runtime_ref => |ref| ref.offset == rhs.runtime_ref.offset and std.meta.eql(ref.ref, rhs.runtime_ref.ref), + .runtime_number => |ref| ref.offset == rhs.runtime_number.offset and std.meta.eql(ref.ref, rhs.runtime_number.ref), .local_data, .retired => false, }; } @@ -2118,9 +2118,9 @@ fn unwindMatches(original: anytype, fresh: @TypeOf(original)) bool { return true; } -/// Where an artifact from another program's pack lands in this program: on +/// Where an excerpt from another program's pack lands in this program: on /// a procedure this program declares (external or its own), or as code that -/// only other spliced artifacts reach. +/// only other spliced excerpts reach. pub const SpliceError = Allocator.Error; /// Which stable references to procedures `markReferrers` follows. @@ -2129,39 +2129,39 @@ pub const ProcSymbols = enum { /// lowers itself is not followed by a splice, but whether a program /// lowers it is not known before lowering, so it counts. follow, - /// None: the marked artifacts are procedures named by site, which every + /// None: the marked excerpts are procedures named by site, which every /// program that takes an entry referring to one lowers itself. skip, }; -/// Mark every artifact of `set` whose splice would place a marked one: -/// every artifact that reaches a marked artifact through references, or -/// through a stable reference to an artifact of the set (to a procedure +/// Mark every excerpt of `set` whose splice would place a marked one: +/// every excerpt that reaches a marked excerpt through references, or +/// through a stable reference to an excerpt of the set (to a procedure /// only as `proc_symbols` says), transitively. pub fn markReferrers(allocator: Allocator, set: *const Set, marked: []bool, proc_symbols: ProcSymbols) Allocator.Error!void { - std.debug.assert(marked.len == set.artifacts.len); + std.debug.assert(marked.len == set.excerpts.len); var by_proc = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator); defer by_proc.deinit(); var by_thunk = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator); defer by_thunk.deinit(); var by_helper = std.StringHashMap(u32).init(allocator); defer by_helper.deinit(); - for (set.artifacts, 0..) |artifact, index| switch (artifact.kind) { + for (set.excerpts, 0..) |excerpt, index| switch (excerpt.kind) { .proc => |identity| _ = try by_proc.getOrPutValue(identity, @intCast(index)), .boxy_thunk => |identity| _ = try by_thunk.getOrPutValue(identity, @intCast(index)), .rc_helper => |name| _ = try by_helper.getOrPutValue(name, @intCast(index)), .entrypoint, .message_pool_run, .branch_island => {}, }; // Reverse edges: who places whom. - var referrers = try allocator.alloc(std.ArrayList(u32), set.artifacts.len); + var referrers = try allocator.alloc(std.ArrayList(u32), set.excerpts.len); defer { for (referrers) |*list| list.deinit(allocator); allocator.free(referrers); } for (referrers) |*list| list.* = .empty; - for (set.artifacts, 0..) |artifact, index| { - for (artifact.refs) |ref| try referrers[ref.target].append(allocator, @intCast(index)); - for (artifact.symbolic_refs) |ref| { + for (set.excerpts, 0..) |excerpt, index| { + for (excerpt.refs) |ref| try referrers[ref.target].append(allocator, @intCast(index)); + for (excerpt.symbolic_refs) |ref| { const target = switch (ref.target) { .proc => |identity| switch (proc_symbols) { .follow => by_proc.get(identity), @@ -2185,19 +2185,19 @@ pub fn markReferrers(allocator: Allocator, set: *const Set, marked: []bool, proc } } -/// Per artifact of `set`, whether a splice of it would place code for a -/// procedure in `identities`: one of the artifacts it places is such a +/// Per excerpt of `set`, whether a splice of it would place code for a +/// procedure in `identities`: one of the excerpts it places is such a /// procedure, or a stable reference of one names one. The caller owns the /// result. pub fn reachingIdentities(allocator: Allocator, set: *const Set, identities: anytype) Allocator.Error![]bool { - const marked = try allocator.alloc(bool, set.artifacts.len); + const marked = try allocator.alloc(bool, set.excerpts.len); errdefer allocator.free(marked); - for (set.artifacts, marked) |artifact, *is_marked| { - is_marked.* = switch (artifact.kind) { + for (set.excerpts, marked) |excerpt, *is_marked| { + is_marked.* = switch (excerpt.kind) { .proc => |identity| identities.contains(identity), .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => false, }; - for (artifact.symbolic_refs) |ref| switch (ref.target) { + for (excerpt.symbolic_refs) |ref| switch (ref.target) { .proc => |identity| if (identities.contains(identity)) { is_marked.* = true; }, @@ -2208,7 +2208,7 @@ pub fn reachingIdentities(allocator: Allocator, set: *const Set, identities: any return marked; } -/// Per artifact of `set`, whether a splice of it would place a line or a +/// Per excerpt of `set`, whether a splice of it would place a line or a /// location in a source file `present` does not mark, by the set's file /// index. The caller owns the result. A procedure keeps its identity across /// an edit of its module only when its identity names it by site, so a @@ -2216,11 +2216,11 @@ pub fn reachingIdentities(allocator: Allocator, set: *const Set, identities: any /// which a program taking an entry that refers to it lowers itself and a /// splice never places through that reference. pub fn reachingAbsentFiles(allocator: Allocator, set: *const Set, present: []const bool) Allocator.Error![]bool { - const marked = try allocator.alloc(bool, set.artifacts.len); + const marked = try allocator.alloc(bool, set.excerpts.len); errdefer allocator.free(marked); - for (set.artifacts, marked) |artifact, *is_marked| { - is_marked.* = artifact.loc.hasLocation() and !present[artifact.loc.file]; - for (artifact.lines) |line| { + for (set.excerpts, marked) |excerpt, *is_marked| { + is_marked.* = excerpt.loc.hasLocation() and !present[excerpt.loc.file]; + for (excerpt.lines) |line| { if (line.loc.hasLocation() and !present[line.loc.file]) is_marked.* = true; } } @@ -2228,13 +2228,13 @@ pub fn reachingAbsentFiles(allocator: Allocator, set: *const Set, present: []con return marked; } -/// Place the closure of `roots` (every artifact they reach through +/// Place the closure of `roots` (every excerpt they reach through /// references, transitively) from `set` into an open code generator, before -/// any of the program's own procedures compile. A procedure artifact whose +/// any of the program's own procedures compile. A procedure excerpt whose /// identity this program declares registers as that procedure, so the /// program's own compile skips it and every call to it is an ordinary /// direct call; refcount helpers register by name so a later request for the -/// same helper reuses the spliced code. `placed` remembers which artifacts of +/// same helper reuses the spliced code. `placed` remembers which excerpts of /// `set` are already in the buffer across calls. /// Worker identities also name Boxy thunks, but the two ABIs have separate /// registries and region kinds so neither can satisfy demand for the other. @@ -2273,13 +2273,13 @@ pub fn splice( if (placed.contains(index)) continue; const gop = try seen.getOrPut(index); if (gop.found_existing) continue; - // Another pack already spliced this artifact: content names make + // Another pack already spliced this excerpt: content names make // the code the same, so references resolve to the existing copy. - const artifact = set.artifacts[index]; - const existing: ?usize = switch (artifact.kind) { + const excerpt = set.excerpts[index]; + const existing: ?usize = switch (excerpt.kind) { .proc => |identity| codegen.splicedProcStart(identity), - .boxy_thunk => |identity| if (codegen.assembledThunkEntry(identity)) |entry| entry - artifact.entry else null, - .rc_helper => |name| if (codegen.splicedHelperEntry(name)) |entry| entry - artifact.entry else null, + .boxy_thunk => |identity| if (codegen.assembledThunkEntry(identity)) |entry| entry - excerpt.entry else null, + .rc_helper => |name| if (codegen.splicedHelperEntry(name)) |entry| entry - excerpt.entry else null, .entrypoint, .message_pool_run, .branch_island => null, }; if (existing) |start| { @@ -2287,7 +2287,7 @@ pub fn splice( continue; } try order.append(allocator, index); - const refs = set.artifacts[index].refs; + const refs = set.excerpts[index].refs; var i = refs.len; while (i > 0) { i -= 1; @@ -2295,7 +2295,7 @@ pub fn splice( } // A pack may combine independent fragments. Stable references to // definitions in this pack participate in the same transitive closure. - for (artifact.symbolic_refs) |ref| { + for (excerpt.symbolic_refs) |ref| { // A procedure of this program has its own code, compiled or // spliced as an entry of its own under the key it was served by; // a copy from this pack never stands in for it. @@ -2308,8 +2308,8 @@ pub fn splice( } for (order.items) |index| { - const artifact = set.artifacts[index]; - const kind: CG.CodeRegionKind = switch (artifact.kind) { + const excerpt = set.excerpts[index]; + const kind: CG.CodeRegionKind = switch (excerpt.kind) { .proc => |identity| if (procs_by_identity.get(identity)) |proc_id| .{ .proc = proc_id } else .{ .spliced_proc = identity }, .boxy_thunk => |identity| if (procs_by_identity.get(identity)) |proc_id| .{ .boxy_thunk = proc_id } else .{ .spliced_boxy_thunk = identity }, .rc_helper => .spliced_helper, @@ -2317,20 +2317,20 @@ pub fn splice( .message_pool_run => .{ .message_pool_run = 0 }, .branch_island => .branch_island, }; - const start = try codegen.appendAssembledRegion(artifact.code, kind, artifact.entry, artifact.frame); - try appendMetadata(CG, codegen, artifact, start, .{ .file_map = file_map }); + const start = try codegen.appendAssembledRegion(excerpt.code, kind, excerpt.entry, excerpt.frame); + try appendMetadata(CG, codegen, excerpt, start, .{ .file_map = file_map }); // A program's own code loads its table numbers; code another // program compiled never reaches the runtime's tables. - if (artifact.runtime_refs.len != 0) - base.invariant("a spliced artifact loads another program's Boxy runtime table numbers", .{}); - switch (artifact.kind) { - .rc_helper => |name| try codegen.registerSplicedHelper(name, start + artifact.entry), + if (excerpt.runtime_numbers.len != 0) + base.invariant("a spliced excerpt loads another program's Boxy runtime table numbers", .{}); + switch (excerpt.kind) { + .rc_helper => |name| try codegen.registerSplicedHelper(name, start + excerpt.entry), .proc => |identity| try codegen.registerSplicedProc(identity, start), .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => {}, } try placed.putNoClobber(index, start); - for (artifact.data) |item| try data_out.append(allocator, item); - for (artifact.relocations) |relocation| { + for (excerpt.data) |item| try data_out.append(allocator, item); + for (excerpt.relocations) |relocation| { const symbol = try codegen.internSymbolName(relocation.name, relocation.scope); const offset: u64 = start + relocation.offset; try codegen.appendAssembledRelocation(switch (relocation.kind) { @@ -2341,12 +2341,12 @@ pub fn splice( } for (order.items) |index| { - const artifact = set.artifacts[index]; + const excerpt = set.excerpts[index]; const start = placed.get(index) orelse unreachable; // the placement loop above put every index in order - for (artifact.refs) |ref| { + for (excerpt.refs) |ref| { const target_start = placed.get(ref.target) orelse unreachable; // the traversal pushed every ref target, so each was placed as existing or appended to order - const target_artifact = set.artifacts[ref.target]; - const target: CG.CodeRefTarget = switch (target_artifact.kind) { + const target_excerpt = set.excerpts[ref.target]; + const target: CG.CodeRefTarget = switch (target_excerpt.kind) { .proc => |identity| if (procs_by_identity.get(identity)) |proc_id| .{ .proc = proc_id } else .{ .offset = target_start + ref.delta }, .boxy_thunk => |identity| if (procs_by_identity.get(identity)) |proc_id| .{ .boxy_thunk = proc_id } else .{ .offset = target_start + ref.delta }, .rc_helper, .entrypoint, .message_pool_run, .branch_island => .{ .offset = target_start + ref.delta }, @@ -2365,7 +2365,7 @@ pub fn splice( } } -test "independent message pools preserve their targets across repeated artifact assembly" { +test "independent message pools preserve their targets across repeated excerpt assembly" { const allocator = std.testing.allocator; inline for (.{ @import("roc_target").RocTarget.x64linux, @import("roc_target").RocTarget.arm64linux }) |target| { const CG = LirCodeGenMod.LirCodeGen(target); @@ -2408,7 +2408,7 @@ test "independent message pools preserve their targets across repeated artifact } } -test "artifact local calls reserve veneers before later regions exceed reach" { +test "excerpt local calls reserve veneers before later regions exceed reach" { const allocator = std.testing.allocator; const CG = LirCodeGenMod.LirCodeGen(.arm64linux); var store = lir.LirStore.init(allocator); @@ -2421,7 +2421,7 @@ test "artifact local calls reserve veneers before later regions exceed reach" { const gap = @as([8192]u8, @splat(0)); var set = Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{ + .excerpts = &.{ .{ .kind = .entrypoint, .code = "\x00\x00\x00\x94", @@ -2450,11 +2450,11 @@ test "artifact local calls reserve veneers before later regions exceed reach" { try std.testing.expect(bytes.len > gap.len); } -test "artifact combination owns all fields and rebases only local references" { +test "excerpt combination owns all fields and rebases only local references" { try std.testing.checkAllAllocationFailures(std.testing.allocator, testCombineOwnership, .{}); } -test "artifact prepared data captures only the reached closure" { +test "excerpt prepared data captures only the reached closure" { try std.testing.checkAllAllocationFailures(std.testing.allocator, testPreparedData, .{}); } @@ -2484,7 +2484,7 @@ fn testPreparedData(allocator: Allocator) (ExtractError || error{ TestExpectedEq try std.testing.expectEqualStrings("literal", refs[0].name); } -test "artifact metadata queues unresolved targets, preserves same-offset line order, and binds file domains" { +test "excerpt metadata queues unresolved targets, preserves same-offset line order, and binds file domains" { const Recorder = struct { const Self = @This(); refs: [2]SymbolicReference = undefined, @@ -2516,7 +2516,7 @@ test "artifact metadata queues unresolved targets, preserves same-offset line or pub fn appendAssembledFailureSite(_: *Self, _: FailureSite) Allocator.Error!void {} }; var recorder = Recorder{}; - const artifact = Artifact{ + const excerpt = Excerpt{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "code", .entry = 1, @@ -2533,23 +2533,23 @@ test "artifact metadata queues unresolved targets, preserves same-offset line or .relocations = &.{}, .data = &.{}, }; - try appendMetadata(Recorder, &recorder, artifact, 100, .same_domain); + try appendMetadata(Recorder, &recorder, excerpt, 100, .same_domain); try std.testing.expectEqual(@as(usize, 101), recorder.proc_entry); try std.testing.expectEqual(@as(u32, 100), recorder.refs[0].site); try std.testing.expectEqual(@as(?u32, 103), recorder.refs[0].veneer); - try std.testing.expectEqualDeep(artifact.symbolic_refs[0].target, recorder.refs[0].target); + try std.testing.expectEqualDeep(excerpt.symbolic_refs[0].target, recorder.refs[0].target); try std.testing.expectEqualStrings("unemitted", recorder.refs[1].target.rc_helper); try std.testing.expectEqual(@as(usize, 102), recorder.veneer_site); try std.testing.expectEqual(@as(usize, 103), recorder.veneer_offset); - for (artifact.lines, recorder.lines[0..recorder.lines_len]) |original, rebased| { + for (excerpt.lines, recorder.lines[0..recorder.lines_len]) |original, rebased| { try std.testing.expectEqual(@as(u32, 100), rebased.offset); try std.testing.expectEqualDeep(original.loc, rebased.loc); } // A pack's file 3 is the receiving program's file 1. var persistent = Recorder{}; - try appendMetadata(Recorder, &persistent, artifact, 100, .{ .file_map = &.{ null, null, null, 1 } }); + try appendMetadata(Recorder, &persistent, excerpt, 100, .{ .file_map = &.{ null, null, null, 1 } }); try std.testing.expectEqual(@as(usize, 2), persistent.lines_len); - for (artifact.lines, persistent.lines[0..persistent.lines_len]) |original, rebound| { + for (excerpt.lines, persistent.lines[0..persistent.lines_len]) |original, rebound| { try std.testing.expectEqual(@as(u32, 1), rebound.loc.file); try std.testing.expectEqual(original.loc.line, rebound.loc.line); try std.testing.expectEqual(original.loc.column, rebound.loc.column); @@ -2560,7 +2560,7 @@ test "artifact metadata queues unresolved targets, preserves same-offset line or fn testCombineOwnership(allocator: Allocator) (Allocator.Error || error{TestExpectedEqual})!void { const source = Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{.{ + .excerpts = &.{.{ .kind = .{ .rc_helper = "helper" }, .code = "code", .entry = 1, @@ -2592,11 +2592,11 @@ fn testCombineOwnership(allocator: Allocator) (Allocator.Error || error{TestExpe defer combined.deinit(); copy.deinit(); copy_live = false; - try std.testing.expectEqualDeep(source.artifacts[0], combined.artifacts[0]); - try std.testing.expectEqual(@as(u32, 1), combined.artifacts[1].refs[0].target); - try std.testing.expectEqualDeep(source.artifacts[0].symbolic_refs, combined.artifacts[1].symbolic_refs); - try std.testing.expectEqualDeep(source.artifacts[0].lines, combined.artifacts[1].lines); - try std.testing.expectEqualDeep(source.artifacts[0].data, combined.artifacts[1].data); + try std.testing.expectEqualDeep(source.excerpts[0], combined.excerpts[0]); + try std.testing.expectEqual(@as(u32, 1), combined.excerpts[1].refs[0].target); + try std.testing.expectEqualDeep(source.excerpts[0].symbolic_refs, combined.excerpts[1].symbolic_refs); + try std.testing.expectEqualDeep(source.excerpts[0].lines, combined.excerpts[1].lines); + try std.testing.expectEqualDeep(source.excerpts[0].data, combined.excerpts[1].data); try std.testing.expectEqualDeep(source.source_files, combined.source_files); } @@ -2619,10 +2619,10 @@ fn testReloc(offset: u32, name: []const u8, addend: i64) DataRelocation { return .{ .offset = offset, .name = name, .addend = addend, .function = false }; } -/// A set whose one artifact carries `data`; the set owns nothing. -fn testDataSet(storage: *[1]Artifact, data: []const DataItem) Set { +/// A set whose one excerpt carries `data`; the set owns nothing. +fn testDataSet(storage: *[1]Excerpt, data: []const DataItem) Set { storage[0] = .{ .kind = .entrypoint, .code = &.{}, .entry = 0, .frame = null, .refs = &.{}, .relocations = &.{}, .data = data }; - return .{ .arena = collections.SingleThreadArena.init(std.testing.allocator), .artifacts = storage }; + return .{ .arena = collections.SingleThreadArena.init(std.testing.allocator), .excerpts = storage }; } test "constants are named by content across programs, through cycles, and never when host-visible" { @@ -2643,14 +2643,14 @@ test "constants are named by content across programs, through cycles, and never testItem("roc__d7_1", &@as([16]u8, @splat('\x00')), &.{ testReloc(0, "roc__d7", 0), testReloc(8, "roc__d7_2", 4) }, true), }; - var storage_one: [1]Artifact = undefined; + var storage_one: [1]Excerpt = undefined; const set_one = testDataSet(&storage_one, &one); var names_one = try ContentNames.init(testing.allocator, &set_one); defer names_one.deinit(); const one_root = names_one.of("roc__d0"); const one_node = names_one.of("roc__d0_1"); const one_leaf = names_one.of("roc__d0_2"); - var storage_two: [1]Artifact = undefined; + var storage_two: [1]Excerpt = undefined; const set_two = testDataSet(&storage_two, &two); var names_two = try ContentNames.init(testing.allocator, &set_two); defer names_two.deinit(); @@ -2670,7 +2670,7 @@ test "constants are named by content across programs, through cycles, and never testItem("roc__d0_1", &@as([16]u8, @splat('\x00')), &.{ testReloc(0, "roc__d0", 0), testReloc(8, "roc__d0_2", 4) }, true), testItem("roc__d0_2", "LEAF", &.{}, true), }; - var storage_three: [1]Artifact = undefined; + var storage_three: [1]Excerpt = undefined; const set_three = testDataSet(&storage_three, &three); var names_three = try ContentNames.init(testing.allocator, &set_three); defer names_three.deinit(); @@ -2690,22 +2690,22 @@ test "combined sets keep the program-local constants of each set apart" { const second_data = [_]DataItem{testItem("roc__d0", "second constant", &.{}, true)}; const first = Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{.{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "\x00\x00\x00\x00", .entry = 0, .frame = null, .refs = &.{}, .relocations = &first_relocations, .data = &first_data }}, + .excerpts = &.{.{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "\x00\x00\x00\x00", .entry = 0, .frame = null, .refs = &.{}, .relocations = &first_relocations, .data = &first_data }}, }; const second = Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{.{ .kind = .{ .proc = lir.ProcIdentity.forTest(2) }, .code = "\x00\x00\x00\x00", .entry = 0, .frame = null, .refs = &.{}, .relocations = &second_relocations, .data = &second_data }}, + .excerpts = &.{.{ .kind = .{ .proc = lir.ProcIdentity.forTest(2) }, .code = "\x00\x00\x00\x00", .entry = 0, .frame = null, .refs = &.{}, .relocations = &second_relocations, .data = &second_data }}, }; var combined = try combineDistinct(allocator, &.{ &first, &second }); defer combined.deinit(); - try std.testing.expectEqual(@as(usize, 2), combined.artifacts.len); - for (combined.artifacts, [_][]const u8{ "first constant", "second constant" }) |artifact, bytes| { - try std.testing.expectEqualStrings(bytes, artifact.data[0].bytes); - try std.testing.expect(!artifact.data[0].program_local_name); - try std.testing.expectEqualStrings(artifact.data[0].name, artifact.relocations[0].name); - try std.testing.expectEqual(SymbolScope.shared, artifact.relocations[0].scope); + try std.testing.expectEqual(@as(usize, 2), combined.excerpts.len); + for (combined.excerpts, [_][]const u8{ "first constant", "second constant" }) |excerpt, bytes| { + try std.testing.expectEqualStrings(bytes, excerpt.data[0].bytes); + try std.testing.expect(!excerpt.data[0].program_local_name); + try std.testing.expectEqualStrings(excerpt.data[0].name, excerpt.relocations[0].name); + try std.testing.expectEqual(SymbolScope.shared, excerpt.relocations[0].scope); } - try std.testing.expect(!std.mem.eql(u8, combined.artifacts[0].data[0].name, combined.artifacts[1].data[0].name)); + try std.testing.expect(!std.mem.eql(u8, combined.excerpts[0].data[0].name, combined.excerpts[1].data[0].name)); } test "an object pack names each constant as the datum anchored at its first byte" { @@ -2750,7 +2750,7 @@ test "an object pack names each constant as the datum anchored at its first byte try testing.expectEqualStrings("roc__answer", inner_names.of("roc__answer")); // A constant anchored at its first byte is named as a dev pack names it. - var storage: [1]Artifact = undefined; + var storage: [1]Excerpt = undefined; const items = [_]DataItem{ testItem("roc__d5", &zeros, &.{testReloc(0, "roc__d5_1", 0)}, true), testItem("roc__d5_1", leaf, &.{}, true), @@ -2761,7 +2761,7 @@ test "an object pack names each constant as the datum anchored at its first byte try testing.expectEqualStrings(dev_names.of("roc__d5"), other_names.of("roc__d5")); } -fn testProc(id: u8, code: []const u8, refs: []const Reference, symbolic_refs: []const SymbolicReference, relocations: []const NamedRelocation, data: []const DataItem) Artifact { +fn testProc(id: u8, code: []const u8, refs: []const Reference, symbolic_refs: []const SymbolicReference, relocations: []const NamedRelocation, data: []const DataItem) Excerpt { return .{ .kind = .{ .proc = lir.ProcIdentity.forTest(id) }, .code = code, @@ -2774,7 +2774,7 @@ fn testProc(id: u8, code: []const u8, refs: []const Reference, symbolic_refs: [] }; } -test "a canonical image does not depend on artifact order or on what a program named its constants" { +test "a arranged image does not depend on excerpt order or on what a program named its constants" { const allocator = std.testing.allocator; // One program placed the pool run and helper first and named its // constant `roc__d5`; another placed them last and named the same @@ -2783,11 +2783,11 @@ test "a canonical image does not depend on artifact order or on what a program n const two_data = [_]DataItem{testItem("roc__d9", "constant", &.{}, true)}; const one_relocations = [_]NamedRelocation{.{ .offset = 1, .name = "roc__d5", .scope = .program, .kind = .{ .data = .rel32 } }}; const two_relocations = [_]NamedRelocation{.{ .offset = 1, .name = "roc__d9", .scope = .program, .kind = .{ .data = .rel32 } }}; - const helper: Artifact = .{ .kind = .{ .rc_helper = "roc__rc_decref_x" }, .code = "\xeb\x00\xc3", .entry = 2, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }; - const pool: Artifact = .{ .kind = .message_pool_run, .code = "message", .entry = 0, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }; + const helper: Excerpt = .{ .kind = .{ .rc_helper = "roc__rc_decref_x" }, .code = "\xeb\x00\xc3", .entry = 2, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }; + const pool: Excerpt = .{ .kind = .message_pool_run, .code = "message", .entry = 0, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }; const one = Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{ + .excerpts = &.{ pool, helper, testProc(2, "\x02\x02\x02\x02", &.{.{ .site = 0, .form = .addr, .target = 0, .delta = 3 }}, &.{}, &.{}, &.{}), @@ -2796,28 +2796,28 @@ test "a canonical image does not depend on artifact order or on what a program n }; const two = Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{ + .excerpts = &.{ testProc(1, "\x01\x01\x01\x01", &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}, &.{.{ .site = 2, .form = .call, .target = .{ .rc_helper = "roc__rc_decref_x" } }}, &two_relocations, &two_data), testProc(2, "\x02\x02\x02\x02", &.{.{ .site = 0, .form = .addr, .target = 3, .delta = 3 }}, &.{}, &.{}, &.{}), helper, pool, }, }; - var first = try canonicalize(allocator, &one, &.{}); + var first = try arrange(allocator, &one, &.{}); defer first.deinit(); - var second = try canonicalize(allocator, &two, &.{}); + var second = try arrange(allocator, &two, &.{}); defer second.deinit(); // Procedures by identity, the pool run right after the procedure // addressing it, then helpers. - try std.testing.expectEqual(@as(usize, 4), first.set.artifacts.len); - const proc_one: usize = if (std.meta.eql(first.set.artifacts[0].kind, Kind{ .proc = lir.ProcIdentity.forTest(1) })) 0 else 2; + try std.testing.expectEqual(@as(usize, 4), first.set.excerpts.len); + const proc_one: usize = if (std.meta.eql(first.set.excerpts[0].kind, Kind{ .proc = lir.ProcIdentity.forTest(1) })) 0 else 2; const proc_two: usize = if (proc_one == 0) 1 else 0; - try std.testing.expectEqual(lir.ProcIdentity.forTest(1), first.set.artifacts[proc_one].kind.proc); - try std.testing.expectEqual(lir.ProcIdentity.forTest(2), first.set.artifacts[proc_two].kind.proc); - try std.testing.expect(first.set.artifacts[proc_two + 1].kind == .message_pool_run); - try std.testing.expect(first.set.artifacts[3].kind == .rc_helper); - try std.testing.expect(std.mem.lessThan(u8, &first.set.artifacts[@min(proc_one, proc_two)].kind.proc.bytes, &first.set.artifacts[@max(proc_one, proc_two)].kind.proc.bytes)); - for (first.set.artifacts, second.set.artifacts) |lhs, rhs| { + try std.testing.expectEqual(lir.ProcIdentity.forTest(1), first.set.excerpts[proc_one].kind.proc); + try std.testing.expectEqual(lir.ProcIdentity.forTest(2), first.set.excerpts[proc_two].kind.proc); + try std.testing.expect(first.set.excerpts[proc_two + 1].kind == .message_pool_run); + try std.testing.expect(first.set.excerpts[3].kind == .rc_helper); + try std.testing.expect(std.mem.lessThan(u8, &first.set.excerpts[@min(proc_one, proc_two)].kind.proc.bytes, &first.set.excerpts[@max(proc_one, proc_two)].kind.proc.bytes)); + for (first.set.excerpts, second.set.excerpts) |lhs, rhs| { try std.testing.expectEqualDeep(lhs.kind, rhs.kind); try std.testing.expectEqualSlices(u8, lhs.code, rhs.code); try std.testing.expectEqualDeep(lhs.refs, rhs.refs); @@ -2827,28 +2827,28 @@ test "a canonical image does not depend on artifact order or on what a program n try std.testing.expectEqual(l.scope, r.scope); } } - try std.testing.expectEqual(lir.ProcIdentity.forTest(2), first.set.artifacts[first.set.artifacts[proc_one].refs[0].target].kind.proc); - try std.testing.expect(first.set.artifacts[first.set.artifacts[proc_two].refs[0].target].kind == .message_pool_run); + try std.testing.expectEqual(lir.ProcIdentity.forTest(2), first.set.excerpts[first.set.excerpts[proc_one].refs[0].target].kind.proc); + try std.testing.expect(first.set.excerpts[first.set.excerpts[proc_two].refs[0].target].kind == .message_pool_run); // The constant is one symbol under its content name in both. try std.testing.expectEqual(@as(usize, 1), first.data.len); try std.testing.expectEqualStrings(first.data[0].item.name, second.data[0].item.name); try std.testing.expect(std.mem.startsWith(u8, first.data[0].item.name, content_data_prefix)); - try std.testing.expectEqualStrings(first.data[0].item.name, first.set.artifacts[proc_one].relocations[0].name); - try std.testing.expectEqual(SymbolScope.shared, first.set.artifacts[proc_one].relocations[0].scope); + try std.testing.expectEqualStrings(first.data[0].item.name, first.set.excerpts[proc_one].relocations[0].name); + try std.testing.expectEqual(SymbolScope.shared, first.set.excerpts[proc_one].relocations[0].scope); } -test "a host-visible constant keeps its name in a canonical image while the constants it names are named by content" { +test "a host-visible constant keeps its name in a arranged image while the constants it names are named by content" { const allocator = std.testing.allocator; const exported = [_]DataItem{ testItem("roc__answer", &@as([8]u8, @splat('\x00')), &.{testReloc(0, "roc__d3", 0)}, false), testItem("roc__d3", "leaf", &.{}, true), }; - const set = Set{ .arena = collections.SingleThreadArena.init(allocator), .artifacts = &.{} }; - var image = try canonicalize(allocator, &set, &exported); + const set = Set{ .arena = collections.SingleThreadArena.init(allocator), .excerpts = &.{} }; + var image = try arrange(allocator, &set, &exported); defer image.deinit(); try std.testing.expectEqual(@as(usize, 2), image.data.len); - var answer: ?CanonicalDatum = null; - var leaf: ?CanonicalDatum = null; + var answer: ?ArrangedDatum = null; + var leaf: ?ArrangedDatum = null; for (image.data) |datum| { if (std.mem.eql(u8, datum.item.name, "roc__answer")) answer = datum else leaf = datum; } @@ -2872,7 +2872,7 @@ test "an image keeps only the code its roots reach" { defer catalog.deinit(); const set = Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{ + .excerpts = &.{ testProc(1, "\x01\x01\x01\x01", &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}, &.{.{ .site = 2, .form = .call, .target = .{ .rc_helper = "roc__rc_decref_x" } }}, &root_relocations, &constant), testProc(2, "\x02\x02\x02\x02", &.{}, &.{}, &.{}, &.{}), testProc(3, "\x03\x03\x03\x03", &.{}, &.{}, &.{}, &.{}), @@ -2891,19 +2891,19 @@ test "an image keeps only the code its roots reach" { .all_thunks = false, }); defer kept.deinit(); - try std.testing.expectEqual(@as(usize, 4), kept.artifacts.len); - for (kept.artifacts) |artifact| switch (artifact.kind) { + try std.testing.expectEqual(@as(usize, 4), kept.excerpts.len); + for (kept.excerpts) |excerpt| switch (excerpt.kind) { .proc => |identity| try std.testing.expect(!std.meta.eql(identity, lir.ProcIdentity.forTest(3))), .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => {}, }; - try std.testing.expectEqual(lir.ProcIdentity.forTest(2), kept.artifacts[kept.artifacts[0].refs[0].target].kind.proc); + try std.testing.expectEqual(lir.ProcIdentity.forTest(2), kept.excerpts[kept.excerpts[0].refs[0].target].kind.proc); } test "a splice reaches a procedure through references and stable references" { const allocator = std.testing.allocator; const set = Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{ + .excerpts = &.{ testProc(1, "\x01\x01\x01\x01", &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}, &.{}, &.{}, &.{}), testProc(2, "\x02\x02\x02\x02", &.{}, &.{.{ .site = 0, .form = .call, .target = .{ .proc = lir.ProcIdentity.forTest(3) } }}, &.{}, &.{}), testProc(3, "\x03\x03\x03\x03", &.{}, &.{}, &.{}, &.{}), @@ -2920,10 +2920,10 @@ test "a splice reaches a procedure through references and stable references" { // A procedure with lines in a file the program lacks is site-named code, // which the program lowers itself, so a stable reference to it places // nothing; a reference into the pack does. - var lined = [_]Artifact{ set.artifacts[0], set.artifacts[1], set.artifacts[2], set.artifacts[3] }; + var lined = [_]Excerpt{ set.excerpts[0], set.excerpts[1], set.excerpts[2], set.excerpts[3] }; lined[2].lines = &.{.{ .offset = 0, .loc = .{ .file = 1, .line = 3, .column = 1 }, .region = base.Region.zero() }}; lined[3].loc = .{ .file = 0, .line = 1, .column = 1 }; - const lined_set = Set{ .arena = collections.SingleThreadArena.init(allocator), .artifacts = &lined }; + const lined_set = Set{ .arena = collections.SingleThreadArena.init(allocator), .excerpts = &lined }; const absent = try reachingAbsentFiles(allocator, &lined_set, &.{ true, false }); defer allocator.free(absent); try std.testing.expectEqualSlices(bool, &.{ false, false, true, false }, absent); diff --git a/src/backend/dev/HostSplice.zig b/src/backend/dev/HostSplice.zig index 7533a54da91..0f05125e862 100644 --- a/src/backend/dev/HostSplice.zig +++ b/src/backend/dev/HostSplice.zig @@ -21,7 +21,7 @@ const builtin = @import("builtin"); const builtins = @import("builtins"); const lir = @import("lir"); const LirCodeGenMod = @import("LirCodeGen.zig"); -const ProcArtifact = @import("ProcArtifact.zig"); +const CodeExcerpt = @import("CodeExcerpt.zig"); const StaticDataExport = @import("StaticDataExport.zig").StaticDataExport; const StaticDataImage = @import("StaticDataImage.zig").StaticDataImage; const relocation_mod = @import("Relocation.zig"); @@ -51,7 +51,7 @@ const stub_size = 16; pub const HostSplice = struct { allocator: Allocator, /// The carried data items in placement order, one per name. - data: std.ArrayList(ProcArtifact.DataItem) = .empty, + data: std.ArrayList(CodeExcerpt.DataItem) = .empty, /// Each carried data item's index in `data`, by name. data_names: std.StringHashMap(u32), /// Each data item's offset in the mapping once `link` has laid it out. @@ -116,17 +116,17 @@ pub const HostSplice = struct { try external.append(self.allocator, proc_id); } if (external.items.len == 0) return; - var carried = std.ArrayList(ProcArtifact.DataItem).empty; + var carried = std.ArrayList(CodeExcerpt.DataItem).empty; defer carried.deinit(self.allocator); try spliceExternalProcs(HostLirCodeGen, self.allocator, codegen, codegen.store.getProcSpecs(), external.items, source, &carried); try self.addDataItems(carried.items); self.spliced_procs += external.items.len; } - /// Carry artifact data into the executable mapping, once per exact name. + /// Carry excerpt data into the executable mapping, once per exact name. /// Records are copied; their names, bytes, and relocations remain borrowed /// and must outlive this splice and its `link` call. - pub fn addDataItems(self: *HostSplice, items: []const ProcArtifact.DataItem) Allocator.Error!void { + pub fn addDataItems(self: *HostSplice, items: []const CodeExcerpt.DataItem) Allocator.Error!void { for (items) |item| { const gop = try self.data_names.getOrPut(item.name); if (gop.found_existing) continue; @@ -161,7 +161,7 @@ pub const HostSplice = struct { if (self.static_data) |layout| if (layout.symbolOffset(name) != null) continue; break :blk name; }, - .local_data, .retired, .runtime_ref => continue, + .local_data, .retired, .runtime_number => continue, }; if (compilerFunction(name, boxy_native_fns) != null or code_symbols.contains(name) or self.data_names.contains(name) or self.hosted_stubs.contains(name)) continue; if (!containsName(needed.items, name)) try needed.append(self.allocator, name); @@ -194,7 +194,7 @@ pub const HostSplice = struct { for (codegen.getRelocations()) |indexed| { try relocations.append(self.allocator, switch (indexed) { // The image keeps the program's own table numbers. - .retired, .runtime_ref => continue, + .retired, .runtime_number => continue, .linked_function => |function| .{ .linked_function = .{ .offset = function.offset, .name = names[@backingInt(function.symbol)], .kind = function.kind } }, .linked_data => |data| .{ .linked_data = .{ .offset = data.offset, .name = names[@backingInt(data.symbol)], .kind = data.kind } }, .local_data => |data| .{ .local_data = data }, @@ -496,7 +496,7 @@ test "static data is placed in the mapping and stays writable" { var splice = HostSplice.init(allocator); defer splice.deinit(); // A carried item that points at the slot. - const pointers = [_]ProcArtifact.DataItem{.{ + const pointers = [_]CodeExcerpt.DataItem{.{ .name = "test_slot_pointer", .bytes = &(@as([8]u8, @splat(0))), .alignment = 8, @@ -543,13 +543,13 @@ test "linking many data items resolves each pointer through its target's own sym var arena = std.heap.ArenaAllocator.init(allocator); defer arena.deinit(); const count = 100_000; - const items = try arena.allocator().alloc(ProcArtifact.DataItem, count); + const items = try arena.allocator().alloc(CodeExcerpt.DataItem, count); const names = try arena.allocator().alloc([]const u8, count); for (names, 0..) |*name, index| name.* = try std.fmt.allocPrint(arena.allocator(), "test_item_{d}", .{index}); for (items, 0..) |*item, index| { const bytes = try arena.allocator().alloc(u8, 2 * @sizeOf(usize)); @memset(bytes, 0); - const relocations = try arena.allocator().alloc(ProcArtifact.DataRelocation, 1); + const relocations = try arena.allocator().alloc(CodeExcerpt.DataRelocation, 1); relocations[0] = .{ .offset = @sizeOf(usize), .name = names[(index + 1) % count], .addend = 0, .function = false }; item.* = .{ .name = names[index], diff --git a/src/backend/dev/LirCodeGen.zig b/src/backend/dev/LirCodeGen.zig index cd27f9f852a..5a4a6fec76f 100644 --- a/src/backend/dev/LirCodeGen.zig +++ b/src/backend/dev/LirCodeGen.zig @@ -52,7 +52,7 @@ const CpuLevel = roc_target_mod.CpuLevel; // with 24-byte RocList struct returns on aarch64 const Relocation = @import("Relocation.zig").IndexedRelocation; -const RuntimeRef = @import("Relocation.zig").RuntimeRef; +const RuntimeNumber = @import("Relocation.zig").RuntimeNumber; const collections = @import("collections"); const SymbolTable = @import("SymbolTable.zig"); const StackPlan = lir.StackPlan; @@ -292,7 +292,7 @@ pub const FragmentContextDependencies = struct { /// Non-LIR inputs to fragment emission. Reuse additionally requires producer /// domain/revision authority; the owner supplies that, not the code generator. /// Dependency flags permit ignoring Boxy-init policy differences only when the -/// emitted artifact proves it does not depend on that policy. +/// emitted excerpt proves it does not depend on that policy. pub const FragmentContract = struct { /// Platform/codegen identity; the selected instruction floor is separate. target: RocTarget, @@ -726,8 +726,8 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// Region starts of procedures spliced from object-cache entries, by /// identity; a second pack holding the same procedure splices nothing. spliced_proc_starts: std.AutoHashMap(lir.ProcIdentity, usize), - /// The source location each procedure placed from an artifact set - /// carries (`ProcArtifact.Artifact.loc`), in this program's + /// The source location each procedure placed from an excerpt set + /// carries (`CodeExcerpt.Excerpt.loc`), in this program's /// source-file domain, by identity. assembled_proc_locs: std.AutoHashMapUnmanaged(lir.ProcIdentity, base.SourceLoc) = .empty, @@ -1006,7 +1006,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// Every call or address literal that targets the buffer itself is /// logged with one of these, so a procedure's bytes can be lifted out /// of the buffer and placed in another one with its references - /// re-resolved (see `ProcArtifact.zig`). + /// re-resolved (see `CodeExcerpt.zig`). pub const CodeRefTarget = union(enum) { proc: lir.LIR.LirProcSpecId, /// Encoded RC helper key (`RcHelperVariant.encode`). @@ -1065,7 +1065,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// Frame metadata carried with an assembled region so its unwind /// record can be re-recorded where it lands. - pub const AssembledFrame = @import("ProcArtifact.zig").Frame; + pub const AssembledFrame = @import("CodeExcerpt.zig").Frame; /// Tracks position of a BL/CALL to a compiled lambda proc. /// Used to re-patch relative offsets after deferred-prologue body shifts. @@ -12636,7 +12636,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { ) Allocator.Error!void { var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; for (args) |arg| switch (arg) { .lea => |offset| try builder.addLeaArg(frame_ptr, offset), .list => |offset| try builder.addListBytesLenCapMemArgs(frame_ptr, offset), @@ -13094,7 +13094,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; try builder.addRegArg(capture_reg); try self.addRuntimeLayoutArg(&builder, &runtime_regs, capture_layout); try builder.addRegArg(desc_reg); @@ -14387,7 +14387,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { // no value until then, so its storage is left as allocated. var defer_builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer defer_builder.deinit(); - var defer_runtime_regs: RuntimeRefRegs = .{}; + var defer_runtime_regs: RuntimeNumberRegs = .{}; try defer_builder.addMemArg(frame_ptr, fn_ptr_slot); try defer_builder.addMemArg(frame_ptr, closure_ptr_slot); try defer_builder.addMemArg(frame_ptr, capture_stack_offset); @@ -14423,7 +14423,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { // the call site's public ABI. var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; try builder.addMemArg(frame_ptr, fn_ptr_slot); if (ret_size == 0) { try builder.addImmArg(0); @@ -14506,7 +14506,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { } var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; if (size == 0) { try builder.addImmArg(0); } else { @@ -14537,7 +14537,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { const slot = self.codegen.allocStackSlot(8); var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; if (result_desc_slot) |s| try builder.addMemArg(frame_ptr, s) else try builder.addImmArg(0); const returned_layout = self.early_return_ret_layout orelse std.debug.panic("Dev/codegen invariant violated: a procedure returning a pending call has no return layout", .{}); @@ -15680,65 +15680,65 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// The registers a call's runtime table numbers were loaded into, /// released once the call is emitted. - const RuntimeRefRegs = struct { + const RuntimeNumberRegs = struct { regs: [boxy_call_plan.max_operands]GeneralReg = undefined, len: u8 = 0, - fn release(self: *RuntimeRefRegs, owner: *Self) void { + fn release(self: *RuntimeNumberRegs, owner: *Self) void { for (self.regs[0..self.len]) |reg| owner.codegen.freeGeneral(reg); self.len = 0; } }; /// Load a number the Boxy runtime reads its sidecar tables by into a - /// temporary register. An object file's canonical image renumbers - /// what its code uses (design.md "Canonical Dev Images"), so the load + /// temporary register. An object file's arranged image renumbers + /// what its code uses (design.md "Deterministic Dev Images"), so the load /// is patchable and recorded as a runtime reference, which every /// other image leaves holding the program's own number. - fn loadRuntimeRef(self: *Self, ref: RuntimeRef) Allocator.Error!GeneralReg { + fn loadRuntimeNumber(self: *Self, ref: RuntimeNumber) Allocator.Error!GeneralReg { const reg = try self.allocTempGeneral(); - try self.emitRuntimeRefInto(reg, ref); + try self.emitRuntimeNumberInto(reg, ref); return reg; } - /// `loadRuntimeRef` into a register the caller holds. - fn emitRuntimeRefInto(self: *Self, reg: GeneralReg, ref: RuntimeRef) Allocator.Error!void { + /// `loadRuntimeNumber` into a register the caller holds. + fn emitRuntimeNumberInto(self: *Self, reg: GeneralReg, ref: RuntimeNumber) Allocator.Error!void { const value = switch (ref) { .layout => |idx| idx, .erased_arg_layouts => |span| span.start, }; const site = try self.codegen.emitPatchableImm32(reg, value); - try self.codegen.relocations.append(self.allocator, .{ .runtime_ref = .{ .offset = site, .ref = ref } }); + try self.codegen.relocations.append(self.allocator, .{ .runtime_number = .{ .offset = site, .ref = ref } }); } /// Give every runtime table number the placed code loads its /// canonical number, in placement order, and patch each load to it - /// (design.md "Canonical Dev Images"). The recorded references then + /// (design.md "Deterministic Dev Images"). The recorded references then /// name canonical numbers. - pub fn renumberRuntimeRefs(self: *Self, numbering: *lir.RuntimeTables.Numbering) Allocator.Error!void { + pub fn renumberRuntimeNumbers(self: *Self, numbering: *lir.RuntimeTables.Numbering) Allocator.Error!void { const code = self.codegen.emit.buf.items; for (self.codegen.relocations.items) |*relocation| switch (relocation.*) { - .runtime_ref => |*runtime_ref| { - const canonical: RuntimeRef = switch (runtime_ref.ref) { + .runtime_number => |*runtime_number| { + const canonical: RuntimeNumber = switch (runtime_number.ref) { .layout => |idx| .{ .layout = try numbering.layoutId(self.allocator, @fromBackingInt(@intCast(idx))) }, .erased_arg_layouts => |span| .{ .erased_arg_layouts = .{ .start = try numbering.erasedArgLayoutsStart(self.allocator, .{ .start = span.start, .len = span.len }), .len = span.len, } }, }; - @TypeOf(self.codegen).patchImm32(code, @intCast(runtime_ref.offset), switch (canonical) { + @TypeOf(self.codegen).patchImm32(code, @intCast(runtime_number.offset), switch (canonical) { .layout => |idx| idx, .erased_arg_layouts => |span| span.start, }); - runtime_ref.ref = canonical; + runtime_number.ref = canonical; }, .linked_function, .linked_data, .local_data, .retired => {}, }; } /// Pass a runtime table number as the call's next argument. - fn addRuntimeRefArg(self: *Self, builder: *Builder, regs: *RuntimeRefRegs, ref: RuntimeRef) Allocator.Error!void { - const reg = try self.loadRuntimeRef(ref); + fn addRuntimeNumberArg(self: *Self, builder: *Builder, regs: *RuntimeNumberRegs, ref: RuntimeNumber) Allocator.Error!void { + const reg = try self.loadRuntimeNumber(ref); regs.regs[regs.len] = reg; regs.len += 1; try builder.addRegArg(reg); @@ -15746,14 +15746,14 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// Pass a layout index the Boxy runtime reads as the call's next /// argument. - fn addRuntimeLayoutArg(self: *Self, builder: *Builder, regs: *RuntimeRefRegs, layout_idx: layout.Idx) Allocator.Error!void { - try self.addRuntimeRefArg(builder, regs, .{ .layout = @backingInt(layout_idx) }); + fn addRuntimeLayoutArg(self: *Self, builder: *Builder, regs: *RuntimeNumberRegs, layout_idx: layout.Idx) Allocator.Error!void { + try self.addRuntimeNumberArg(builder, regs, .{ .layout = @backingInt(layout_idx) }); } /// Pass a run of erased argument layouts as the call's next two /// arguments: its start and its length. - fn addErasedArgLayoutsArgs(self: *Self, builder: *Builder, regs: *RuntimeRefRegs, span: lir.LIR.BoxySpan) Allocator.Error!void { - try self.addRuntimeRefArg(builder, regs, .{ .erased_arg_layouts = .{ .start = span.start, .len = span.len } }); + fn addErasedArgLayoutsArgs(self: *Self, builder: *Builder, regs: *RuntimeNumberRegs, span: lir.LIR.BoxySpan) Allocator.Error!void { + try self.addRuntimeNumberArg(builder, regs, .{ .erased_arg_layouts = .{ .start = span.start, .len = span.len } }); try builder.addImmArg(span.len); } @@ -15762,7 +15762,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { fn callBoxyToSlot(self: *Self, boxy_fn: BoxyBuiltinFn, args: []const BoxyScalarArg) Allocator.Error!i32 { var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; for (args) |arg| switch (arg) { .imm => |value| try builder.addImmArg(value), .mem => |offset| try builder.addMemArg(frame_ptr, offset), @@ -16026,7 +16026,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { defer projection_builder.deinit(); try projection_builder.addMemArg(frame_ptr, base_slot); if (assign.box_payload_layout) |box_layout| { - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; try self.addRuntimeLayoutArg(&projection_builder, &runtime_regs, box_layout); try self.callBoxyBuiltin(&projection_builder, .box_payload_desc); runtime_regs.release(self); @@ -16122,7 +16122,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; for (plan.operands(), operand_slots[0..plan.len]) |operand, slot| switch (operand) { .out => try builder.addLeaArg(frame_ptr, slots.out), .out_desc => try builder.addLeaArg(frame_ptr, slots.out_desc), @@ -16446,7 +16446,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { const reg = try self.allocTempGeneral(); if (plan.value_off) |off| try self.emitLeaStack(reg, off) else try self.codegen.emitLoadImm(reg, 0); try self.emitStore(.w64, frame_ptr, entry_base + call_arg_value_off, reg); - try self.emitRuntimeRefInto(reg, .{ .layout = @backingInt(plan.layout) }); + try self.emitRuntimeNumberInto(reg, .{ .layout = @backingInt(plan.layout) }); try self.emitStore(.w32, frame_ptr, entry_base + call_arg_layout_off, reg); if (plan.desc_slot) |s| try self.emitLoad(.w64, reg, frame_ptr, s) else try self.codegen.emitLoadImm(reg, 0); try self.emitStore(.w64, frame_ptr, entry_base + call_arg_desc_off, reg); @@ -16470,7 +16470,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; try builder.addLeaArg(frame_ptr, out_slot); try builder.addLeaArg(frame_ptr, out_desc_slot); try builder.addMemArg(frame_ptr, dict_slot); @@ -16510,7 +16510,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { try builder.addLeaArg(frame_ptr, out_desc_slot); try builder.addLeaArg(frame_ptr, value_slot); try builder.addMemArg(frame_ptr, desc_slot); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; try self.addRuntimeLayoutArg(&builder, &runtime_regs, default_layout); try self.addRuntimeLayoutArg(&builder, &runtime_regs, target_layout); try self.callBoxyBuiltin(&builder, boxy_fn); @@ -16553,7 +16553,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; try builder.addLeaArg(frame_ptr, value_off); try self.addRuntimeLayoutArg(&builder, &runtime_regs, value_layout); try builder.addMemArg(frame_ptr, desc_slot); @@ -22313,7 +22313,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { try self.emitInternalCodeAddress(.{ .proc = proc.id }, proc.code_start, addr_reg); var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; try builder.addRegArg(addr_reg); try self.addRuntimeLayoutArg(&builder, &runtime_regs, self.runtimeRepresentationLayoutIdx(proc_spec.ret_layout)); try builder.addImmArg(metadata_offset); @@ -22366,7 +22366,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { try self.emitInternalCodeAddress(.{ .boxy_thunk = proc_id }, thunk_offset, addr_reg); var builder = try Builder.init(&self.codegen.emit, &self.codegen.stack_offset); defer builder.deinit(); - var runtime_regs: RuntimeRefRegs = .{}; + var runtime_regs: RuntimeNumberRegs = .{}; try builder.addImmArg(@intCast(proc_index)); try builder.addRegArg(addr_reg); try self.addRuntimeLayoutArg(&builder, &runtime_regs, ret_layout); @@ -23134,7 +23134,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { if (!gop.found_existing) gop.value_ptr.* = entry; } - /// Remember the source location a procedure placed from an artifact + /// Remember the source location a procedure placed from an excerpt /// set carries; the first placement of an identity wins, as for its /// code. pub fn registerAssembledProcLoc(self: *Self, identity: lir.ProcIdentity, loc: base.SourceLoc) Allocator.Error!void { @@ -23142,7 +23142,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { if (!gop.found_existing) gop.value_ptr.* = loc; } - /// The source location a procedure placed from an artifact set + /// The source location a procedure placed from an excerpt set /// carries, when one was placed. pub fn assembledProcLoc(self: *const Self, identity: lir.ProcIdentity) ?base.SourceLoc { return self.assembled_proc_locs.get(identity); @@ -23158,7 +23158,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { } /// The queue owns helper names through the generator's symbol table; - /// callers may destroy the source artifact immediately after append. + /// callers may destroy the source excerpt immediately after append. pub fn queueAssembledSymbolicRef(self: *Self, reference: anytype) Allocator.Error!void { var owned = SymbolicReference{ .site = reference.site, @@ -23342,12 +23342,12 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// Detached stubs belong to placement. Embedded stubs stay with their /// body's branch so a large body never loses its reachable reservation. - pub fn artifactRelocations(self: *Self, allocator: Allocator) Allocator.Error![]Relocation { + pub fn excerptRelocations(self: *Self, allocator: Allocator) Allocator.Error![]Relocation { const relocations = try allocator.dupe(Relocation, self.getRelocations()); if (comptime target.toCpuArch() == .aarch64) { for (self.codegen.extern_stubs.items) |stub| { const stub_start = relocations[stub.page_relocation].linked_data.offset; - if (self.artifactContainsExternStub(stub.call, stub_start)) continue; + if (self.excerptContainsExternStub(stub.call, stub_start)) continue; relocations[stub.original_relocation] = .{ .linked_function = .{ .offset = @intCast(stub.call), .symbol = stub.symbol, @@ -23360,7 +23360,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { return relocations; } - fn artifactContainsExternStub(self: *const Self, call: usize, stub_start: u64) bool { + fn excerptContainsExternStub(self: *const Self, call: usize, stub_start: u64) bool { for (self.code_regions.items) |region| { if (region.kind == .branch_island) continue; if (call >= region.start and call < region.end and @@ -23371,11 +23371,11 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// Remove placement-specific stub displacements using producer records. /// Clear the placement-specific displacement of a reference at - /// `site` in an artifact's `bytes`, and of the in-body veneer it + /// `site` in an excerpt's `bytes`, and of the in-body veneer it /// goes through, as if it named itself. Placement patches every - /// reference again, so an artifact's bytes are then the same however + /// reference again, so an excerpt's bytes are then the same however /// the buffer it was lifted from was laid out. - pub fn normalizeArtifactRefSite(bytes: []u8, site: usize, form: CodeRefForm, veneer: ?usize) void { + pub fn normalizeExcerptRefSite(bytes: []u8, site: usize, form: CodeRefForm, veneer: ?usize) void { if (comptime target.toCpuArch() == .aarch64) { const EmitT = aarch64.Emit(target); switch (form) { @@ -23411,7 +23411,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { for (words, 0..) |word, i| std.mem.writeInt(u32, bytes[loc + i * 4 ..][0..4], word, .little); } - pub fn normalizeArtifactCode(self: *const Self, start: usize, bytes: []u8) void { + pub fn normalizeExcerptCode(self: *const Self, start: usize, bytes: []u8) void { if (comptime target.toCpuArch() == .aarch64) { for (self.codegen.extern_stubs.items) |stub| { if (stub.call < start or stub.call >= start + bytes.len) continue; @@ -24320,9 +24320,9 @@ test "Boxy dictionary thunks are emitted only for producer-named workers" { try std.testing.expect(!codegen.boxy_dict_thunks.contains(@backingInt(ordinary_proc))); } -test "Boxy thunk artifact splice preserves worker namespace in both orders and across packs" { +test "Boxy thunk excerpt splice preserves worker namespace in both orders and across packs" { if (comptime builtin.cpu.arch != .x86_64 and builtin.cpu.arch != .aarch64) return error.SkipZigTest; - const artifacts = @import("ProcArtifact.zig"); + const excerpts = @import("CodeExcerpt.zig"); const allocator = std.testing.allocator; var store = LirStore.init(allocator); defer store.deinit(); @@ -24344,18 +24344,18 @@ test "Boxy thunk artifact splice preserves worker namespace in both orders and a defer producer.deinit(); try producer.compileAllProcSpecs(store.getProcSpecs()); try producer.finishImage(); - var original = try artifacts.extract(HostLirCodeGen, allocator, &producer, store.getProcSpecs(), &test_state.layout_store, &.{}, &.{}, &.{}); + var original = try excerpts.extract(HostLirCodeGen, allocator, &producer, store.getProcSpecs(), &test_state.layout_store, &.{}, &.{}, &.{}); defer original.deinit(); - try std.testing.expectEqual(@as(usize, 2), original.artifacts.len); + try std.testing.expectEqual(@as(usize, 2), original.excerpts.len); for ([_]bool{ false, true }) |reverse| { - var set = try artifacts.combine(allocator, &.{&original}); + var set = try excerpts.combine(allocator, &.{&original}); defer set.deinit(); if (reverse) { - const entries = @constCast(set.artifacts); - std.mem.swap(artifacts.Artifact, &entries[0], &entries[1]); - for (entries) |*artifact| { - for (@constCast(artifact.refs)) |*ref| ref.target = 1 - ref.target; + const entries = @constCast(set.excerpts); + std.mem.swap(excerpts.Excerpt, &entries[0], &entries[1]); + for (entries) |*excerpt| { + for (@constCast(excerpt.refs)) |*ref| ref.target = 1 - ref.target; } } for ([_]bool{ false, true }) |declared| { @@ -24376,12 +24376,12 @@ test "Boxy thunk artifact splice preserves worker namespace in both orders and a if (declared) try procs.put(identity, worker); var placed = std.AutoHashMap(u32, usize).init(allocator); defer placed.deinit(); - var data = std.ArrayList(artifacts.DataItem).empty; + var data = std.ArrayList(excerpts.DataItem).empty; defer data.deinit(allocator); // Separate calls also prove a previously placed worker cannot hide - // its thunk, regardless of artifact ordering. - try artifacts.splice(HostLirCodeGen, allocator, &receiver, &set, &.{0}, &procs, &placed, &data); - try artifacts.splice(HostLirCodeGen, allocator, &receiver, &set, &.{1}, &procs, &placed, &data); + // its thunk, regardless of excerpt ordering. + try excerpts.splice(HostLirCodeGen, allocator, &receiver, &set, &.{0}, &procs, &placed, &data); + try excerpts.splice(HostLirCodeGen, allocator, &receiver, &set, &.{1}, &procs, &placed, &data); const worker_start = receiver.splicedProcStart(identity).?; const thunk_entry = receiver.assembledThunkEntry(identity).?; try std.testing.expect(worker_start != thunk_entry); @@ -24397,20 +24397,20 @@ test "Boxy thunk artifact splice preserves worker namespace in both orders and a } // A second pack has its own placement map, but reuses both bodies. placed.clearRetainingCapacity(); - try artifacts.splice(HostLirCodeGen, allocator, &receiver, &set, &.{ 1, 0 }, &procs, &placed, &data); + try excerpts.splice(HostLirCodeGen, allocator, &receiver, &set, &.{ 1, 0 }, &procs, &placed, &data); try std.testing.expectEqual(code_len, receiver.codegen.currentOffset()); - for (set.artifacts, 0..) |artifact, index| { - try std.testing.expectEqual(switch (artifact.kind) { + for (set.excerpts, 0..) |excerpt, index| { + try std.testing.expectEqual(switch (excerpt.kind) { .proc => worker_start, - .boxy_thunk => thunk_entry - artifact.entry, + .boxy_thunk => thunk_entry - excerpt.entry, .rc_helper, .entrypoint, .message_pool_run, .branch_island => return error.TestUnexpectedResult, }, placed.get(@intCast(index)).?); } try receiver.finishImage(); - var recaptured = try artifacts.extract(HostLirCodeGen, allocator, &receiver, store.getProcSpecs(), &test_state.layout_store, &.{}, &.{}, &.{}); + var recaptured = try excerpts.extract(HostLirCodeGen, allocator, &receiver, store.getProcSpecs(), &test_state.layout_store, &.{}, &.{}, &.{}); defer recaptured.deinit(); - try std.testing.expectEqual(@as(usize, 2), recaptured.artifacts.len); - try std.testing.expect(std.meta.activeTag(recaptured.artifacts[0].kind) != std.meta.activeTag(recaptured.artifacts[1].kind)); + try std.testing.expectEqual(@as(usize, 2), recaptured.excerpts.len); + try std.testing.expect(std.meta.activeTag(recaptured.excerpts[0].kind) != std.meta.activeTag(recaptured.excerpts[1].kind)); } } } @@ -26130,8 +26130,8 @@ test "independent fragment symbolic calls resolve after all procedures are place } } -test "AArch64 finalized artifacts preserve external calls across changed placement" { - const Artifact = @import("ProcArtifact.zig"); +test "AArch64 finalized excerpts preserve external calls across changed placement" { + const Excerpt = @import("CodeExcerpt.zig"); const allocator = std.testing.allocator; const CG = LirCodeGen(.arm64linux); var store = LirStore.init(allocator); @@ -26142,7 +26142,7 @@ test "AArch64 finalized artifacts preserve external calls across changed placeme var original = try CG.init(allocator, &store, &layouts.layout_store, .{}, &.{}, .default); defer original.deinit(); original.codegen.branch_reach_limit = 4096; - const symbol = try original.internSymbolName("artifact_external_target", .shared); + const symbol = try original.internSymbolName("excerpt_external_target", .shared); while (original.codegen.currentOffset() < 4096) try original.codegen.emit.buf.appendSlice(allocator, &.{ 0x1f, 0x20, 0x03, 0xd5 }); const call_offset = original.codegen.currentOffset(); try original.codegen.emitExternCall(symbol); @@ -26165,20 +26165,20 @@ test "AArch64 finalized artifacts preserve external calls across changed placeme }); try original.finishImage(); try std.testing.expectEqual(@as(usize, 1), original.codegen.extern_stubs.items.len); - var set = try Artifact.extract(CG, allocator, &original, &.{}, &layouts.layout_store, &.{}, &.{}, &.{}); + var set = try Excerpt.extract(CG, allocator, &original, &.{}, &layouts.layout_store, &.{}, &.{}, &.{}); defer set.deinit(); - try std.testing.expectEqual(@as(usize, 1), set.artifacts.len); - const artifact = set.artifacts[0]; - try std.testing.expectEqual(@as(usize, if (embedded) 2 else 1), artifact.relocations.len); - try std.testing.expectEqualStrings("artifact_external_target", artifact.relocations[0].name); + try std.testing.expectEqual(@as(usize, 1), set.excerpts.len); + const excerpt = set.excerpts[0]; + try std.testing.expectEqual(@as(usize, if (embedded) 2 else 1), excerpt.relocations.len); + try std.testing.expectEqualStrings("excerpt_external_target", excerpt.relocations[0].name); if (embedded) { - try std.testing.expectEqual(.page21, artifact.relocations[0].kind.data); - try std.testing.expectEqual(.pageoff12, artifact.relocations[1].kind.data); - try std.testing.expectEqualSlices(u8, original.getGeneratedCode()[call_offset..][0..4], artifact.code[call_offset..][0..4]); + try std.testing.expectEqual(.page21, excerpt.relocations[0].kind.data); + try std.testing.expectEqual(.pageoff12, excerpt.relocations[1].kind.data); + try std.testing.expectEqualSlices(u8, original.getGeneratedCode()[call_offset..][0..4], excerpt.code[call_offset..][0..4]); } else { - try std.testing.expect(artifact.relocations[0].kind == .function); - try std.testing.expectEqual(@as(u32, @intCast(call_offset)), artifact.relocations[0].offset); - try std.testing.expectEqual(@as(u32, 0x94000000), std.mem.readInt(u32, artifact.code[call_offset..][0..4], .little)); + try std.testing.expect(excerpt.relocations[0].kind == .function); + try std.testing.expectEqual(@as(u32, @intCast(call_offset)), excerpt.relocations[0].offset); + try std.testing.expectEqual(@as(u32, 0x94000000), std.mem.readInt(u32, excerpt.code[call_offset..][0..4], .little)); } var placed = try CG.init(allocator, &store, &layouts.layout_store, .{}, &.{}, .default); @@ -26186,17 +26186,17 @@ test "AArch64 finalized artifacts preserve external calls across changed placeme placed.codegen.branch_reach_limit = 4096; const prefix = @as([512]u8, @splat(0)); _ = try placed.appendAssembledRegion(&prefix, .{ .message_pool_run = 0 }, 0, null); - var helpers = Artifact.HelperKeys.init(allocator); + var helpers = Excerpt.HelperKeys.init(allocator); defer helpers.deinit(); - try Artifact.assemble(CG, allocator, &placed, &set, &.{}, &helpers); + try Excerpt.assemble(CG, allocator, &placed, &set, &.{}, &helpers); try placed.finishImage(); if (embedded) { try std.testing.expectEqual(@as(usize, 0), placed.codegen.extern_stubs.items.len); const page = placed.getRelocations()[0].linked_data; const pageoff = placed.getRelocations()[1].linked_data; - try std.testing.expectEqual(prefix.len + artifact.relocations[0].offset, page.offset); + try std.testing.expectEqual(prefix.len + excerpt.relocations[0].offset, page.offset); try std.testing.expectEqual(page.offset + 4, pageoff.offset); - try std.testing.expectEqualStrings("artifact_external_target", placed.symbolName(page.symbol)); + try std.testing.expectEqualStrings("excerpt_external_target", placed.symbolName(page.symbol)); const moved_call = prefix.len + call_offset; const instruction = std.mem.readInt(u32, placed.getGeneratedCode()[moved_call..][0..4], .little); try std.testing.expectEqual(page.offset, moved_call + (instruction & 0x03ffffff) * 4); @@ -26207,7 +26207,7 @@ test "AArch64 finalized artifacts preserve external calls across changed placeme try std.testing.expectEqual(prefix.len + call_offset, stub.call); const page = placed.getRelocations()[stub.page_relocation].linked_data; const pageoff = placed.getRelocations()[stub.page_relocation + 1].linked_data; - try std.testing.expectEqualStrings("artifact_external_target", placed.symbolName(page.symbol)); + try std.testing.expectEqualStrings("excerpt_external_target", placed.symbolName(page.symbol)); try std.testing.expectEqual(page.symbol, pageoff.symbol); try std.testing.expectEqual(page.offset + 4, pageoff.offset); const instruction = std.mem.readInt(u32, placed.getGeneratedCode()[stub.call..][0..4], .little); @@ -26270,7 +26270,7 @@ test "independent fragments record their static data use" { .linked_data => |data| { if (std.mem.eql(u8, cg.getSymbolNames()[@backingInt(data.symbol)], "fragment_static_value")) reads_value = true; }, - .linked_function, .retired, .runtime_ref => {}, + .linked_function, .retired, .runtime_number => {}, } } try std.testing.expect(reads_value); diff --git a/src/backend/dev/NativeProcCompiler.zig b/src/backend/dev/NativeProcCompiler.zig index df77d5240cb..fb8b8e03d48 100644 --- a/src/backend/dev/NativeProcCompiler.zig +++ b/src/backend/dev/NativeProcCompiler.zig @@ -1,8 +1,8 @@ -//! Shared native procedure compilation and owned, same-program artifact reuse. +//! Shared native procedure compilation and owned, same-program excerpt reuse. //! //! A reuse handle is a capability supplied by the caller: its LIR and layout //! domain must be the very same transferred program, never an independently -//! lowered program with matching identities. Artifacts outlive executable images. +//! lowered program with matching identities. Excerpts outlive executable images. //! Each wave freezes borrowed inputs until every accepted callback has drained; //! only then may the coordinator append code or destroy fragment owners. @@ -12,7 +12,7 @@ const lir = @import("lir"); const layout = @import("layout"); const roc_target = @import("roc_target"); const Emitter = @import("LirCodeGen.zig"); -const Artifact = @import("ProcArtifact.zig"); +const Excerpt = @import("CodeExcerpt.zig"); const task_waves = @import("../task_waves.zig"); const StackPlan = lir.StackPlan; const tasks = base.post_check_task_executor; @@ -63,13 +63,13 @@ pub const Procedure = struct { id: ProcId, identity: lir.ProcIdentity, revision: u64, - fragment: Artifact.Fragment, + fragment: Excerpt.Fragment, }; /// An owned explicit LIR reference-count helper fragment. pub const Helper = struct { key: u64, - fragment: Artifact.Fragment, + fragment: Excerpt.Fragment, }; /// Owns reusable fragments independently of workers and executable images. @@ -81,8 +81,8 @@ pub const Retained = struct { /// Final image/object linking consumes carried data separately from code. /// The returned slice is caller-owned; its fields borrow this retained owner. - pub fn dataItems(self: *const Retained, allocator: Allocator) Allocator.Error![]const Artifact.DataItem { - var items: std.ArrayList(Artifact.DataItem) = .empty; + pub fn dataItems(self: *const Retained, allocator: Allocator) Allocator.Error![]const Excerpt.DataItem { + var items: std.ArrayList(Excerpt.DataItem) = .empty; errdefer items.deinit(allocator); var names = std.StringHashMap(void).init(allocator); defer names.deinit(); @@ -91,9 +91,9 @@ pub const Retained = struct { return items.toOwnedSlice(allocator); } - fn collectData(allocator: Allocator, fragment: *const Artifact.Fragment, items: *std.ArrayList(Artifact.DataItem), names: *std.StringHashMap(void)) Allocator.Error!void { - for (fragment.set.artifacts) |artifact| { - for (artifact.data) |item| { + fn collectData(allocator: Allocator, fragment: *const Excerpt.Fragment, items: *std.ArrayList(Excerpt.DataItem), names: *std.StringHashMap(void)) Allocator.Error!void { + for (fragment.set.excerpts) |excerpt| { + for (excerpt.data) |item| { const entry = try names.getOrPut(item.name); if (!entry.found_existing) try items.append(allocator, item); } @@ -110,7 +110,7 @@ pub const Retained = struct { } }; -fn invariant(err: (Artifact.ExtractError || Artifact.AssembleError)) Allocator.Error { +fn invariant(err: (Excerpt.ExtractError || Excerpt.AssembleError)) Allocator.Error { return switch (err) { error.OutOfMemory => error.OutOfMemory, else => base.invariant("native fragment compiler invariant: {s}", .{@errorName(err)}), @@ -139,10 +139,10 @@ fn reusable(source: Emitter.FragmentContract, target: Emitter.FragmentContract, fn Job(comptime CG: type) type { return struct { source: *const CG, - data: *const Artifact.PreparedData, + data: *const Excerpt.PreparedData, proc: ?ProcId = null, helper: u64 = 0, - fragment: ?Artifact.Fragment = null, + fragment: ?Excerpt.Fragment = null, oom: bool = false, reused: bool = false, @@ -160,7 +160,7 @@ fn Job(comptime CG: type) type { return self; } - fn compile(self: *@This(), worker: tasks.Worker) Artifact.ExtractError!void { + fn compile(self: *@This(), worker: tasks.Worker) Excerpt.ExtractError!void { const source = self.source; var cg = try CG.initWithBoxyMetadata( worker.scratch, @@ -179,9 +179,9 @@ fn Job(comptime CG: type) type { cg.enable_default_platform_runtime = source.enable_default_platform_runtime; cg.borrowStaticDataSymbolsFrom(source); self.fragment = if (self.proc) |id| - try Artifact.compileProcFragmentPrepared(CG, worker.allocator, &cg, id, source.store.getProcSpecs(), source.layout_store, self.data) + try Excerpt.compileProcFragmentPrepared(CG, worker.allocator, &cg, id, source.store.getProcSpecs(), source.layout_store, self.data) else - try Artifact.compileRcHelperFragmentPrepared(CG, worker.allocator, &cg, self.helper, source.store.getProcSpecs(), source.layout_store, self.data); + try Excerpt.compileRcHelperFragmentPrepared(CG, worker.allocator, &cg, self.helper, source.store.getProcSpecs(), source.layout_store, self.data); } }; } @@ -221,16 +221,16 @@ fn executeWave(comptime CG: type, allocator: Allocator, jobs: []Job(CG), executo return task_waves.execute(Job(CG), allocator, jobs, executor, sequential_lane, sequential_scratch, &admission); } -fn requireHelpers(allocator: Allocator, fragment: *const Artifact.Fragment, queue: *std.ArrayList(u64), seen: *std.AutoHashMap(u64, void)) Allocator.Error!void { +fn requireHelpers(allocator: Allocator, fragment: *const Excerpt.Fragment, queue: *std.ArrayList(u64), seen: *std.AutoHashMap(u64, void)) Allocator.Error!void { for (fragment.required_helpers) |key| { const entry = try seen.getOrPut(key); if (!entry.found_existing) try queue.append(allocator, key); } } -fn account(metrics: *Metrics, fragment: *const Artifact.Fragment, reused: bool, helper: bool) void { +fn account(metrics: *Metrics, fragment: *const Excerpt.Fragment, reused: bool, helper: bool) void { var bytes: u64 = 0; - for (fragment.set.artifacts) |artifact| bytes +|= artifact.code.len; + for (fragment.set.excerpts) |excerpt| bytes +|= excerpt.code.len; if (reused) { metrics.code_bytes_reused +|= bytes; if (helper) metrics.helpers_reused +|= 1 else metrics.procedures_reused +|= 1; @@ -262,7 +262,7 @@ pub fn run(comptime CG: type, allocator: Allocator, destination: *CG, demand: [] // Index immutable backing once; workers capture only reached data. Execution // callers leave mutable static roots external, never copied placeholder values. std.debug.assert(contract.static_data_readonly or options.constant_exports.len == 0); - var data = Artifact.PreparedData.init(allocator, destination.static_strings.exports, options.constant_exports, &.{}) catch |err| return invariant(err); + var data = Excerpt.PreparedData.init(allocator, destination.static_strings.exports, options.constant_exports, &.{}) catch |err| return invariant(err); defer data.deinit(); const specs = destination.store.getProcSpecs(); var demanded = try std.DynamicBitSetUnmanaged.initEmpty(allocator, specs.len); @@ -278,7 +278,7 @@ pub fn run(comptime CG: type, allocator: Allocator, destination: *CG, demand: [] for (cache.procedures.items) |*proc| try cached_procs.putNoClobber(proc.identity, proc); for (cache.helpers.items) |*helper| try cached_helpers.putNoClobber(helper.key, helper); } - var helper_keys = Artifact.HelperKeys.init(allocator); + var helper_keys = Excerpt.HelperKeys.init(allocator); defer { var names = helper_keys.keyIterator(); while (names.next()) |name| allocator.free(name.*); @@ -324,7 +324,7 @@ pub fn run(comptime CG: type, allocator: Allocator, destination: *CG, demand: [] const fragment = &job.fragment.?; try requireHelpers(allocator, fragment, &helper_queue, &helper_seen); destination.boxy_runtime_used = destination.boxy_runtime_used or fragment.context_dependencies.boxy_runtime; - Artifact.appendPrepared(CG, allocator, destination, &fragment.set, &identities, &helper_keys) catch |err| return invariant(err); + Excerpt.appendPrepared(CG, allocator, destination, &fragment.set, &identities, &helper_keys) catch |err| return invariant(err); const id = job.proc.?; const spec = specs[@backingInt(id)]; try retained.procedures.append(allocator, .{ .id = id, .identity = spec.identity, .revision = spec.native_code_revision, .fragment = fragment.* }); @@ -366,7 +366,7 @@ pub fn run(comptime CG: type, allocator: Allocator, destination: *CG, demand: [] return err; }; destination.boxy_runtime_used = destination.boxy_runtime_used or fragment.context_dependencies.boxy_runtime; - Artifact.appendPrepared(CG, allocator, destination, &fragment.set, &identities, &helper_keys) catch |err| return invariant(err); + Excerpt.appendPrepared(CG, allocator, destination, &fragment.set, &identities, &helper_keys) catch |err| return invariant(err); try retained.helpers.append(allocator, .{ .key = job.helper, .fragment = fragment.* }); account(&metrics, fragment, job.reused, true); job.fragment = null; @@ -442,7 +442,7 @@ fn testProc(store: *lir.LirStore, value: i64) Allocator.Error!ProcId { }, .none); } -test "native driver serial reverse lanes own identical executable artifacts and reuse" { +test "native driver serial reverse lanes own identical executable excerpts and reuse" { if (comptime !Emitter.host_lir_codegen_available) return error.SkipZigTest; const a = std.testing.allocator; var store = lir.LirStore.init(a); @@ -640,7 +640,7 @@ test "native driver owns and reuses complete static helper closure" { try std.testing.expectEqual(@as(u64, 0), metrics.tasks_submitted); for (cache.?.helpers.items, retained.helpers.items) |old, new| { try std.testing.expectEqual(old.key, new.key); - try std.testing.expectEqualSlices(u8, old.fragment.set.artifacts[0].code, new.fragment.set.artifacts[0].code); + try std.testing.expectEqualSlices(u8, old.fragment.set.excerpts[0].code, new.fragment.set.excerpts[0].code); } } if (cache) |*owner| owner.deinit(); @@ -689,7 +689,7 @@ test "native driver helper waves keep the serial queue order at every lane count // region bounds mean an identical code buffer. var layout_record: std.ArrayList(u8) = .empty; defer layout_record.deinit(a); - for (retained.helpers.items) |helper| try layout_record.appendSlice(a, helper.fragment.set.artifacts[0].code); + for (retained.helpers.items) |helper| try layout_record.appendSlice(a, helper.fragment.set.excerpts[0].code); for (cg.codeRegions()) |region| try layout_record.appendSlice(a, std.mem.asBytes(&[2]usize{ region.start, region.end })); if (baseline_code) |expected| { try std.testing.expectEqual(baseline_keys.items.len, retained.helpers.items.len); diff --git a/src/backend/dev/ObjectFileCompiler.zig b/src/backend/dev/ObjectFileCompiler.zig index 3746f23027f..f22a92bed71 100644 --- a/src/backend/dev/ObjectFileCompiler.zig +++ b/src/backend/dev/ObjectFileCompiler.zig @@ -27,7 +27,7 @@ const Dwarf = @import("Dwarf.zig"); const ObjectWriter = @import("ObjectWriter.zig"); const LirCodeGenMod = @import("LirCodeGen.zig"); -const ProcArtifact = @import("ProcArtifact.zig"); +const CodeExcerpt = @import("CodeExcerpt.zig"); const NativeProcCompiler = @import("NativeProcCompiler.zig"); const Executor = @import("base").post_check_task_executor.Executor; const static_data_export = @import("StaticDataExport.zig"); @@ -62,17 +62,17 @@ pub const CompilationResult = struct { /// `roc_boxy_init_embedded`, so the link must include the boxy runtime /// object and the embedded sidecar. uses_boxy: bool = false, - /// The compiled program lifted into artifacts, when the compile ran in - /// pack mode on a target whose artifacts can be extracted. - artifacts: ?ProcArtifact.Set = null, + /// The compiled program lifted into excerpts, when the compile ran in + /// pack mode on a target whose excerpts can be extracted. + excerpts: ?CodeExcerpt.Set = null, /// The Boxy runtime table entries the object's code loads, in the /// canonical order it numbers them by; the sidecar linked beside the - /// object holds exactly these (`LirImage.buildCanonicalSidecarBlob`). + /// object holds exactly these (`LirImage.buildArrangedSidecarBlob`). runtime_tables: lir.RuntimeTables.Order = .{}, pub fn deinit(self: *CompilationResult) void { self.allocator.free(self.object_bytes); - if (self.artifacts) |*set| set.deinit(); + if (self.excerpts) |*set| set.deinit(); self.runtime_tables.deinit(self.allocator); } }; @@ -97,26 +97,26 @@ pub const ObjectFileCompiler = struct { erased_capture_prefix: u32 = 0, timing: ?*Timing = null, post_check_executor: ?Executor = null, - /// Borrowed artifacts from this exact LIR program, never an executable image. + /// Borrowed excerpts from this exact LIR program, never an executable image. reuse_same_program: ?*const NativeProcCompiler.Retained = null, /// Emit every procedure as a global symbol and emit the object even /// without host entrypoints: the object is a module pack, not an app. pack_mode: bool = false, - /// Where the artifacts of external (object-cache) procedures come from. + /// Where the excerpts of external (object-cache) procedures come from. splice_source: ?SpliceSource = null, - /// Lift the compiled program into artifacts and keep them in - /// `captured_artifacts` after `compileToObjectFileAndWrite`, so the + /// Lift the compiled program into excerpts and keep them in + /// `captured_excerpts` after `compileToObjectFileAndWrite`, so the /// program's own pack can be written from the build that produced it. - capture_artifacts: bool = false, - captured_artifacts: ?ProcArtifact.Set = null, + capture_excerpts: bool = false, + captured_excerpts: ?CodeExcerpt.Set = null, /// Encode the object file and its debug sections. A pack written only - /// as artifacts leaves this off, and its result's `object_bytes` is + /// as excerpts leaves this off, and its result's `object_bytes` is /// empty. emit_object: bool = true, pub const TimingSnapshot = struct { backend_setup_ns: u64 = 0, - /// Native artifact emission, including its RC-helper closure. + /// Native excerpt emission, including its RC-helper closure. procedure_instructions_ns: u64 = 0, /// Kept for snapshot consumers; helpers are timed with procedures. rc_helper_instructions_ns: u64 = 0, @@ -198,7 +198,7 @@ pub const ObjectFileCompiler = struct { boxy_worker_procs: []const lir.LIR.LirProcSpecId, target: RocTarget, ) CompilationError!CompilationResult { - return crossCompileDispatch(self.allocator, lir_store, layout_store, entrypoints, static_data_exports, proc_specs, erased_arg_desc_offsets, erased_arg_desc_params, boxy_worker_procs, target, self.enable_default_platform_runtime, self.erased_capture_prefix, self.timing, self.pack_mode, self.splice_source, self.capture_artifacts, self.emit_object, self.post_check_executor, self.reuse_same_program); + return crossCompileDispatch(self.allocator, lir_store, layout_store, entrypoints, static_data_exports, proc_specs, erased_arg_desc_offsets, erased_arg_desc_params, boxy_worker_procs, target, self.enable_default_platform_runtime, self.erased_capture_prefix, self.timing, self.pack_mode, self.splice_source, self.capture_excerpts, self.emit_object, self.post_check_executor, self.reuse_same_program); } /// What a written object needs linked beside it. @@ -239,10 +239,10 @@ pub const ObjectFileCompiler = struct { target, ); defer result.deinit(); - if (self.capture_artifacts) { - if (self.captured_artifacts) |*previous| previous.deinit(); - self.captured_artifacts = result.artifacts; - result.artifacts = null; + if (self.capture_excerpts) { + if (self.captured_excerpts) |*previous| previous.deinit(); + self.captured_excerpts = result.excerpts; + result.excerpts = null; } // Write to file. Use the AV-safe wrapper so a transient AccessDenied @@ -328,7 +328,7 @@ fn compileWithCodeGen( timing: ?*ObjectFileCompiler.Timing, pack_mode: bool, splice_source: ?SpliceSource, - capture_artifacts: bool, + capture_excerpts: bool, emit_object: bool, executor: ?Executor, reuse_same_program: ?*const NativeProcCompiler.Retained, @@ -371,7 +371,7 @@ fn compileWithCodeGen( // Native emission includes the transitive RC-helper closure. Do not time // or compile static-export helpers a second time. const procedure_instructions_started_ns = if (timing) |timings| timings.start() else 0; - var spliced_data = std.ArrayList(ProcArtifact.DataItem).empty; + var spliced_data = std.ArrayList(CodeExcerpt.DataItem).empty; defer spliced_data.deinit(allocator); if (splice_source) |source| { var external_procs = std.ArrayList(lir.LIR.LirProcSpecId).empty; @@ -396,7 +396,7 @@ fn compileWithCodeGen( .static_helpers = static_rc_helpers, .constant_exports = static_data_exports, }) catch return CompilationError.OutOfMemory; - // Artifact data remains borrowed by the destination until object encoding + // Excerpt data remains borrowed by the destination until object encoding // and optional pack capture have finished. defer retained.deinit(); const native_data = retained.dataItems(allocator) catch return CompilationError.OutOfMemory; @@ -408,19 +408,19 @@ fn compileWithCodeGen( // Lay the program's code out canonically, so that whether each procedure // was compiled now or spliced from an object-cache entry, and in which // order either happened, changes nothing in the object (design.md - // "Canonical Dev Images"). The code generated so far is lifted into - // artifacts and placed again, in canonical order and under canonical + // "Deterministic Dev Images"). The code generated so far is lifted into + // excerpts and placed again, in arranged order and under canonical // names, into the code generator the object is encoded from. var symbol_relocations_started_ns = if (timing) |timings| timings.start() else 0; codegen.finishImage() catch return CompilationError.OutOfMemory; - var catalog = ProcArtifact.PreparedData.init(allocator, static_strings.exports, static_data_exports, spliced_data.items) catch return CompilationError.OutOfMemory; + var catalog = CodeExcerpt.PreparedData.init(allocator, static_strings.exports, static_data_exports, spliced_data.items) catch return CompilationError.OutOfMemory; defer catalog.deinit(); - var staged = ProcArtifact.extractCataloged(CodeGen, allocator, &codegen, proc_specs, layout_store, &catalog) catch |err| switch (err) { + var staged = CodeExcerpt.extractCataloged(CodeGen, allocator, &codegen, proc_specs, layout_store, &catalog) catch |err| switch (err) { error.OutOfMemory => return CompilationError.OutOfMemory, - error.NestedCodeRegion, error.UncoveredCode, error.DanglingReference, error.UnsupportedRelocation => invariant("canonical image extraction failed: {s}", .{@errorName(err)}), + error.NestedCodeRegion, error.UncoveredCode, error.DanglingReference, error.UnsupportedRelocation => invariant("arranged image extraction failed: {s}", .{@errorName(err)}), }; defer staged.deinit(); - var host_visible = std.ArrayList(ProcArtifact.DataItem).empty; + var host_visible = std.ArrayList(CodeExcerpt.DataItem).empty; defer host_visible.deinit(allocator); { var roots = std.ArrayList([]const u8).empty; @@ -428,12 +428,12 @@ fn compileWithCodeGen( for (static_data_exports) |data_export| { if (data_export.is_exported) roots.append(allocator, data_export.symbol_name) catch return CompilationError.OutOfMemory; } - ProcArtifact.dataClosure(allocator, &catalog, roots.items, &host_visible) catch |err| switch (err) { + CodeExcerpt.dataClosure(allocator, &catalog, roots.items, &host_visible) catch |err| switch (err) { error.OutOfMemory => return CompilationError.OutOfMemory, error.DanglingReference => invariant("a host-visible constant names a constant the program does not define", .{}), }; } - // Each refcount helper static data names, by the artifact name of its + // Each refcount helper static data names, by the excerpt name of its // code, and the global name static data relocates to it by. var static_helper_names = std.StringHashMap(layout.RcHelperKey).init(allocator); defer { @@ -467,7 +467,7 @@ fn compileWithCodeGen( // A linked program's image holds the code its roots reach, however its // procedures were provided; a pack program's holds every procedure it // offers. - var reachable: ?ProcArtifact.Set = null; + var reachable: ?CodeExcerpt.Set = null; defer if (reachable) |*retained_set| retained_set.deinit(); if (!pack_mode) { const root_procs = allocator.alloc(lir.ProcIdentity, entrypoints.len) catch return CompilationError.OutOfMemory; @@ -476,14 +476,14 @@ fn compileWithCodeGen( var root_data = std.ArrayList([]const u8).empty; defer root_data.deinit(allocator); for (host_visible.items) |item| root_data.append(allocator, item.name) catch return CompilationError.OutOfMemory; - reachable = ProcArtifact.retainReachable(allocator, &staged, &catalog, .{ + reachable = CodeExcerpt.retainReachable(allocator, &staged, &catalog, .{ .procs = root_procs, .data = root_data.items, .helper_aliases = &helper_aliases, .all_thunks = boxy_worker_procs.len != 0, }) catch return CompilationError.OutOfMemory; } - var canonical = ProcArtifact.canonicalize(allocator, if (reachable) |*retained_set| retained_set else &staged, host_visible.items) catch return CompilationError.OutOfMemory; + var canonical = CodeExcerpt.arrange(allocator, if (reachable) |*retained_set| retained_set else &staged, host_visible.items) catch return CompilationError.OutOfMemory; defer canonical.deinit(); var final = CodeGen.initWithBoxyMetadata( @@ -508,7 +508,7 @@ fn compileWithCodeGen( if (proc.is_static_initializer) continue; procs_by_identity.put(proc.identity, @fromBackingInt(@intCast(@as(u32, @intCast(index))))) catch return CompilationError.OutOfMemory; } - var helper_keys = ProcArtifact.HelperKeys.init(allocator); + var helper_keys = CodeExcerpt.HelperKeys.init(allocator); defer { var names = helper_keys.keyIterator(); while (names.next()) |name| allocator.free(name.*); @@ -530,13 +530,13 @@ fn compileWithCodeGen( gop.value_ptr.* = helper.key; } } - const starts = ProcArtifact.assembleCanonical(CodeGen, allocator, &final, &canonical.set, &procs_by_identity, &helper_keys) catch return CompilationError.OutOfMemory; + const starts = CodeExcerpt.assembleArranged(CodeGen, allocator, &final, &canonical.set, &procs_by_identity, &helper_keys) catch return CompilationError.OutOfMemory; defer allocator.free(starts); // The placed code loads the Boxy runtime's table numbers in canonical // order; number them by it, so they depend on nothing but that code. var runtime_numbering: lir.RuntimeTables.Numbering = .{}; defer runtime_numbering.deinit(allocator); - final.renumberRuntimeRefs(&runtime_numbering) catch return CompilationError.OutOfMemory; + final.renumberRuntimeNumbers(&runtime_numbering) catch return CompilationError.OutOfMemory; var symbols = std.ArrayList(SymbolDefinition).empty; defer symbols.deinit(allocator); @@ -597,9 +597,9 @@ fn compileWithCodeGen( { var seen_proc_symbol_names = std.StringHashMap(void).init(allocator); defer seen_proc_symbol_names.deinit(); - for (canonical.set.artifacts, starts) |artifact, start| switch (artifact.kind) { + for (canonical.set.excerpts, starts) |excerpt, start| switch (excerpt.kind) { .proc => |identity| { - const frame = artifact.frame orelse invariant("a procedure artifact had no frame", .{}); + const frame = excerpt.frame orelse invariant("a procedure excerpt had no frame", .{}); const symbol_name = static_data_export.procSymbolName(allocator, identity) catch return CompilationError.OutOfMemory; owned_proc_symbol_names.append(allocator, symbol_name) catch { allocator.free(symbol_name); @@ -611,7 +611,7 @@ fn compileWithCodeGen( appendDefinition(allocator, &final.codegen.symbols, &symbols, .{ .name = symbol_name, .offset = start, - .size = artifact.code.len, + .size = excerpt.code.len, .is_global = pack_mode, .is_function = true, .is_external = false, @@ -626,8 +626,8 @@ fn compileWithCodeGen( dwarf_procs.append(allocator, .{ .name = symbol_name, .code_start = start, - .code_size = artifact.code.len, - .loc = artifact.loc, + .code_size = excerpt.code.len, + .loc = excerpt.loc, }) catch return CompilationError.OutOfMemory; }, .rc_helper => |name| { @@ -635,7 +635,7 @@ fn compileWithCodeGen( // local symbol and a debug entry of its own, so a frame // inside one is named rather than attributed to whichever // procedure precedes it. - const frame = artifact.frame orelse continue; + const frame = excerpt.frame orelse continue; const static_helper = static_helper_names.get(name); const symbol_name = if (static_helper) |helper_key| static_data_export.atomicRcHelperSymbolName(allocator, layout_store, helper_key) catch return CompilationError.OutOfMemory @@ -647,8 +647,8 @@ fn compileWithCodeGen( }; appendDefinition(allocator, &final.codegen.symbols, &symbols, .{ .name = symbol_name, - .offset = start + artifact.entry, - .size = artifact.code.len - artifact.entry, + .offset = start + excerpt.entry, + .size = excerpt.code.len - excerpt.entry, .is_global = static_helper != null, .is_function = true, .is_external = false, @@ -663,8 +663,8 @@ fn compileWithCodeGen( }) catch return CompilationError.OutOfMemory; if (static_helper == null) dwarf_procs.append(allocator, .{ .name = symbol_name, - .code_start = start + artifact.entry, - .code_size = artifact.code.len - artifact.entry, + .code_start = start + excerpt.entry, + .code_size = excerpt.code.len - excerpt.entry, .loc = .none, }) catch return CompilationError.OutOfMemory; }, @@ -707,9 +707,9 @@ fn compileWithCodeGen( // Get generated code and relocations symbol_relocations_started_ns = if (timing) |timings| timings.start() else 0; final.finishImage() catch return CompilationError.OutOfMemory; - // `ROC_DEV_ARTIFACT_ROUNDTRIP` makes every object compile also assemble the - // program from its own procedure artifacts and panic if the result differs. - if (dev_diagnostics.isSet(.dev_artifact_roundtrip)) { + // `ROC_DEV_EXCERPT_ROUNDTRIP` makes every object compile also assemble the + // program from its own procedure excerpts and panic if the result differs. + if (dev_diagnostics.isSet(.dev_excerpt_roundtrip)) { var fresh = CodeGen.initWithBoxyMetadata( allocator, lir_store, @@ -725,7 +725,7 @@ fn compileWithCodeGen( fresh.generation_mode = .object_file; fresh.setStaticDataSymbols(static_data_exports) catch return CompilationError.OutOfMemory; fresh.enable_default_platform_runtime = enable_default_platform_runtime; - ProcArtifact.verifyRoundTrip(CodeGen, allocator, &final, &fresh, proc_specs, layout_store, static_strings.exports) catch |err| switch (err) { + CodeExcerpt.verifyRoundTrip(CodeGen, allocator, &final, &fresh, proc_specs, layout_store, static_strings.exports) catch |err| switch (err) { error.OutOfMemory => return CompilationError.OutOfMemory, error.NestedCodeRegion, error.UncoveredCode, @@ -734,7 +734,7 @@ fn compileWithCodeGen( error.UnknownProcIdentity, error.UnknownRcHelper, error.RoundTripMismatch, - => invariant("dev artifact round trip failed: {s}", .{@errorName(err)}), + => invariant("dev excerpt round trip failed: {s}", .{@errorName(err)}), }; } const code = final.getGeneratedCode(); @@ -750,14 +750,14 @@ fn compileWithCodeGen( &.{}; errdefer allocator.free(object_bytes); - var artifacts: ?ProcArtifact.Set = null; - if (pack_mode or capture_artifacts) { - const canonical_data = allocator.alloc(ProcArtifact.DataItem, canonical.data.len) catch return CompilationError.OutOfMemory; + var excerpts: ?CodeExcerpt.Set = null; + if (pack_mode or capture_excerpts) { + const canonical_data = allocator.alloc(CodeExcerpt.DataItem, canonical.data.len) catch return CompilationError.OutOfMemory; defer allocator.free(canonical_data); for (canonical.data, canonical_data) |datum, *item| item.* = datum.item; - artifacts = ProcArtifact.extract(CodeGen, allocator, &final, proc_specs, layout_store, &.{}, &.{}, canonical_data) catch |err| switch (err) { + excerpts = CodeExcerpt.extract(CodeGen, allocator, &final, proc_specs, layout_store, &.{}, &.{}, canonical_data) catch |err| switch (err) { error.OutOfMemory => return CompilationError.OutOfMemory, - error.NestedCodeRegion, error.UncoveredCode, error.DanglingReference, error.UnsupportedRelocation => invariant("pack artifact extraction failed: {s}", .{@errorName(err)}), + error.NestedCodeRegion, error.UncoveredCode, error.DanglingReference, error.UnsupportedRelocation => invariant("pack excerpt extraction failed: {s}", .{@errorName(err)}), }; } @@ -765,7 +765,7 @@ fn compileWithCodeGen( .object_bytes = object_bytes, .allocator = allocator, .uses_boxy = final.boxy_runtime_used, - .artifacts = artifacts, + .excerpts = excerpts, .runtime_tables = runtime_numbering.takeOrder(), }; } @@ -842,23 +842,23 @@ const SymbolDefinition = struct { symbol: ObjectWriter.Symbol, }; -/// An artifact in a loaded pack. -pub const LocatedArtifact = struct { - set: *const ProcArtifact.Set, +/// An excerpt in a loaded pack. +pub const LocatedExcerpt = struct { + set: *const CodeExcerpt.Set, index: u32, }; -/// The object cache's artifacts, asked by the key an entry was served under +/// The object cache's excerpts, asked by the key an entry was served under /// (`LIR.LirProcSpec.cache_key`). The consumer that loaded the packs /// supplies the context and the lookup. pub const SpliceSource = struct { context: *anyopaque, - find: *const fn (context: *anyopaque, key: [32]u8) ?LocatedArtifact, + find: *const fn (context: *anyopaque, key: [32]u8) ?LocatedExcerpt, }; /// Place the object-cache entry of each of `external_procs`, with its /// closure, into the code generator before the program's own procedures -/// compile. Each pack's artifacts are placed once, in the given order. The +/// compile. Each pack's excerpts are placed once, in the given order. The /// data items the entries carry are appended to `data_out` for the caller /// to define. pub fn spliceExternalProcs( @@ -868,7 +868,7 @@ pub fn spliceExternalProcs( proc_specs: []const LirProcSpec, external_procs: []const lir.LIR.LirProcSpecId, source: SpliceSource, - data_out: *std.ArrayList(ProcArtifact.DataItem), + data_out: *std.ArrayList(CodeExcerpt.DataItem), ) Allocator.Error!void { var procs_by_identity = std.AutoHashMap(lir.ProcIdentity, lir.LIR.LirProcSpecId).init(allocator); defer procs_by_identity.deinit(); @@ -878,7 +878,7 @@ pub fn spliceExternalProcs( } const PackState = struct { placed: std.AutoHashMap(u32, usize) }; - var packs = std.AutoHashMap(*const ProcArtifact.Set, PackState).init(allocator); + var packs = std.AutoHashMap(*const CodeExcerpt.Set, PackState).init(allocator); defer { var states = packs.valueIterator(); while (states.next()) |state| state.placed.deinit(); @@ -890,10 +890,10 @@ pub fn spliceExternalProcs( if (!proc.external) invariant("procedure {d} was offered for splicing but is not an object-cache entry", .{@backingInt(proc_id)}); const key = proc.cache_key orelse invariant("object-cache procedure {d} had no cache key", .{@backingInt(proc_id)}); const located = source.find(source.context, key) orelse - invariant("object cache served a specialization whose artifact {s} is not in any loaded pack", .{&proc.identity.symbolHex()}); + invariant("object cache served a specialization whose excerpt {s} is not in any loaded pack", .{&proc.identity.symbolHex()}); const gop = try packs.getOrPut(located.set); if (!gop.found_existing) gop.value_ptr.* = .{ .placed = std.AutoHashMap(u32, usize).init(allocator) }; - try ProcArtifact.splice(CodeGen, allocator, codegen, located.set, &.{located.index}, &procs_by_identity, &gop.value_ptr.placed, data_out); + try CodeExcerpt.splice(CodeGen, allocator, codegen, located.set, &.{located.index}, &procs_by_identity, &gop.value_ptr.placed, data_out); } } @@ -936,7 +936,7 @@ fn resolveObjectSymbols( .linked_function => |function| { symbols[@backingInt(function.symbol)].is_function = true; }, - .linked_data, .local_data, .retired, .runtime_ref => {}, + .linked_data, .local_data, .retired, .runtime_number => {}, }; for (definitions) |definition| { const symbol = &symbols[@backingInt(definition.id)]; @@ -1151,7 +1151,7 @@ fn crossCompileDispatch( timing: ?*ObjectFileCompiler.Timing, pack_mode: bool, splice_source: ?SpliceSource, - capture_artifacts: bool, + capture_excerpts: bool, emit_object: bool, executor: ?Executor, reuse_same_program: ?*const NativeProcCompiler.Retained, @@ -1181,7 +1181,7 @@ fn crossCompileDispatch( timing, pack_mode, splice_source, - capture_artifacts, + capture_excerpts, emit_object, executor, reuse_same_program, @@ -1252,8 +1252,8 @@ test "ObjectFileCompiler native emission skips static initializers and captures defer result.deinit(); try std.testing.expect(std.mem.startsWith(u8, result.object_bytes, "\x7fELF")); try std.testing.expect(!result.uses_boxy); - try std.testing.expect(result.artifacts != null); - try std.testing.expect(result.artifacts.?.artifacts.len > 0); + try std.testing.expect(result.excerpts != null); + try std.testing.expect(result.excerpts.?.excerpts.len > 0); } const snapshot = timing.snapshot(); try std.testing.expectEqual(@as(u64, targets.len), snapshot.native_emission.procedures_emitted); @@ -1333,28 +1333,28 @@ test "ObjectFileCompiler runtime static-root pack owns only reachable canonical // No source descriptor, backing, symbol names, LIR or layout owners remain. const bytes = serialized: { defer result.deinit(); - break :serialized try PackFile.write(allocator, &result.artifacts.?, &.{}); + break :serialized try PackFile.write(allocator, &result.excerpts.?, &.{}); }; defer allocator.free(bytes); var pack = try PackFile.read(allocator, bytes); defer pack.deinit(); - try std.testing.expectEqual(@as(usize, 1), pack.set.artifacts.len); - const artifact = pack.set.artifacts[0]; - try std.testing.expectEqual(@as(usize, 2), artifact.data.len); - for (artifact.data) |item| { - try std.testing.expect(std.mem.startsWith(u8, item.name, ProcArtifact.content_data_prefix)); + try std.testing.expectEqual(@as(usize, 1), pack.set.excerpts.len); + const excerpt = pack.set.excerpts[0]; + try std.testing.expectEqual(@as(usize, 2), excerpt.data.len); + for (excerpt.data) |item| { + try std.testing.expect(std.mem.startsWith(u8, item.name, CodeExcerpt.content_data_prefix)); try std.testing.expect(!std.mem.eql(u8, item.name, "roc__d7")); try std.testing.expect(!std.mem.eql(u8, item.name, "roc__d7_1")); for (item.relocations) |relocation| { try std.testing.expect(!relocation.external); - try std.testing.expect(std.mem.startsWith(u8, relocation.name, ProcArtifact.content_data_prefix)); + try std.testing.expect(std.mem.startsWith(u8, relocation.name, CodeExcerpt.content_data_prefix)); } } var data_references: usize = 0; - for (artifact.relocations) |relocation| { + for (excerpt.relocations) |relocation| { if (relocation.kind != .data) continue; data_references += 1; - try std.testing.expect(std.mem.startsWith(u8, relocation.name, ProcArtifact.content_data_prefix)); + try std.testing.expect(std.mem.startsWith(u8, relocation.name, CodeExcerpt.content_data_prefix)); } try std.testing.expect(data_references > 0); // A different consumer has neither the procedure ordinal nor any static table. @@ -1374,9 +1374,9 @@ test "ObjectFileCompiler runtime static-root pack owns only reachable canonical defer procs.deinit(); var placed = std.AutoHashMap(u32, usize).init(allocator); defer placed.deinit(); - var data = std.ArrayList(ProcArtifact.DataItem).empty; + var data = std.ArrayList(CodeExcerpt.DataItem).empty; defer data.deinit(allocator); - try ProcArtifact.splice(CG, allocator, &receiver, &pack.set, &.{0}, &procs, &placed, &data); + try CodeExcerpt.splice(CG, allocator, &receiver, &pack.set, &.{0}, &procs, &placed, &data); try receiver.finishImage(); if (comptime target == RocTarget.detectNative()) { var splice = @import("HostSplice.zig").HostSplice.init(allocator); @@ -1392,7 +1392,7 @@ test "ObjectFileCompiler runtime static-root pack owns only reachable canonical const saved_host = builtins.in_process_host.enter(host.getOps(), null); defer builtins.in_process_host.leave(saved_host); var output: [3]usize = @splat(0); - executable.callRocABIAt(placed.get(0).? + artifact.entry, @ptrCast(&output), null); + executable.callRocABIAt(placed.get(0).? + excerpt.entry, @ptrCast(&output), null); try std.testing.expectEqual(text.len, output[2]); const value: [*]const u8 = @ptrFromInt(output[0]); try std.testing.expectEqualStrings(text, value[0..output[2]]); diff --git a/src/backend/dev/ObjectWriter.zig b/src/backend/dev/ObjectWriter.zig index 88e1d8e4190..40727c089cb 100644 --- a/src/backend/dev/ObjectWriter.zig +++ b/src/backend/dev/ObjectWriter.zig @@ -138,7 +138,7 @@ pub fn generateIndexedObjectFileWithDebug( switch (rel) { .linked_function => |f| try elf.addTextRelocation(rel.getOffset(), target_indices[@backingInt(f.symbol)], f.kind), .linked_data => |d| try elf.addTextDataRelocation(rel.getOffset(), target_indices[@backingInt(d.symbol)], d.kind), - .local_data, .retired, .runtime_ref => {}, + .local_data, .retired, .runtime_number => {}, } } for (rodata_relocations) |rel| try elf.addRodataRelocation(rel.offset, target_indices[@backingInt(rel.symbol)], rel.addend); @@ -170,7 +170,7 @@ pub fn generateIndexedObjectFileWithDebug( .linked_data => |d| { referenced[@backingInt(d.symbol)] = true; }, - .local_data, .retired, .runtime_ref => {}, + .local_data, .retired, .runtime_number => {}, }; for (rodata_relocations) |rel| referenced[@backingInt(rel.symbol)] = true; @@ -190,7 +190,7 @@ pub fn generateIndexedObjectFileWithDebug( switch (rel) { .linked_function => |f| try macho.addTextRelocation(@intCast(rel.getOffset()), target_indices[@backingInt(f.symbol)], true, f.kind), .linked_data => |d| try macho.addTextDataRelocation(@intCast(rel.getOffset()), target_indices[@backingInt(d.symbol)], true, d.kind), - .local_data, .retired, .runtime_ref => {}, + .local_data, .retired, .runtime_number => {}, } } for (rodata_relocations) |rel| try macho.addRodataRelocation(@intCast(rel.offset), target_indices[@backingInt(rel.symbol)], true, rel.addend); @@ -265,7 +265,7 @@ pub fn generateIndexedObjectFileWithDebug( switch (rel) { .linked_function => |f| try coff_writer.addTextRelocation(@intCast(rel.getOffset()), target_indices[@backingInt(f.symbol)], f.kind), .linked_data => |d| try coff_writer.addTextDataRelocation(@intCast(rel.getOffset()), target_indices[@backingInt(d.symbol)], d.kind), - .local_data, .retired, .runtime_ref => {}, + .local_data, .retired, .runtime_number => {}, } } for (rodata_relocations) |rel| try coff_writer.addRdataRelocation(@intCast(rel.offset), target_indices[@backingInt(rel.symbol)], rel.addend); diff --git a/src/backend/dev/PackFile.zig b/src/backend/dev/PackFile.zig index 02a6826c040..0d7f4fcfb78 100644 --- a/src/backend/dev/PackFile.zig +++ b/src/backend/dev/PackFile.zig @@ -1,8 +1,8 @@ -//! On-disk form of one module's pack: its procedure artifacts and the table -//! from specialization key to root artifact and ownership signature. +//! On-disk form of one module's pack: its procedure excerpts and the table +//! from specialization key to root excerpt and ownership signature. //! //! The encoding is a pure function of its inputs (fixed-width little-endian -//! integers, length-prefixed bytes, artifacts in set order, specs in table +//! integers, length-prefixed bytes, excerpts in set order, specs in table //! order), so two builds of the same module write identical files and the //! store's rename-into-place protocol leaves a pack already in place alone //! (builds with runtime safety compare the bytes). @@ -10,28 +10,28 @@ //! Line entries keep their order, lines, and columns. Their file indices are //! indices into the pack's own source-file table, which names each file by //! its module's content identity in the order the lines first use it, so a -//! splicing program binds them to its own table (`ProcArtifact.splice`). +//! splicing program binds them to its own table (`CodeExcerpt.splice`). const std = @import("std"); const base = @import("base"); const collections = @import("collections"); const lir = @import("lir"); -const ProcArtifact = @import("ProcArtifact.zig"); +const CodeExcerpt = @import("CodeExcerpt.zig"); const RelocationMod = @import("Relocation.zig"); const Allocator = std.mem.Allocator; const magic = "RPCK"; -/// Format version; bump whenever the encoding or artifact contents change. +/// Format version; bump whenever the encoding or excerpt contents change. pub const format_version: u32 = 12; /// One specialization the pack can serve: its reservation-time key, the -/// artifact holding its procedure, and the ownership signature and +/// excerpt holding its procedure, and the ownership signature and /// uniqueness facts ARC solved for that procedure, which the linking program /// adopts as fixed. pub const SpecEntry = struct { key: [32]u8, - artifact: u32, + excerpt: u32, /// The compile-time roots the procedure's code reads, which a linking /// program demands (`Common.SpecCacheHit.comptime_root_reads`). comptime_root_reads: []const lir.CheckedPipeline.ComptimeRootRead = &.{}, @@ -47,7 +47,7 @@ pub const SpecEntry = struct { /// A pack read back from its bytes. pub const Pack = struct { - set: ProcArtifact.Set, + set: CodeExcerpt.Set, specs: []const SpecEntry, pub fn deinit(self: *Pack) void { @@ -61,11 +61,11 @@ pub const ReadError = Allocator.Error || error{ UnsupportedPackVersion, }; -/// Encode an artifact set and its spec table. Every carried constant the +/// Encode an excerpt set and its spec table. Every carried constant the /// set's program named for itself is written under its content name, and so /// is every relocation to it, so the pack links into any program. -pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const SpecEntry) Allocator.Error![]u8 { - var names = try ProcArtifact.ContentNames.init(allocator, set); +pub fn write(allocator: Allocator, set: *const CodeExcerpt.Set, specs: []const SpecEntry) Allocator.Error![]u8 { + var names = try CodeExcerpt.ContentNames.init(allocator, set); defer names.deinit(); var bytes = std.ArrayList(u8).empty; errdefer bytes.deinit(allocator); @@ -73,22 +73,22 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const try writer.raw(magic); try writer.word(format_version); - try writer.word(@intCast(set.artifacts.len)); + try writer.word(@intCast(set.excerpts.len)); try writer.word(@intCast(specs.len)); // The files the lines use, in first-use order, so the table depends only - // on the artifacts and not on the writing program's whole file table. + // on the excerpts and not on the writing program's whole file table. const file_map = try allocator.alloc(u32, set.source_files.len); defer allocator.free(file_map); @memset(file_map, std.math.maxInt(u32)); var used_files = std.ArrayList([32]u8).empty; defer used_files.deinit(allocator); - for (set.artifacts) |artifact| { - if (artifact.loc.hasLocation() and file_map[artifact.loc.file] == std.math.maxInt(u32)) { - file_map[artifact.loc.file] = @intCast(used_files.items.len); - try used_files.append(allocator, set.source_files[artifact.loc.file]); + for (set.excerpts) |excerpt| { + if (excerpt.loc.hasLocation() and file_map[excerpt.loc.file] == std.math.maxInt(u32)) { + file_map[excerpt.loc.file] = @intCast(used_files.items.len); + try used_files.append(allocator, set.source_files[excerpt.loc.file]); } - for (artifact.lines) |line| { + for (excerpt.lines) |line| { if (!line.loc.hasLocation() or file_map[line.loc.file] != std.math.maxInt(u32)) continue; file_map[line.loc.file] = @intCast(used_files.items.len); try used_files.append(allocator, set.source_files[line.loc.file]); @@ -97,8 +97,8 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const try writer.word(@intCast(used_files.items.len)); for (used_files.items) |identity| try writer.raw(&identity); - for (set.artifacts) |artifact| { - switch (artifact.kind) { + for (set.excerpts) |excerpt| { + switch (excerpt.kind) { .proc => |identity| { try writer.byte(0); try writer.raw(&identity.bytes); @@ -115,8 +115,8 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const .message_pool_run => try writer.byte(4), .branch_island => try writer.byte(5), } - try writer.word(artifact.entry); - if (artifact.frame) |frame| { + try writer.word(excerpt.entry); + if (excerpt.frame) |frame| { try writer.byte(1); try writer.word(frame.prologue_size); try writer.word(frame.stack_alloc); @@ -127,9 +127,9 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const } else { try writer.byte(0); } - try writer.str(artifact.code); - try writer.word(@intCast(artifact.refs.len)); - for (artifact.refs) |ref| { + try writer.str(excerpt.code); + try writer.word(@intCast(excerpt.refs.len)); + for (excerpt.refs) |ref| { try writer.word(ref.site); try writer.byte(switch (ref.form) { .call => 0, @@ -140,8 +140,8 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const try writer.word(ref.delta); try writer.word(ref.veneer orelse std.math.maxInt(u32)); } - try writer.word(@intCast(artifact.symbolic_refs.len)); - for (artifact.symbolic_refs) |ref| { + try writer.word(@intCast(excerpt.symbolic_refs.len)); + for (excerpt.symbolic_refs) |ref| { try writer.word(ref.site); try writer.byte(switch (ref.form) { .call => 0, @@ -164,21 +164,21 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const }, } } - const loc = ProcArtifact.rebindLoc(artifact.loc, file_map); + const loc = CodeExcerpt.rebindLoc(excerpt.loc, file_map); try writer.word(loc.file); try writer.word(loc.line); try writer.word(loc.column); - try writer.word(@intCast(artifact.lines.len)); - for (artifact.lines) |line| { + try writer.word(@intCast(excerpt.lines.len)); + for (excerpt.lines) |line| { try writer.word(line.offset); - try writer.word(ProcArtifact.rebindLoc(line.loc, file_map).file); + try writer.word(CodeExcerpt.rebindLoc(line.loc, file_map).file); try writer.word(line.loc.line); try writer.word(line.loc.column); try writer.word(line.region.start.offset); try writer.word(line.region.end.offset); } - try writer.word(@intCast(artifact.failure_sites.len)); - for (artifact.failure_sites) |site| { + try writer.word(@intCast(excerpt.failure_sites.len)); + for (excerpt.failure_sites) |site| { try writer.word(site.offset); switch (site.meaning) { .checked_error => try writer.byte(0), @@ -214,8 +214,8 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const }, } } - try writer.word(@intCast(artifact.relocations.len)); - for (artifact.relocations) |relocation| { + try writer.word(@intCast(excerpt.relocations.len)); + for (excerpt.relocations) |relocation| { try writer.word(relocation.offset); switch (relocation.kind) { .function => |function_kind| { @@ -229,13 +229,13 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const } try writer.str(names.of(relocation.name)); // A reference to carried program data now names it by content. - const scope: ProcArtifact.SymbolScope = if (names.renames(relocation.name)) .shared else relocation.scope; + const scope: CodeExcerpt.SymbolScope = if (names.renames(relocation.name)) .shared else relocation.scope; try writer.byte(@backingInt(scope)); } - try writer.word(@intCast(artifact.runtime_refs.len)); - for (artifact.runtime_refs) |runtime_ref| { - try writer.word(runtime_ref.site); - switch (runtime_ref.ref) { + try writer.word(@intCast(excerpt.runtime_numbers.len)); + for (excerpt.runtime_numbers) |runtime_number| { + try writer.word(runtime_number.site); + switch (runtime_number.ref) { .layout => |idx| { try writer.byte(0); try writer.word(idx); @@ -247,8 +247,8 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const }, } } - try writer.word(@intCast(artifact.data.len)); - for (artifact.data) |item| { + try writer.word(@intCast(excerpt.data.len)); + for (excerpt.data) |item| { try writer.str(names.of(item.name)); try writer.str(item.bytes); try writer.word(item.alignment); @@ -294,14 +294,14 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) const header = try reader.raw(magic.len); if (!std.mem.eql(u8, header, magic)) return error.MalformedPack; if (try reader.word() != format_version) return error.UnsupportedPackVersion; - const artifact_count = try reader.word(); + const excerpt_count = try reader.word(); const spec_count = try reader.word(); const source_files = try arena_allocator.alloc([32]u8, try reader.word()); for (source_files) |*identity| identity.* = (try reader.raw(32))[0..32].*; - const artifacts = try arena_allocator.alloc(ProcArtifact.Artifact, artifact_count); - for (artifacts) |*artifact| { - const kind: ProcArtifact.Kind = switch (try reader.byte()) { + const excerpts = try arena_allocator.alloc(CodeExcerpt.Excerpt, excerpt_count); + for (excerpts) |*excerpt| { + const kind: CodeExcerpt.Kind = switch (try reader.byte()) { 0 => .{ .proc = .{ .bytes = (try reader.raw(32))[0..32].* } }, 1 => .{ .rc_helper = try reader.str() }, 2 => .{ .boxy_thunk = .{ .bytes = (try reader.raw(32))[0..32].* } }, @@ -311,7 +311,7 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) else => return error.MalformedPack, }; const entry = try reader.word(); - const frame: ?ProcArtifact.Frame = switch (try reader.byte()) { + const frame: ?CodeExcerpt.Frame = switch (try reader.byte()) { 0 => null, 1 => .{ .prologue_size = try reader.word(), @@ -328,7 +328,7 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) else => return error.MalformedPack, }; const code = try reader.str(); - const refs = try arena_allocator.alloc(ProcArtifact.Reference, try reader.word()); + const refs = try arena_allocator.alloc(CodeExcerpt.Reference, try reader.word()); for (refs) |*ref| { ref.* = .{ .site = try reader.word(), @@ -346,7 +346,7 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) }, }; } - const symbolic_refs = try arena_allocator.alloc(ProcArtifact.SymbolicReference, try reader.word()); + const symbolic_refs = try arena_allocator.alloc(CodeExcerpt.SymbolicReference, try reader.word()); for (symbolic_refs) |*ref| { ref.* = .{ .site = try reader.word(), @@ -370,7 +370,7 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) } const loc: base.SourceLoc = .{ .file = try reader.word(), .line = try reader.word(), .column = try reader.word() }; if (loc.hasLocation() and loc.file >= source_files.len) return error.MalformedPack; - const lines = try arena_allocator.alloc(ProcArtifact.LineEntry, try reader.word()); + const lines = try arena_allocator.alloc(CodeExcerpt.LineEntry, try reader.word()); for (lines) |*line| { line.* = .{ .offset = try reader.word(), @@ -379,7 +379,7 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) }; if (line.loc.hasLocation() and line.loc.file >= source_files.len) return error.MalformedPack; } - const failure_sites = try arena_allocator.alloc(ProcArtifact.FailureSite, try reader.word()); + const failure_sites = try arena_allocator.alloc(CodeExcerpt.FailureSite, try reader.word()); for (failure_sites) |*site| { const offset = try reader.word(); const meaning: lir.LIR.FailureMeaning = switch (try reader.byte()) { @@ -409,13 +409,13 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) }; site.* = .{ .offset = offset, .meaning = meaning }; } - const relocations = try arena_allocator.alloc(ProcArtifact.NamedRelocation, try reader.word()); + const relocations = try arena_allocator.alloc(CodeExcerpt.NamedRelocation, try reader.word()); for (relocations) |*relocation| { const offset = try reader.word(); const kind_tag = try reader.byte(); const encoding_raw = try reader.byte(); const name = try reader.str(); - const scope = std.enums.fromInt(ProcArtifact.SymbolScope, try reader.byte()) orelse return error.MalformedPack; + const scope = std.enums.fromInt(CodeExcerpt.SymbolScope, try reader.byte()) orelse return error.MalformedPack; relocation.* = .{ .offset = offset, .name = name, @@ -427,22 +427,22 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) }, }; } - const runtime_refs = try arena_allocator.alloc(ProcArtifact.RuntimeReference, try reader.word()); - for (runtime_refs) |*runtime_ref| { + const runtime_numbers = try arena_allocator.alloc(CodeExcerpt.RuntimeNumberLoad, try reader.word()); + for (runtime_numbers) |*runtime_number| { const site = try reader.word(); - runtime_ref.* = .{ .site = site, .ref = switch (try reader.byte()) { + runtime_number.* = .{ .site = site, .ref = switch (try reader.byte()) { 0 => .{ .layout = try reader.word() }, 1 => .{ .erased_arg_layouts = .{ .start = try reader.word(), .len = try reader.word() } }, else => return error.MalformedPack, } }; } - const data = try arena_allocator.alloc(ProcArtifact.DataItem, try reader.word()); + const data = try arena_allocator.alloc(CodeExcerpt.DataItem, try reader.word()); for (data) |*item| { const name = try reader.str(); const item_bytes = try reader.str(); const alignment = try reader.word(); const symbol_offset = try reader.word(); - const data_relocations = try arena_allocator.alloc(ProcArtifact.DataRelocation, try reader.word()); + const data_relocations = try arena_allocator.alloc(CodeExcerpt.DataRelocation, try reader.word()); for (data_relocations) |*relocation| { const offset = try reader.word(); const addend: i64 = @bitCast(try reader.wide()); @@ -472,7 +472,7 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) .relocations = data_relocations, }; } - artifact.* = .{ + excerpt.* = .{ .kind = kind, .code = code, .entry = entry, @@ -482,30 +482,30 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) .lines = lines, .failure_sites = failure_sites, .relocations = relocations, - .runtime_refs = runtime_refs, + .runtime_numbers = runtime_numbers, .data = data, .loc = loc, }; for (refs) |ref| { - if (ref.target >= artifact_count) return error.MalformedPack; + if (ref.target >= excerpt_count) return error.MalformedPack; } } - const specs = try readSpecs(&reader, arena_allocator, spec_count, artifact_count); + const specs = try readSpecs(&reader, arena_allocator, spec_count, excerpt_count); if (reader.offset != bytes.len) return error.MalformedPack; return .{ - .set = .{ .arena = arena, .artifacts = artifacts, .source_files = source_files }, + .set = .{ .arena = arena, .excerpts = excerpts, .source_files = source_files }, .specs = specs, }; } const object_magic = "RPKO"; -/// A pack whose code is one linkable object rather than procedure artifacts: +/// A pack whose code is one linkable object rather than procedure excerpts: /// the object exports a C-ABI entry for each procedure it offers /// (`lir.ProcIdentity.writeCAbiEntryName`), and every spec entry's -/// `artifact` indexes `identities`, the procedures those entries name. +/// `excerpt` indexes `identities`, the procedures those entries name. pub const ObjectPack = struct { arena: collections.SingleThreadArena, object: []const u8, @@ -635,7 +635,7 @@ pub fn objectInputKey(bytes: []const u8) ?[32]u8 { fn writeSpecs(writer: *Writer, specs: []const SpecEntry) Allocator.Error!void { for (specs) |spec| { try writer.raw(&spec.key); - try writer.word(spec.artifact); + try writer.word(spec.excerpt); try writer.word(@intCast(spec.comptime_root_reads.len)); for (spec.comptime_root_reads) |root_read| { try writer.raw(&root_read.module); @@ -652,14 +652,14 @@ fn writeSpecs(writer: *Writer, specs: []const SpecEntry) Allocator.Error!void { } } -/// Decode `count` spec entries whose `artifact` indexes a table of -/// `artifact_count` rows. -fn readSpecs(reader: *Reader, arena_allocator: Allocator, count: u32, artifact_count: u32) ReadError![]SpecEntry { +/// Decode `count` spec entries whose `excerpt` indexes a table of +/// `excerpt_count` rows. +fn readSpecs(reader: *Reader, arena_allocator: Allocator, count: u32, excerpt_count: u32) ReadError![]SpecEntry { const specs = try arena_allocator.alloc(SpecEntry, count); for (specs) |*spec| { spec.* = .{ .key = (try reader.raw(32))[0..32].*, - .artifact = try reader.word(), + .excerpt = try reader.word(), .comptime_root_reads = reads: { const reads = try arena_allocator.alloc(lir.CheckedPipeline.ComptimeRootRead, try reader.word()); for (reads) |*root_read| root_read.* = .{ .module = (try reader.raw(32))[0..32].*, .root = try reader.word() }; @@ -684,7 +684,7 @@ fn readSpecs(reader: *Reader, arena_allocator: Allocator, count: u32, artifact_c const conditions = try arena_allocator.alloc(u32, try reader.word()); for (conditions) |*condition| condition.* = try reader.word(); spec.rc_ret_conditions = conditions; - if (spec.artifact >= artifact_count) return error.MalformedPack; + if (spec.excerpt >= excerpt_count) return error.MalformedPack; } return specs; } @@ -763,9 +763,9 @@ test "pack ownership survives input destruction and allocation failure" { } fn testOwnedPack(allocator: Allocator) (ReadError || error{TestExpectedEqual})!void { - const set = ProcArtifact.Set{ + const set = CodeExcerpt.Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{.{ + .excerpts = &.{.{ .kind = .{ .rc_helper = "helper" }, .code = "code", .entry = 0, @@ -791,23 +791,23 @@ fn testOwnedPack(allocator: Allocator) (ReadError || error{TestExpectedEqual})!v defer pack.deinit(); allocator.free(bytes); bytes_live = false; - try std.testing.expectEqualDeep(set.artifacts, pack.set.artifacts); + try std.testing.expectEqualDeep(set.excerpts, pack.set.excerpts); try std.testing.expectEqualDeep(set.source_files, pack.set.source_files); } -test "pack bytes round-trip every artifact field and spec entry" { +test "pack bytes round-trip every excerpt field and spec entry" { const testing = std.testing; var arena = collections.SingleThreadArena.init(testing.allocator); const a = arena.allocator(); - const refs = try a.dupe(ProcArtifact.Reference, &.{ + const refs = try a.dupe(CodeExcerpt.Reference, &.{ .{ .site = 5, .form = .call, .target = 1, .delta = 8 }, .{ .site = 12, .form = .addr, .target = 0, .delta = 0 }, }); - const relocations = try a.dupe(ProcArtifact.NamedRelocation, &.{ + const relocations = try a.dupe(CodeExcerpt.NamedRelocation, &.{ .{ .offset = 3, .name = try a.dupe(u8, "roc_builtins_str_concat"), .scope = .shared, .kind = .{ .function = .x86_call_rel32 } }, .{ .offset = 9, .name = try a.dupe(u8, "roc__d1"), .scope = .program, .kind = .{ .data = .rel32 } }, }); - const artifacts = try a.dupe(ProcArtifact.Artifact, &.{ + const excerpts = try a.dupe(CodeExcerpt.Excerpt, &.{ .{ .kind = .{ .proc = lir.ProcIdentity.forTest(7) }, .code = try a.dupe(u8, &.{ 0x55, 0x48, 0x89, 0xe5, 0xc3 }), @@ -824,14 +824,14 @@ test "pack bytes round-trip every artifact field and spec entry" { .{ .offset = 0, .loc = .{ .file = 3, .line = 43, .column = 9 }, .region = base.Region.zero() }, }, .relocations = relocations, - .data = try a.dupe(ProcArtifact.DataItem, &.{ + .data = try a.dupe(CodeExcerpt.DataItem, &.{ .{ .name = try a.dupe(u8, "roc__hab"), .bytes = try a.dupe(u8, "\x00\x00hi"), .alignment = 8, .symbol_offset = 2 }, .{ .name = try a.dupe(u8, "roc__hcd"), .bytes = try a.dupe(u8, &@as([16]u8, @splat('\x00'))), .alignment = 8, .symbol_offset = 0, - .relocations = try a.dupe(ProcArtifact.DataRelocation, &.{ + .relocations = try a.dupe(CodeExcerpt.DataRelocation, &.{ .{ .offset = 0, .name = try a.dupe(u8, "roc__hab"), .addend = 2, .function = false }, .{ .offset = 8, .name = try a.dupe(u8, "roc__rc_decref_abc"), .addend = -1, .function = true }, }), @@ -854,12 +854,12 @@ test "pack bytes round-trip every artifact field and spec entry" { // its lines file 3, which the pack keeps as its own two files in that // order. const source_files = try a.dupe([32]u8, &.{ @as([32]u8, @splat(0x30)), @as([32]u8, @splat(0x31)), @as([32]u8, @splat(0x32)), @as([32]u8, @splat(0x33)) }); - var set = ProcArtifact.Set{ .arena = arena, .artifacts = artifacts, .source_files = source_files }; + var set = CodeExcerpt.Set{ .arena = arena, .excerpts = excerpts, .source_files = source_files }; defer set.deinit(); const specs = [_]SpecEntry{ .{ .key = @as([32]u8, @splat(0xab)), - .artifact = 0, + .excerpt = 0, .rc_borrowed_params = 0b101, .rc_ret_borrowed = true, .rc_ret_lenders = 1, @@ -882,7 +882,7 @@ test "pack bytes round-trip every artifact field and spec entry" { var pack = try read(testing.allocator, bytes); defer pack.deinit(); - try testing.expectEqual(@as(usize, 3), pack.set.artifacts.len); + try testing.expectEqual(@as(usize, 3), pack.set.excerpts.len); try testing.expectEqual(@as(usize, 1), pack.specs.len); try testing.expectEqualSlices(u8, &specs[0].key, &pack.specs[0].key); try testing.expectEqual(specs[0].rc_borrowed_params, pack.specs[0].rc_borrowed_params); @@ -892,14 +892,14 @@ test "pack bytes round-trip every artifact field and spec entry" { try testing.expectEqual(specs[0].rc_ret_unique_fields, pack.specs[0].rc_ret_unique_fields); try testing.expectEqualSlices(u32, specs[0].rc_ret_conditions, pack.specs[0].rc_ret_conditions); try testing.expectEqualDeep(specs[0].comptime_root_reads, pack.specs[0].comptime_root_reads); - const proc = pack.set.artifacts[0]; + const proc = pack.set.excerpts[0]; try testing.expectEqualSlices(u8, &lir.ProcIdentity.forTest(7).bytes, &proc.kind.proc.bytes); - try testing.expectEqualSlices(u8, artifacts[0].code, proc.code); + try testing.expectEqualSlices(u8, excerpts[0].code, proc.code); try testing.expectEqual(@as(usize, 2), proc.refs.len); try testing.expectEqual(@as(usize, 3), proc.symbolic_refs.len); - try testing.expectEqualDeep(artifacts[0].symbolic_refs, proc.symbolic_refs); - try testing.expectEqual(artifacts[0].lines.len, proc.lines.len); - for (artifacts[0].lines, proc.lines) |written, read_back| { + try testing.expectEqualDeep(excerpts[0].symbolic_refs, proc.symbolic_refs); + try testing.expectEqual(excerpts[0].lines.len, proc.lines.len); + for (excerpts[0].lines, proc.lines) |written, read_back| { try testing.expectEqual(written.offset, read_back.offset); try testing.expectEqual(written.loc.line, read_back.loc.line); try testing.expectEqual(written.loc.column, read_back.loc.column); @@ -909,12 +909,12 @@ test "pack bytes round-trip every artifact field and spec entry" { try testing.expectEqual(@as(u32, 10), proc.loc.line); try testing.expectEqual(@as(u32, 2), proc.loc.column); try testing.expectEqualSlices(u8, &source_files[1], &pack.set.source_files[proc.loc.file]); - try testing.expect(!pack.set.artifacts[1].loc.hasLocation()); - try testing.expectEqual(ProcArtifact.Form.addr, proc.refs[1].form); + try testing.expect(!pack.set.excerpts[1].loc.hasLocation()); + try testing.expectEqual(CodeExcerpt.Form.addr, proc.refs[1].form); try testing.expectEqual(@as(u32, 9), proc.relocations[1].offset); try testing.expectEqualStrings("roc__d1", proc.relocations[1].name); - try testing.expectEqual(ProcArtifact.SymbolScope.program, proc.relocations[1].scope); - try testing.expectEqual(ProcArtifact.SymbolScope.shared, proc.relocations[0].scope); + try testing.expectEqual(CodeExcerpt.SymbolScope.program, proc.relocations[1].scope); + try testing.expectEqual(CodeExcerpt.SymbolScope.shared, proc.relocations[0].scope); try testing.expectEqual(RelocationMod.DataRelocationKind.rel32, proc.relocations[1].kind.data); try testing.expectEqual(@as(u32, 0x1000), proc.frame.?.callee_saved_mask); try testing.expectEqual(@as(usize, 2), proc.data.len); @@ -929,9 +929,9 @@ test "pack bytes round-trip every artifact field and spec entry" { try testing.expectEqualStrings("roc__rc_decref_abc", proc.data[1].relocations[1].name); try testing.expect(!proc.data[1].relocations[0].function); try testing.expectEqual(@as(i64, 2), proc.data[1].relocations[0].addend); - try testing.expectEqualStrings("roc__rc_decref_abc", pack.set.artifacts[1].kind.rc_helper); - try testing.expectEqual(@as(u32, 2), pack.set.artifacts[1].entry); - try testing.expect(pack.set.artifacts[1].frame == null); + try testing.expectEqualStrings("roc__rc_decref_abc", pack.set.excerpts[1].kind.rc_helper); + try testing.expectEqual(@as(u32, 2), pack.set.excerpts[1].entry); + try testing.expect(pack.set.excerpts[1].frame == null); const rewritten = try write(testing.allocator, &pack.set, pack.specs); defer testing.allocator.free(rewritten); @@ -943,9 +943,9 @@ test "pack bytes round-trip every artifact field and spec entry" { test "pack round-trips spec summaries and declines a previous format" { const Attempt = struct { fn run(allocator: Allocator) (ReadError || error{ TestExpectedEqual, TestExpectedError, TestUnexpectedError })!void { - const set = ProcArtifact.Set{ + const set = CodeExcerpt.Set{ .arena = collections.SingleThreadArena.init(allocator), - .artifacts = &.{.{ + .excerpts = &.{.{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "code", .entry = 0, @@ -957,7 +957,7 @@ test "pack round-trips spec summaries and declines a previous format" { }; const bytes = try write(allocator, &set, &.{.{ .key = @as([32]u8, @splat(7)), - .artifact = 0, + .excerpt = 0, .rc_borrowed_params = 1, .rc_ret_borrowed = true, .rc_ret_lenders = 1, @@ -994,9 +994,9 @@ test "pack writes program-local constants and every reference to them under cont var arena = collections.SingleThreadArena.init(testing.allocator); defer arena.deinit(); for (names, 0..) |bytes, index| { - const set = ProcArtifact.Set{ + const set = CodeExcerpt.Set{ .arena = collections.SingleThreadArena.init(testing.allocator), - .artifacts = &.{.{ + .excerpts = &.{.{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "code", .entry = 0, @@ -1027,14 +1027,14 @@ test "pack writes program-local constants and every reference to them under cont defer testing.allocator.free(encoded); var pack = try read(testing.allocator, encoded); defer pack.deinit(); - const artifact = pack.set.artifacts[0]; - const root = artifact.data[0]; - const node = artifact.data[1]; - try testing.expect(std.mem.startsWith(u8, root.name, ProcArtifact.content_data_prefix)); - try testing.expect(std.mem.startsWith(u8, node.name, ProcArtifact.content_data_prefix)); - try testing.expectEqualStrings(root.name, artifact.relocations[0].name); + const excerpt = pack.set.excerpts[0]; + const root = excerpt.data[0]; + const node = excerpt.data[1]; + try testing.expect(std.mem.startsWith(u8, root.name, CodeExcerpt.content_data_prefix)); + try testing.expect(std.mem.startsWith(u8, node.name, CodeExcerpt.content_data_prefix)); + try testing.expectEqualStrings(root.name, excerpt.relocations[0].name); // Named by content, the reference means the same in every program. - try testing.expectEqual(ProcArtifact.SymbolScope.shared, artifact.relocations[0].scope); + try testing.expectEqual(CodeExcerpt.SymbolScope.shared, excerpt.relocations[0].scope); try testing.expectEqualStrings(node.name, root.relocations[0].name); // An external binding keeps the linking image's name, even one the // set also carries a datum under. @@ -1049,7 +1049,7 @@ test "object packs round-trip their object, identities, spec table, and binding" const identities = [_]lir.ProcIdentity{ lir.ProcIdentity.forTest(1), lir.ProcIdentity.forTest(2) }; const specs = [_]SpecEntry{.{ .key = @as([32]u8, @splat(4)), - .artifact = 1, + .excerpt = 1, .rc_borrowed_params = 0b11, .rc_ret_borrowed = false, .rc_ret_lenders = 0, @@ -1072,12 +1072,12 @@ test "object packs round-trip their object, identities, spec table, and binding" try testing.expect(!pack.servesProgram(1, @splat(1))); try testing.expectEqualStrings("\x7fELF object", pack.object); try testing.expectEqualDeep(identities[0..], pack.identities); - try testing.expectEqual(@as(u32, 1), pack.specs[0].artifact); + try testing.expectEqual(@as(u32, 1), pack.specs[0].excerpt); try testing.expectEqual(specs[0].rc_borrowed_params, pack.specs[0].rc_borrowed_params); try testing.expectEqualSlices(u32, specs[0].rc_ret_conditions, pack.specs[0].rc_ret_conditions); const again = try writeObject(testing.allocator, pack.object, pack.identities, pack.specs, pack.binding); defer testing.allocator.free(again); try testing.expectEqualSlices(u8, bytes, again); - // An artifact pack is not an object pack. + // An excerpt pack is not an object pack. try testing.expectError(error.MalformedPack, readObject(testing.allocator, "RPCK")); } diff --git a/src/backend/dev/Relocation.zig b/src/backend/dev/Relocation.zig index d587f44ed36..21e0e413e50 100644 --- a/src/backend/dev/Relocation.zig +++ b/src/backend/dev/Relocation.zig @@ -25,9 +25,9 @@ pub const DataRelocationKind = enum { /// A number the Boxy runtime reads its sidecar tables by, loaded into a /// register by a fixed-width instruction sequence at the relocation's offset. -/// A canonical image renumbers what its code uses (design.md "Canonical Dev +/// An arranged image renumbers what its code uses (design.md "Deterministic Dev /// Images") and patches each load, so these are never linker relocations. -pub const RuntimeRef = union(enum) { +pub const RuntimeNumber = union(enum) { /// A layout index. layout: u32, /// The start of a run of the program's erased argument layouts. @@ -92,8 +92,8 @@ pub const IndexedRelocation = union(enum) { }, local_data: @FieldType(Relocation, "local_data"), /// A runtime table number's patchable load; `offset` is the encoding's - /// patch site (`CodeGen.patchRuntimeRef`). - runtime_ref: struct { offset: u64, ref: RuntimeRef }, + /// patch site (`CodeGen.patchRuntimeNumber`). + runtime_number: struct { offset: u64, ref: RuntimeNumber }, /// A relocation superseded by a stub that carries its own relocations. /// Consumers skip it; it keeps its slot so recorded relocation indices /// stay valid. @@ -101,14 +101,14 @@ pub const IndexedRelocation = union(enum) { pub fn getOffset(self: IndexedRelocation) u64 { return switch (self) { - inline .linked_function, .linked_data, .local_data, .runtime_ref => |r| r.offset, + inline .linked_function, .linked_data, .local_data, .runtime_number => |r| r.offset, .retired => 0, }; } pub fn adjustOffset(self: *IndexedRelocation, delta: u64) void { switch (self.*) { - inline .linked_function, .linked_data, .local_data, .runtime_ref => |*r| r.offset += delta, + inline .linked_function, .linked_data, .local_data, .runtime_number => |*r| r.offset += delta, .retired => {}, } } diff --git a/src/backend/dev/RunImage.zig b/src/backend/dev/RunImage.zig index add93f66769..1cd0f815b3d 100644 --- a/src/backend/dev/RunImage.zig +++ b/src/backend/dev/RunImage.zig @@ -253,7 +253,7 @@ pub fn writeToSharedMemory( }); }, .local_data => return error.UnsupportedDevRunRelocation, - .retired, .runtime_ref => {}, + .retired, .runtime_number => {}, } } @@ -464,7 +464,7 @@ pub fn requiredCapacityFromOffset( relocation_count = try addNoOverflow(relocation_count, 1); }, .local_data => return error.UnsupportedDevRunRelocation, - .retired, .runtime_ref => {}, + .retired, .runtime_number => {}, } } @@ -598,7 +598,7 @@ fn countReservedFunctionStubs( uses[@backingInt(data.symbol)] |= 2; }, .local_data => return error.UnsupportedDevRunRelocation, - .retired, .runtime_ref => {}, + .retired, .runtime_number => {}, }; var count: usize = 0; for (names, uses) |name, use| { diff --git a/src/backend/dev/StaticStringData.zig b/src/backend/dev/StaticStringData.zig index ed0725065a1..313feb1d39d 100644 --- a/src/backend/dev/StaticStringData.zig +++ b/src/backend/dev/StaticStringData.zig @@ -23,7 +23,7 @@ pub const Entry = struct { pub const View = struct { entries: []const Entry = &.{}, index: ?*const Index = null, - /// Borrowed immutable backing records for owned native artifact capture. + /// Borrowed immutable backing records for owned native excerpt capture. exports: []const StaticDataExport = &.{}, pub fn ordinal(self: View, id: base.StringLiteral.Idx) ?u32 { diff --git a/src/backend/dev/mod.zig b/src/backend/dev/mod.zig index afe419c3043..c4425811201 100644 --- a/src/backend/dev/mod.zig +++ b/src/backend/dev/mod.zig @@ -42,19 +42,19 @@ pub const host_lir_codegen_available = LirCodeGenMod.host_lir_codegen_available; /// Supports cross-compilation to any RocTarget. /// Only available on non-freestanding targets (uses std.fs) pub const ObjectFileCompiler = if (builtin.os.tag == .freestanding) void else @import("ObjectFileCompiler.zig").ObjectFileCompiler; -/// Per-region machine-code artifacts and their reassembly. -pub const ProcArtifact = @import("ProcArtifact.zig"); -/// Shared native procedure task driver and same-program retained artifacts. +/// Per-region machine-code excerpts and their reassembly. +pub const CodeExcerpt = @import("CodeExcerpt.zig"); +/// Shared native procedure task driver and same-program retained excerpts. pub const NativeProcCompiler = @import("NativeProcCompiler.zig"); -/// An artifact located in a loaded pack. -pub const LocatedArtifact = if (builtin.os.tag == .freestanding) void else @import("ObjectFileCompiler.zig").LocatedArtifact; +/// An excerpt located in a loaded pack. +pub const LocatedExcerpt = if (builtin.os.tag == .freestanding) void else @import("ObjectFileCompiler.zig").LocatedExcerpt; /// Where the object compiler splices object-cache procedures from. pub const SpliceSource = if (builtin.os.tag == .freestanding) void else @import("ObjectFileCompiler.zig").SpliceSource; /// Place object-cache entries into an open code generator. pub const spliceExternalProcs = if (builtin.os.tag == .freestanding) void else @import("ObjectFileCompiler.zig").spliceExternalProcs; /// Links object-cache entries spliced into the compile-time evaluator's image. pub const HostSplice = if (builtin.os.tag == .freestanding) void else @import("HostSplice.zig").HostSplice; -/// On-disk form of one module's pack of artifacts. +/// On-disk form of one module's pack of excerpts. pub const PackFile = @import("PackFile.zig"); pub const Entrypoint = if (builtin.os.tag == .freestanding) void else @import("ObjectFileCompiler.zig").Entrypoint; pub const StaticDataExport = @import("StaticDataExport.zig").StaticDataExport; diff --git a/src/backend/llvm/MonoLlvmCodeGen.zig b/src/backend/llvm/MonoLlvmCodeGen.zig index def4892601d..d1890425674 100644 --- a/src/backend/llvm/MonoLlvmCodeGen.zig +++ b/src/backend/llvm/MonoLlvmCodeGen.zig @@ -377,7 +377,7 @@ pub const MonoLlvmCodeGen = struct { /// objects, which may define the same entries and constants. object_cache_pack: bool = false, /// By static-data export row, the content name an object-cache pack's - /// object defines each export under (`ProcArtifact.ContentNames. + /// object defines each export under (`CodeExcerpt.ContentNames. /// initObjectData`); a host-visible export keeps its own name and stays /// internal to the object. pack_data_names: []const []const u8 = &.{}, diff --git a/src/base/dev_diagnostics.zig b/src/base/dev_diagnostics.zig index d7aa4bb30bb..04940298133 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -33,7 +33,7 @@ pub const Var = enum { /// Load the pack objects in the named directory into a native dev build. dev_pack_hits, /// Check every object compile against one assembled from its artifacts. - dev_artifact_roundtrip, + dev_excerpt_roundtrip, /// Append one record to the named file per in-process LLVM object compile. test_llvm_object_compile_count_file, @@ -48,7 +48,7 @@ pub const Var = enum { .ctfe_reuse_trace => "ROC_CTFE_REUSE_TRACE", .dev_pack_objects => "ROC_DEV_PACK_OBJECTS", .dev_pack_hits => "ROC_DEV_PACK_HITS", - .dev_artifact_roundtrip => "ROC_DEV_ARTIFACT_ROUNDTRIP", + .dev_excerpt_roundtrip => "ROC_DEV_EXCERPT_ROUNDTRIP", .test_llvm_object_compile_count_file => "ROC_TEST_LLVM_OBJECT_COMPILE_COUNT_FILE", }; } diff --git a/src/cli/main.zig b/src/cli/main.zig index 4885cab4655..7bb7ab381b7 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -335,7 +335,7 @@ const CompileTimeObjectCache = struct { /// The offers of this command's compile-time pack, gathered from each /// evaluation level while its code exists (`eval.CompileTimeFinalization.StageSink`); -/// the pack's code is the code evaluation ran (`CompletedStages.artifacts`). +/// the pack's code is the code evaluation ran (`CompletedStages.excerpts`). const CompileTimePackParts = struct { offers: PackOffers, /// The command the pack is published by, which its key names. @@ -6259,7 +6259,7 @@ fn writeDevRunImageToSharedMemory( const proc_specs = store.getProcSpecs(); // The object-cache entries this program was served are placed first, // with the data their code carries. - var spliced_data = std.ArrayList(backend.dev.ProcArtifact.DataItem).empty; + var spliced_data = std.ArrayList(backend.dev.CodeExcerpt.DataItem).empty; defer spliced_data.deinit(ctx.gpa); if (splice_source) |source| { var external_procs = std.ArrayList(lir.LIR.LirProcSpecId).empty; @@ -6851,7 +6851,7 @@ const RunPackageObjects = struct { object.* = pack.object; for (pack.specs) |spec| { var buffer: [lir.ProcIdentity.symbol_name_len]u8 = undefined; - const name = try arena.dupe(u8, pack.identities[spec.artifact].writeCAbiEntryName(&buffer)); + const name = try arena.dupe(u8, pack.identities[spec.excerpt].writeCAbiEntryName(&buffer)); if ((try seen.getOrPut(name)).found_existing) continue; try symbols.append(arena, name); } @@ -8340,7 +8340,7 @@ fn writePackObjects( backend.writeFileWindowsAvSafe(ctx.io.std_io, object_path, pack.compiled.object_bytes) catch { return error.NativeCompilationFailed; }; - if (pack.compiled.artifacts) |*set| { + if (pack.compiled.excerpts) |*set| { const pack_bytes = try packFileBytes(ctx.gpa, set, &pack.lowered); defer ctx.gpa.free(pack_bytes); const pack_path = try std.fmt.allocPrint(ctx.arena, "{s}.pack.{s}.rpk", .{ final_output_path, file_name }); @@ -8552,7 +8552,7 @@ fn writePackageDevPacksToStore( } var pack = try compileModulePack(ctx, build_env, root_artifact, imports, relations, artifact, roots, .dev, .lss, RocTarget.detectNative(), false); defer pack.deinit(); - const set = &pack.compiled.artifacts.?; + const set = &pack.compiled.excerpts.?; const bytes = try packFileBytes(ctx.gpa, set, &pack.lowered); defer ctx.gpa.free(bytes); // Offer before filing: a provider whose first lookup happens here @@ -8585,7 +8585,7 @@ fn writePacksToStore( app_imports: []const check.CheckedArtifact.ImportedModuleView, app_relations: []const check.CheckedArtifact.ImportedModuleView, app_lowered: *const lir.CheckedPipeline.LoweredProgram, - app_artifacts: ?*const backend.dev.ProcArtifact.Set, + app_excerpts: ?*const backend.dev.CodeExcerpt.Set, args: cli_args.BuildArgs, target: RocTarget, ) CliMainError!void { @@ -8607,7 +8607,7 @@ fn writePacksToStore( std.debug.print("compiled {x} {x:0>16} {s}\n", .{ &spec_proc.key, fingerprint, program_store.procDebugName(spec_proc.proc) orelse "" }); } } - if (app_artifacts) |set| { + if (app_excerpts) |set| { if (build_env.packPlacementForArtifactKey(root_artifact.key)) |placement| { if (!try store.has(placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes)) { const bytes = try packFileBytes(ctx.gpa, set, app_lowered); @@ -8643,7 +8643,7 @@ fn writePacksToStore( } var pack = try compileModulePack(ctx, build_env, root_artifact, app_imports, app_relations, artifact, roots, args.opt, currentRuntimeSpecializationStrategy(args.specialization_strategy), target, false); defer pack.deinit(); - const set = &(pack.compiled.artifacts orelse continue); + const set = &(pack.compiled.excerpts orelse continue); const bytes = try packFileBytes(ctx.gpa, set, &pack.lowered); defer ctx.gpa.free(bytes); store.write(origin, identity, artifact.codeGenerationKey().bytes, bytes) catch |err| { @@ -9027,7 +9027,7 @@ fn objectPackBytes( }; try specs.append(ctx.gpa, .{ .key = candidate.key, - .artifact = identity_index, + .excerpt = identity_index, .comptime_root_reads = candidate.reads.reads, .rc_borrowed_params = proc.rc_borrowed_params, .rc_ret_borrowed = proc.rc_ret_borrowed, @@ -9044,7 +9044,7 @@ fn objectPackBytes( if (specs.items.len == 0) return null; // Every constant the object carries is weak data under its content name. - var data_names = try backend.dev.ProcArtifact.ContentNames.initObjectData(ctx.gpa, static_data_exports); + var data_names = try backend.dev.CodeExcerpt.ContentNames.initObjectData(ctx.gpa, static_data_exports); defer data_names.deinit(); const pack_data_names = try arena.alloc([]const u8, static_data_exports.len); for (static_data_exports, pack_data_names) |data_export, *name| name.* = data_names.of(data_export.symbol_name); @@ -9237,7 +9237,7 @@ fn publishCompileTimePack(ctx: *CliCtx, build_env: *BuildEnv) Allocator.Error!vo // The pack holds the code evaluation ran: each stage of the evaluation is // its own program, and the pack offers the procedures of all of them. - var combined = try completed.combinedArtifacts(ctx.gpa); + var combined = try completed.combinedExcerpts(ctx.gpa); defer combined.deinit(); const bytes = try parts.offers.encode(ctx.gpa, &combined); defer ctx.gpa.free(bytes); @@ -9289,7 +9289,7 @@ fn procReachesLinkedEntry(allocator: Allocator, store: *const lir.LirStore, root /// signature ARC solved for it. fn packFileBytes( allocator: Allocator, - set: *const backend.dev.ProcArtifact.Set, + set: *const backend.dev.CodeExcerpt.Set, lowered: *const lir.CheckedPipeline.LoweredProgram, ) Allocator.Error![]u8 { const comptime_reads = if (lowered.spec_comptime_reads) |*reads| reads else { @@ -9446,18 +9446,18 @@ const PackOffers = struct { /// Encode `set`, compiled from the gathered programs, with the spec table /// of every procedure they offer. - fn encode(self: *const PackOffers, allocator: Allocator, set: *const backend.dev.ProcArtifact.Set) Allocator.Error![]u8 { + fn encode(self: *const PackOffers, allocator: Allocator, set: *const backend.dev.CodeExcerpt.Set) Allocator.Error![]u8 { var specs = std.ArrayList(backend.dev.PackFile.SpecEntry).empty; defer specs.deinit(allocator); const trace = dev_diagnostics.isSet(.pack_trace); var withheld: usize = 0; - var converting_artifacts = try literalConvertingArtifacts(allocator, set, &self.literal_converters); - defer converting_artifacts.deinit(allocator); + var converting_excerpts = try literalConvertingExcerpts(allocator, set, &self.literal_converters); + defer converting_excerpts.deinit(allocator); var closure = try SpliceClosure.init(allocator, set); defer closure.deinit(); for ([_][]const Candidate{ self.keyed.items, self.by_identity.items }, [_]bool{ false, true }) |candidates, by_identity| { for (candidates) |offer| { - const artifact = closure.offeredProcArtifact(offer.identity) orelse continue; + const excerpt = closure.offeredProcExcerpt(offer.identity) orelse continue; if (offer.withheld) { withheld += 1; continue; @@ -9466,8 +9466,8 @@ const PackOffers = struct { // and a constant holding a code pointer names code the pack // may not carry; an entry that reaches either cannot be // linked elsewhere, so it is not offered. - const placed = try closure.of(artifact); - if (closureNamesProgramLocalSymbols(set, placed) or closureConvertsLiteral(&converting_artifacts, placed)) { + const placed = try closure.of(excerpt); + if (closureNamesProgramLocalSymbols(set, placed) or closureConvertsLiteral(&converting_excerpts, placed)) { withheld += 1; continue; } @@ -9476,7 +9476,7 @@ const PackOffers = struct { }; try specs.append(allocator, .{ .key = offer.key, - .artifact = artifact, + .excerpt = excerpt, .comptime_root_reads = offer.reads.reads, .rc_borrowed_params = offer.rc_borrowed_params, .rc_ret_borrowed = offer.rc_ret_borrowed, @@ -9489,7 +9489,7 @@ const PackOffers = struct { } } if (trace) { - std.debug.print("pack: {d} artifacts, {d} specs offered, {d} withheld\n", .{ set.artifacts.len, specs.items.len, withheld }); + std.debug.print("pack: {d} excerpts, {d} specs offered, {d} withheld\n", .{ set.excerpts.len, specs.items.len, withheld }); } return backend.dev.PackFile.write(allocator, set, specs.items); } @@ -9503,37 +9503,37 @@ fn procFingerprint(allocator: Allocator, lowered: *const lir.CheckedPipeline.Low }; } -/// The artifacts a splice of one entry places (`ProcArtifact.splice`): the +/// The excerpts a splice of one entry places (`CodeExcerpt.splice`): the /// entry, and everything it reaches through set-local references and stable /// references to definitions in the same set. The set's definitions are /// indexed once; each query reuses the walk's storage. const SpliceClosure = struct { allocator: Allocator, - set: *const backend.dev.ProcArtifact.Set, - procs: std.AutoHashMap(lir.ProcIdentity, ProcArtifacts), + set: *const backend.dev.CodeExcerpt.Set, + procs: std.AutoHashMap(lir.ProcIdentity, ProcExcerpts), thunks: std.AutoHashMap(lir.ProcIdentity, u32), helpers: std.StringHashMap(u32), - /// Artifacts the current query reached; exactly the indices in `order`. + /// Excerpts the current query reached; exactly the indices in `order`. seen: std.DynamicBitSetUnmanaged, order: std.ArrayList(u32) = .empty, - /// The artifacts of one procedure identity: a splice reaches the first, + /// The excerpts of one procedure identity: a splice reaches the first, /// and a pack offers the last. - const ProcArtifacts = struct { first: u32, last: u32 }; + const ProcExcerpts = struct { first: u32, last: u32 }; - fn init(allocator: Allocator, set: *const backend.dev.ProcArtifact.Set) Allocator.Error!SpliceClosure { + fn init(allocator: Allocator, set: *const backend.dev.CodeExcerpt.Set) Allocator.Error!SpliceClosure { var self = SpliceClosure{ .allocator = allocator, .set = set, - .procs = std.AutoHashMap(lir.ProcIdentity, ProcArtifacts).init(allocator), + .procs = std.AutoHashMap(lir.ProcIdentity, ProcExcerpts).init(allocator), .thunks = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator), .helpers = std.StringHashMap(u32).init(allocator), .seen = .{}, }; errdefer self.deinit(); - self.seen = try std.DynamicBitSetUnmanaged.initEmpty(allocator, set.artifacts.len); - for (set.artifacts, 0..) |artifact, index| { - switch (artifact.kind) { + self.seen = try std.DynamicBitSetUnmanaged.initEmpty(allocator, set.excerpts.len); + for (set.excerpts, 0..) |excerpt, index| { + switch (excerpt.kind) { .proc => |identity| { const entry = try self.procs.getOrPut(identity); if (!entry.found_existing) entry.value_ptr.first = @intCast(index); @@ -9547,10 +9547,10 @@ const SpliceClosure = struct { return self; } - /// The artifact a pack offers for the procedure `identity`. - fn offeredProcArtifact(self: *const SpliceClosure, identity: lir.ProcIdentity) ?u32 { - const artifacts = self.procs.get(identity) orelse return null; - return artifacts.last; + /// The excerpt a pack offers for the procedure `identity`. + fn offeredProcExcerpt(self: *const SpliceClosure, identity: lir.ProcIdentity) ?u32 { + const excerpts = self.procs.get(identity) orelse return null; + return excerpts.last; } fn deinit(self: *SpliceClosure) void { @@ -9561,7 +9561,7 @@ const SpliceClosure = struct { self.procs.deinit(); } - /// Indices of the artifacts a splice of `root` places, valid until the + /// Indices of the excerpts a splice of `root` places, valid until the /// next query. fn of(self: *SpliceClosure, root: u32) Allocator.Error![]const u32 { for (self.order.items) |index| self.seen.unset(index); @@ -9569,11 +9569,11 @@ const SpliceClosure = struct { try self.visit(root); var cursor: usize = 0; while (cursor < self.order.items.len) : (cursor += 1) { - const artifact = self.set.artifacts[self.order.items[cursor]]; - for (artifact.refs) |ref| try self.visit(ref.target); - for (artifact.symbolic_refs) |ref| { + const excerpt = self.set.excerpts[self.order.items[cursor]]; + for (excerpt.refs) |ref| try self.visit(ref.target); + for (excerpt.symbolic_refs) |ref| { const target = switch (ref.target) { - .proc => |identity| if (self.procs.get(identity)) |artifacts| artifacts.first else null, + .proc => |identity| if (self.procs.get(identity)) |excerpts| excerpts.first else null, .boxy_thunk => |identity| self.thunks.get(identity), .rc_helper => |name| self.helpers.get(name), }; @@ -9590,16 +9590,16 @@ const SpliceClosure = struct { } }; -/// The artifacts of `set` whose procedure converts a specialized custom +/// The excerpts of `set` whose procedure converts a specialized custom /// literal when it runs (`PackProgram.procConvertsLiteralAtRuntime`). -fn literalConvertingArtifacts( +fn literalConvertingExcerpts( allocator: Allocator, - set: *const backend.dev.ProcArtifact.Set, + set: *const backend.dev.CodeExcerpt.Set, literal_converters: *const std.AutoHashMap(lir.ProcIdentity, void), ) Allocator.Error!std.DynamicBitSetUnmanaged { - var converting = try std.DynamicBitSetUnmanaged.initEmpty(allocator, set.artifacts.len); + var converting = try std.DynamicBitSetUnmanaged.initEmpty(allocator, set.excerpts.len); if (literal_converters.count() == 0) return converting; - for (set.artifacts, 0..) |artifact, index| switch (artifact.kind) { + for (set.excerpts, 0..) |excerpt, index| switch (excerpt.kind) { .proc => |identity| if (literal_converters.contains(identity)) converting.set(index), .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => {}, }; @@ -9608,50 +9608,50 @@ fn literalConvertingArtifacts( /// Whether any procedure a splice places converts a specialized custom /// literal when it runs. -fn closureConvertsLiteral(converting_artifacts: *const std.DynamicBitSetUnmanaged, placed: []const u32) bool { +fn closureConvertsLiteral(converting_excerpts: *const std.DynamicBitSetUnmanaged, placed: []const u32) bool { for (placed) |index| { - if (converting_artifacts.isSet(index)) return true; + if (converting_excerpts.isSet(index)) return true; } return false; } -/// Whether any artifact a splice places refers to something only its own +/// Whether any excerpt a splice places refers to something only its own /// program defines: a program-scope symbol it does not carry, such as the Boxy /// runtime, whose calls index this program's descriptor sidecar, or a constant /// holding a code pointer. -fn closureNamesProgramLocalSymbols(set: *const backend.dev.ProcArtifact.Set, placed: []const u32) bool { +fn closureNamesProgramLocalSymbols(set: *const backend.dev.CodeExcerpt.Set, placed: []const u32) bool { for (placed) |index| { - const artifact = set.artifacts[index]; + const excerpt = set.excerpts[index]; // The Boxy runtime's table numbers are the writing program's own. - if (artifact.runtime_refs.len != 0) return true; - for (artifact.relocations) |relocation| { - if (relocation.scope == .program and !carriesData(artifact, relocation.name)) return true; + if (excerpt.runtime_numbers.len != 0) return true; + for (excerpt.relocations) |relocation| { + if (relocation.scope == .program and !carriesData(excerpt, relocation.name)) return true; } - for (artifact.data) |item| { + for (excerpt.data) |item| { for (item.relocations) |relocation| if (relocation.function) return true; } } return false; } -fn carriesData(artifact: backend.dev.ProcArtifact.Artifact, name: []const u8) bool { - for (artifact.data) |item| { +fn carriesData(excerpt: backend.dev.CodeExcerpt.Excerpt, name: []const u8) bool { + for (excerpt.data) |item| { if (std.mem.eql(u8, item.name, name)) return true; } return false; } /// Test helper: whether the entry at `root` is withheld for program-local symbols. -fn testNamesProgramLocalSymbols(set: *const backend.dev.ProcArtifact.Set, root: u32) Allocator.Error!bool { +fn testNamesProgramLocalSymbols(set: *const backend.dev.CodeExcerpt.Set, root: u32) Allocator.Error!bool { var closure = try SpliceClosure.init(std.testing.allocator, set); defer closure.deinit(); return closureNamesProgramLocalSymbols(set, try closure.of(root)); } test "pack withholds an entry whose stable references reach a constant holding a code pointer" { - const ProcArtifact = backend.dev.ProcArtifact; + const CodeExcerpt = backend.dev.CodeExcerpt; const callee = lir.ProcIdentity.forTest(2); - const artifacts = [_]ProcArtifact.Artifact{ + const excerpts = [_]CodeExcerpt.Excerpt{ .{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "call", @@ -9679,15 +9679,15 @@ test "pack withholds an entry whose stable references reach a constant holding a }}, }, }; - const set = ProcArtifact.Set{ .arena = base.SingleThreadArena.init(std.testing.allocator), .artifacts = &artifacts }; + const set = CodeExcerpt.Set{ .arena = base.SingleThreadArena.init(std.testing.allocator), .excerpts = &excerpts }; try std.testing.expect(try testNamesProgramLocalSymbols(&set, 0)); try std.testing.expect(try testNamesProgramLocalSymbols(&set, 1)); } test "pack withholds an entry whose stable references reach a literal-converting procedure" { - const ProcArtifact = backend.dev.ProcArtifact; + const CodeExcerpt = backend.dev.CodeExcerpt; const converter = lir.ProcIdentity.forTest(2); - const artifacts = [_]ProcArtifact.Artifact{ + const excerpts = [_]CodeExcerpt.Excerpt{ .{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "call", @@ -9700,20 +9700,20 @@ test "pack withholds an entry whose stable references reach a literal-converting }, .{ .kind = .{ .proc = converter }, .code = "conv", .entry = 0, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }, }; - const set = ProcArtifact.Set{ .arena = base.SingleThreadArena.init(std.testing.allocator), .artifacts = &artifacts }; + const set = CodeExcerpt.Set{ .arena = base.SingleThreadArena.init(std.testing.allocator), .excerpts = &excerpts }; var converters = std.AutoHashMap(lir.ProcIdentity, void).init(std.testing.allocator); defer converters.deinit(); try converters.put(converter, {}); - var converting_artifacts = try literalConvertingArtifacts(std.testing.allocator, &set, &converters); - defer converting_artifacts.deinit(std.testing.allocator); + var converting_excerpts = try literalConvertingExcerpts(std.testing.allocator, &set, &converters); + defer converting_excerpts.deinit(std.testing.allocator); var closure = try SpliceClosure.init(std.testing.allocator, &set); defer closure.deinit(); - try std.testing.expect(closureConvertsLiteral(&converting_artifacts, try closure.of(0))); + try std.testing.expect(closureConvertsLiteral(&converting_excerpts, try closure.of(0))); } test "pack offers an entry that carries its program data and withholds one that calls the Boxy runtime" { - const ProcArtifact = backend.dev.ProcArtifact; - const artifacts = [_]ProcArtifact.Artifact{ + const CodeExcerpt = backend.dev.CodeExcerpt; + const excerpts = [_]CodeExcerpt.Excerpt{ .{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "read", @@ -9742,7 +9742,7 @@ test "pack offers an entry that carries its program data and withholds one that .data = &.{}, }, }; - const set = ProcArtifact.Set{ .arena = base.SingleThreadArena.init(std.testing.allocator), .artifacts = &artifacts }; + const set = CodeExcerpt.Set{ .arena = base.SingleThreadArena.init(std.testing.allocator), .excerpts = &excerpts }; try std.testing.expect(!try testNamesProgramLocalSymbols(&set, 0)); try std.testing.expect(try testNamesProgramLocalSymbols(&set, 1)); } @@ -10056,7 +10056,7 @@ fn lirResultNeedsBoxyRuntime(result: *const lir.Program.Result) bool { /// link. The runtime object provides the `roc_boxy_*` wrappers and /// `roc_boxy_init_embedded`; a companion data object carries the sidecar those /// symbols read (the descriptor tables, layout store, and string store the -/// program's boxy statements index). A canonical dev image's code numbers +/// program's boxy statements index). A arranged dev image's code numbers /// those tables as `canonical_order` does, so its sidecar holds exactly what /// that order names; an LLVM image's indexes the program's own tables. fn appendBoxyRuntimeLinkInputs( @@ -10081,7 +10081,7 @@ fn appendBoxyRuntimeLinkInputs( }; var sidecar_blob = (if (canonical_order) |order| - lir.LirImage.buildCanonicalSidecarBlob(ctx.gpa, lir_result, order) + lir.LirImage.buildArrangedSidecarBlob(ctx.gpa, lir_result, order) else lir.LirImage.buildSidecarBlob(ctx.gpa, lir_result)) catch { return error.NativeCompilationFailed; @@ -10694,7 +10694,7 @@ fn staticDataLinkRootSymbols( /// The static data a dev object's link must keep: each constant the host binds /// by name. A dev object defines only its host-visible constants under their -/// own names (design.md "Canonical Dev Images"); every other constant is named +/// own names (design.md "Deterministic Dev Images"); every other constant is named /// by its content and reached through the code that names it, so a program's /// own name for it (such as a static-data slot's) is not a symbol of the /// object. A link told to keep such a name fails where a forced undefined @@ -11923,7 +11923,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu for (lowered.lir_result.store.getProcSpecs()) |proc| { if (proc.c_abi_entry) linked_procs += 1; } - std.debug.print("pack hits: {d} external procs: {d} linked procs: {d} evaluator artifacts: {d} packs loaded: {d} keys: {d}\n", .{ packs.hits, external_procs, linked_procs, packs.artifacts_served, packs.packs.items.len, packs.specs.count() }); + std.debug.print("pack hits: {d} external procs: {d} linked procs: {d} evaluator excerpts: {d} packs loaded: {d} keys: {d}\n", .{ packs.hits, external_procs, linked_procs, packs.excerpts_served, packs.packs.items.len, packs.specs.count() }); } const entrypoints = try nativeBuildEntrypoints(ctx, root_artifact, &lowered); @@ -11965,9 +11965,9 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu object_compiler.timing = &backend_timing; object_compiler.post_check_executor = build_env.postCheckExecutor(); if (loaded_packs) |packs| object_compiler.splice_source = packs.spliceSource(); - object_compiler.capture_artifacts = object_cache_enabled; + object_compiler.capture_excerpts = object_cache_enabled; object_compiler.erased_capture_prefix = lowered.lir_result.erased_capture_prefix; - defer if (object_compiler.captured_artifacts) |*set| set.deinit(); + defer if (object_compiler.captured_excerpts) |*set| set.deinit(); const build_scratch_dir = createUniqueTempDir(ctx) catch |err| { return ctx.fail(.{ .temp_dir_failed = .{ .err = err } }); @@ -12006,7 +12006,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu // and so skip its diagnostic, so a build with errors writes no packs. if (object_store != null and diag.errors == 0) { const store = object_store.?; - try writePacksToStore(ctx, &build_env, store, root_artifact, imported_artifacts, relation_artifacts, &lowered, if (object_compiler.captured_artifacts) |*set| set else null, args, target); + try writePacksToStore(ctx, &build_env, store, root_artifact, imported_artifacts, relation_artifacts, &lowered, if (object_compiler.captured_excerpts) |*set| set else null, args, target); } const link_facts: ProgramOutput.LinkFacts = .{ diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index b0b6ce5ec06..17e0a66dac7 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -236,9 +236,9 @@ test "object cache loading owns decoded packs and commits both indexes atomicall defer tmp.cleanup(); const identity = lir.ProcIdentity.forTest(43); const key = @as([32]u8, @splat(41)); - const set = backend.dev.ProcArtifact.Set{ + const set = backend.dev.CodeExcerpt.Set{ .arena = @import("base").SingleThreadArena.init(allocator), - .artifacts = &.{.{ + .excerpts = &.{.{ .kind = .{ .proc = identity }, .code = "\xc3", .entry = 0, @@ -250,7 +250,7 @@ test "object cache loading owns decoded packs and commits both indexes atomicall }; const bytes = try PackFile.write(allocator, &set, &.{.{ .key = key, - .artifact = 0, + .excerpt = 0, .rc_borrowed_params = 1, .rc_ret_borrowed = false, .rc_ret_lenders = 0, @@ -275,10 +275,10 @@ test "object cache loading owns decoded packs and commits both indexes atomicall const hit = packs.specCacheLookup().lookup(expected_key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; try std.testing.expectEqualSlices(u8, &expected_identity.bytes, &hit.identity); try std.testing.expectEqual(@as(u64, 1), hit.rc_borrowed_params); - // A consumer that cannot splice is never served dev artifacts. + // A consumer that cannot splice is never served dev excerpts. try std.testing.expect(packs.specCacheLookup().lookup(expected_key, .linked_objects, &.{}) == null); const located = packs.spliceSource().find(packs.spliceSource().context, expected_key) orelse return error.TestUnexpectedResult; - try std.testing.expectEqual(expected_identity, located.set.artifacts[located.index].kind.proc); + try std.testing.expectEqual(expected_identity, located.set.excerpts[located.index].kind.proc); } fn rejected(packs: *LoadedPacks, key_to_find: [32]u8, err: LoadedPacks.LoadError) (LoadedPacks.LoadError || error{ TestExpectedEqual, TestUnexpectedResult, TestExpectedError, TestUnexpectedError })!void { @@ -290,7 +290,7 @@ test "object cache loading owns decoded packs and commits both indexes atomicall const splice = packs.spliceSource(); try std.testing.expect(splice.find(splice.context, key_to_find) == null); try std.testing.expectEqual(@as(u64, 0), packs.hits); - try std.testing.expectEqual(@as(u64, 0), packs.artifacts_served); + try std.testing.expectEqual(@as(u64, 0), packs.excerpts_served); return err; } }; @@ -322,18 +322,18 @@ test "object cache loading owns decoded packs and commits both indexes atomicall try std.testing.expect(malformed.spliceSource().find(malformed.spliceSource().context, key) == null); } -test "object cache accepts new offers after indexing without moving served artifacts" { +test "object cache accepts new offers after indexing without moving served excerpts" { const allocator = std.testing.allocator; var packs = LoadedPacks.init(allocator); defer packs.deinit(); try packs.indexPacks(); - var first: ?backend.dev.LocatedArtifact = null; + var first: ?backend.dev.LocatedExcerpt = null; for (0..32) |index| { const identity = lir.ProcIdentity.forTest(@intCast(index)); const key = @as([32]u8, @splat(@intCast(index))); - const set = backend.dev.ProcArtifact.Set{ + const set = backend.dev.CodeExcerpt.Set{ .arena = @import("base").SingleThreadArena.init(allocator), - .artifacts = &.{.{ + .excerpts = &.{.{ .kind = .{ .proc = identity }, .code = "\xc3", .entry = 0, @@ -345,7 +345,7 @@ test "object cache accepts new offers after indexing without moving served artif }; const bytes = try PackFile.write(allocator, &set, &.{.{ .key = key, - .artifact = 0, + .excerpt = 0, .rc_borrowed_params = 0, .rc_ret_borrowed = false, .rc_ret_lenders = 0, @@ -360,10 +360,10 @@ test "object cache accepts new offers after indexing without moving served artif try std.testing.expectEqualSlices(u8, &identity.bytes, &hit.identity); const splice = packs.spliceSource(); const located = splice.find(splice.context, key) orelse return error.TestUnexpectedResult; - try std.testing.expectEqual(identity, located.set.artifacts[located.index].kind.proc); + try std.testing.expectEqual(identity, located.set.excerpts[located.index].kind.proc); if (first) |served| { try std.testing.expectEqual(&packs.packs.items[0].set, served.set); - try std.testing.expectEqual(lir.ProcIdentity.forTest(0), served.set.artifacts[served.index].kind.proc); + try std.testing.expectEqual(lir.ProcIdentity.forTest(0), served.set.excerpts[served.index].kind.proc); } else first = located; } } @@ -650,10 +650,10 @@ pub fn programBindingKey(allocator: Allocator, build_env: *compile.BuildEnv, roo /// A dev pack as a program asks for its entries: the pack's artifact set and /// a digest of the program's modules (`Common.ProgramModules`). const ProgramPack = struct { - set: *const backend.dev.ProcArtifact.Set, + set: *const backend.dev.CodeExcerpt.Set, modules: [32]u8, - fn of(set: *const backend.dev.ProcArtifact.Set, modules: Common.ProgramModules) ProgramPack { + fn of(set: *const backend.dev.CodeExcerpt.Set, modules: Common.ProgramModules) ProgramPack { var hasher = std.crypto.hash.Blake3.init(.{}); for (modules) |identity| hasher.update(&identity); var digest: [32]u8 = undefined; @@ -770,8 +770,8 @@ pub const LoadedPacks = struct { allocator: Allocator, packs: std.ArrayList(*PackFile.Pack), specs: std.AutoHashMap([32]u8, Common.SpecCacheHit), - /// The artifact of each entry `specs` holds, by the same key. - artifacts: std.AutoHashMap([32]u8, backend.dev.LocatedArtifact), + /// The excerpt of each entry `specs` holds, by the same key. + excerpts: std.AutoHashMap([32]u8, backend.dev.LocatedExcerpt), /// Object packs, whose entries only a consumer whose code is linked takes. object_packs: std.ArrayList(PackFile.ObjectPack) = .empty, linked_specs: std.AutoHashMap([32]u8, LinkedOffer), @@ -782,8 +782,8 @@ pub const LoadedPacks = struct { /// Per dev pack, by its artifact set, whether a splice of each artifact /// would place code for a procedure an object pack offers /// (`linked_identities`). - dev_reaches_linked: std.AutoHashMapUnmanaged(*const backend.dev.ProcArtifact.Set, []bool) = .empty, - /// Per dev pack and asking program, whether a splice of each artifact + dev_reaches_linked: std.AutoHashMapUnmanaged(*const backend.dev.CodeExcerpt.Set, []bool) = .empty, + /// Per dev pack and asking program, whether a splice of each excerpt /// would place a line or a location in a module version the program does /// not lower against (`linesOutsideProgram`). lines_outside: std.AutoHashMapUnmanaged(ProgramPack, []bool) = .empty, @@ -798,7 +798,7 @@ pub const LoadedPacks = struct { /// Specializations served so far. hits: u64 = 0, /// Artifacts handed to a splice so far. - artifacts_served: u64 = 0, + excerpts_served: u64 = 0, /// Set until `indexPacks` runs; lookups before that load the pending /// store's packs for the modules in view first. pending: ?Pending = null, @@ -827,7 +827,7 @@ pub const LoadedPacks = struct { .allocator = allocator, .packs = .empty, .specs = std.AutoHashMap([32]u8, Common.SpecCacheHit).init(allocator), - .artifacts = std.AutoHashMap([32]u8, backend.dev.LocatedArtifact).init(allocator), + .excerpts = std.AutoHashMap([32]u8, backend.dev.LocatedExcerpt).init(allocator), .linked_specs = std.AutoHashMap([32]u8, LinkedOffer).init(allocator), .discovery_arena = std.heap.ArenaAllocator.init(allocator), }; @@ -1035,15 +1035,15 @@ pub const LoadedPacks = struct { fn indexPack(self: *LoadedPacks, pack: *const PackFile.Pack) LoadError!void { for (pack.specs) |spec| { - const root = pack.set.artifacts[spec.artifact]; + const root = pack.set.excerpts[spec.excerpt]; const identity = switch (root.kind) { .proc => |identity| identity, .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => return error.MalformedPack, }; if (self.specs.contains(spec.key)) continue; - try self.artifacts.ensureUnusedCapacity(1); + try self.excerpts.ensureUnusedCapacity(1); try self.specs.ensureUnusedCapacity(1); - self.artifacts.putAssumeCapacityNoClobber(spec.key, .{ .set = &pack.set, .index = spec.artifact }); + self.excerpts.putAssumeCapacityNoClobber(spec.key, .{ .set = &pack.set, .index = spec.excerpt }); self.specs.putAssumeCapacityNoClobber(spec.key, .{ .identity = identity.bytes, .comptime_root_reads = spec.comptime_root_reads, @@ -1064,13 +1064,13 @@ pub const LoadedPacks = struct { fn indexObjectPacks(self: *LoadedPacks, program_key: [32]u8) LoadError!void { for (self.object_packs.items, 0..) |*pack, pack_index| { for (pack.specs) |spec| { - if (!pack.servesProgram(spec.artifact, program_key)) continue; - const linked = try self.linked_identities.getOrPut(self.allocator, pack.identities[spec.artifact]); + if (!pack.servesProgram(spec.excerpt, program_key)) continue; + const linked = try self.linked_identities.getOrPut(self.allocator, pack.identities[spec.excerpt]); if (!linked.found_existing) linked.value_ptr.* = @intCast(pack_index); const gop = try self.linked_specs.getOrPut(spec.key); if (!gop.found_existing) gop.value_ptr.* = .{ .hit = .{ - .identity = pack.identities[spec.artifact].bytes, + .identity = pack.identities[spec.excerpt].bytes, .c_abi_entry = true, .comptime_root_reads = spec.comptime_root_reads, .rc_borrowed_params = spec.rc_borrowed_params, @@ -1212,7 +1212,7 @@ pub const LoadedPacks = struct { for (self.object_packs.items) |*pack| pack.deinit(); self.object_packs.deinit(self.allocator); self.object_packs_used.deinit(self.allocator); - self.artifacts.deinit(); + self.excerpts.deinit(); self.specs.deinit(); for (self.packs.items) |pack| { pack.deinit(); @@ -1297,7 +1297,7 @@ pub const LoadedPacks = struct { /// Whether a splice of the dev entry under `key` would place a line or a /// location in a module version outside `modules`. fn linesOutsideProgram(self: *LoadedPacks, key: [32]u8, modules: Common.ProgramModules) bool { - const located = self.artifacts.get(key) orelse return false; + const located = self.excerpts.get(key) orelse return false; const set = located.set; var all_present = true; for (set.source_files) |identity| all_present = all_present and Common.programHasModule(modules, identity); @@ -1309,7 +1309,7 @@ pub const LoadedPacks = struct { const present = self.allocator.alloc(bool, set.source_files.len) catch return true; defer self.allocator.free(present); for (set.source_files, present) |identity, *is_present| is_present.* = Common.programHasModule(modules, identity); - const marks = backend.dev.ProcArtifact.reachingAbsentFiles(self.allocator, set, present) catch return true; + const marks = backend.dev.CodeExcerpt.reachingAbsentFiles(self.allocator, set, present) catch return true; self.lines_outside.put(self.allocator, program_pack, marks) catch { const outside = marks[located.index]; self.allocator.free(marks); @@ -1323,11 +1323,11 @@ pub const LoadedPacks = struct { /// procedure, or one its code reaches through the pack. fn devEntryReachesLinked(self: *LoadedPacks, key: [32]u8) bool { if (self.linked_identities.count() == 0) return false; - const located = self.artifacts.get(key) orelse return false; + const located = self.excerpts.get(key) orelse return false; const marks = self.dev_reaches_linked.get(located.set) orelse marks: { // Refusing the entry is always sound: the program compiles the // procedure itself. - const computed = backend.dev.ProcArtifact.reachingIdentities(self.allocator, located.set, &self.linked_identities) catch return true; + const computed = backend.dev.CodeExcerpt.reachingIdentities(self.allocator, located.set, &self.linked_identities) catch return true; self.dev_reaches_linked.put(self.allocator, located.set, computed) catch { const reaches = computed[located.index]; self.allocator.free(computed); @@ -1359,12 +1359,12 @@ pub const LoadedPacks = struct { return list.items; } - fn findArtifact(context: *anyopaque, key: [32]u8) ?backend.dev.LocatedArtifact { + fn findArtifact(context: *anyopaque, key: [32]u8) ?backend.dev.LocatedExcerpt { const self: *LoadedPacks = @ptrCast(@alignCast(context)); self.ensureLoaded(); if (self.state != .ready) return null; - const located = self.artifacts.get(key) orelse return null; - self.artifacts_served += 1; + const located = self.excerpts.get(key) orelse return null; + self.excerpts_served += 1; return located; } }; @@ -1415,10 +1415,10 @@ test "loaded packs offer no entry whose lines name a missing module version or t const caller = lir.ProcIdentity.forTest(61); const callee = lir.ProcIdentity.forTest(62); const stale = lir.ProcIdentity.forTest(63); - const frame: backend.dev.ProcArtifact.Frame = .{ .prologue_size = 1, .stack_alloc = 0, .frame_size = 0, .callee_saved_mask = 0, .epilogue_offset = 1, .uses_frame_pointer = true }; - const set = backend.dev.ProcArtifact.Set{ + const frame: backend.dev.CodeExcerpt.Frame = .{ .prologue_size = 1, .stack_alloc = 0, .frame_size = 0, .callee_saved_mask = 0, .epilogue_offset = 1, .uses_frame_pointer = true }; + const set = backend.dev.CodeExcerpt.Set{ .arena = base_mod.SingleThreadArena.init(allocator), - .artifacts = &.{ + .excerpts = &.{ .{ .kind = .{ .proc = caller }, .code = "\xe8\x00\x00\x00\x00\xc3", .entry = 0, .frame = frame, .refs = &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}, .relocations = &.{}, .data = &.{}, .loc = .{ .file = 0, .line = 1, .column = 1 } }, .{ .kind = .{ .proc = callee }, .code = "\xc3", .entry = 0, .frame = frame, .refs = &.{}, .relocations = &.{}, .data = &.{}, .loc = .{ .file = 0, .line = 2, .column = 1 } }, .{ .kind = .{ .proc = stale }, .code = "\xc3", .entry = 0, .frame = frame, .refs = &.{}, .relocations = &.{}, .data = &.{}, .lines = &.{.{ .offset = 0, .loc = .{ .file = 1, .line = 7, .column = 1 }, .region = base_mod.Region.zero() }} }, @@ -1428,7 +1428,7 @@ test "loaded packs offer no entry whose lines name a missing module version or t const Spec = PackFile.SpecEntry; const spec = struct { fn of(key: u8, artifact: u32) Spec { - return .{ .key = @splat(key), .artifact = artifact, .rc_borrowed_params = 0, .rc_ret_borrowed = false, .rc_ret_lenders = 0, .rc_read_only_params = 0, .rc_ret_unique = false, .rc_ret_unique_fields = 0, .rc_ret_conditions = &.{} }; + return .{ .key = @splat(key), .excerpt = artifact, .rc_borrowed_params = 0, .rc_ret_borrowed = false, .rc_ret_lenders = 0, .rc_read_only_params = 0, .rc_ret_unique = false, .rc_ret_unique_fields = 0, .rc_ret_conditions = &.{} }; } }.of; const bytes = try PackFile.write(allocator, &set, &.{ spec(1, 0), spec(2, 1), spec(3, 2) }); @@ -1468,7 +1468,7 @@ test "object packs serve in load order, and app-bound entries serve only the pro const Spec = PackFile.SpecEntry; const spec = struct { fn of(key: u8, artifact: u32) Spec { - return .{ .key = @splat(key), .artifact = artifact, .rc_borrowed_params = 0, .rc_ret_borrowed = false, .rc_ret_lenders = 0, .rc_read_only_params = 0, .rc_ret_unique = false, .rc_ret_unique_fields = 0, .rc_ret_conditions = &.{} }; + return .{ .key = @splat(key), .excerpt = artifact, .rc_borrowed_params = 0, .rc_ret_borrowed = false, .rc_ret_lenders = 0, .rc_read_only_params = 0, .rc_ret_unique = false, .rc_ret_unique_fields = 0, .rc_ret_conditions = &.{} }; } }.of; // The first pack offers `shared`, and the program's own code bound to @@ -1501,7 +1501,7 @@ test "an object pack that offers nothing is no offer and contributes no object t var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const offered = lir.ProcIdentity.forTest(81); - const spec: PackFile.SpecEntry = .{ .key = @splat(3), .artifact = 0, .rc_borrowed_params = 0, .rc_ret_borrowed = false, .rc_ret_lenders = 0, .rc_read_only_params = 0, .rc_ret_unique = false, .rc_ret_unique_fields = 0, .rc_ret_conditions = &.{} }; + const spec: PackFile.SpecEntry = .{ .key = @splat(3), .excerpt = 0, .rc_borrowed_params = 0, .rc_ret_borrowed = false, .rc_ret_lenders = 0, .rc_read_only_params = 0, .rc_ret_unique = false, .rc_ret_unique_fields = 0, .rc_ret_conditions = &.{} }; const offering = try PackFile.writeObject(allocator, "object", &.{offered}, &.{spec}, .{ .app_bound = &.{false} }); defer allocator.free(offering); // What an optimized build files when its program's code could not be a diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 01463fccade..7dfa46f7230 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -443,7 +443,7 @@ const CustomCase = enum { edited_lambda_object_cache, edited_nominal_object_cache, edited_method_object_cache, - native_build_artifact_round_trip, + native_build_excerpt_round_trip, native_build_pack_objects, native_build_pack_hits, early_ctfe_cache, @@ -588,7 +588,7 @@ const forced_ice_skip: Skip = if (builtin.mode == .debug) .never else .{ .always /// here sets one of them and checks what it changes or prints. const dev_diagnostics_cases = [_]CustomCase{ .package_dev_packs, - .native_build_artifact_round_trip, + .native_build_excerpt_round_trip, .native_build_pack_objects, .native_build_pack_hits, .early_ctfe_cache, @@ -3211,7 +3211,7 @@ const subcommand_cases = [_]CliCase{ .{ .id = 0, .suite = .subcommands, .name = "object cache: spliced procedures keep the debug lines of the cold build", .timeout_ms = 600_000, .body = .{ .custom = .spliced_procedure_debug_lines } }, .{ .id = 0, .suite = .subcommands, .name = "object cache: exports taking and returning boxed closures are offered and served warm", .timeout_ms = 600_000, .body = .{ .custom = .boxed_closure_object_cache } }, .{ .id = 0, .suite = .subcommands, .name = "roc build native dev writes identical object-cache packs across thread counts", .timeout_ms = 600_000, .body = .{ .custom = .native_build_pack_store_thread_count_reproducible } }, - .{ .id = 0, .suite = .subcommands, .name = "roc build native dev output assembled from its own procedure artifacts is identical", .timeout_ms = 600_000, .body = .{ .custom = .native_build_artifact_round_trip } }, + .{ .id = 0, .suite = .subcommands, .name = "roc build native dev output assembled from its own procedure excerpts is identical", .timeout_ms = 600_000, .body = .{ .custom = .native_build_excerpt_round_trip } }, .{ .id = 0, .suite = .subcommands, .name = "roc build native dev pack programs are deterministic and round-trip through artifacts", .timeout_ms = 600_000, .body = .{ .custom = .native_build_pack_objects } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11673: imported callable identity survives cold warm and sibling builds", .timeout_ms = 600_000, .body = .{ .custom = .issue_11673_callable_cache } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11678: cached recursive callbacks retain method result rows", .timeout_ms = 600_000, .body = .{ .custom = .issue_11678_recursive_callback_cache } }, @@ -5194,7 +5194,7 @@ fn runCustomCase( .edited_lambda_object_cache => customEditedLambdaObjectCache(io, allocator, &env, &timer, timeout_ms), .edited_nominal_object_cache => customEditedNominalObjectCache(io, allocator, &env, &timer, timeout_ms), .edited_method_object_cache => customEditedMethodObjectCache(io, allocator, &env, &timer, timeout_ms), - .native_build_artifact_round_trip => customNativeBuildArtifactRoundTrip(io, allocator, &env, &timer, timeout_ms), + .native_build_excerpt_round_trip => customNativeBuildExcerptRoundTrip(io, allocator, &env, &timer, timeout_ms), .native_build_pack_objects => customNativeBuildPackObjects(io, allocator, &env, &timer, timeout_ms), .issue_11673_callable_cache => customIssue11673CallableCache(io, allocator, &env, &timer, timeout_ms), .issue_11678_recursive_callback_cache => customIssue11678RecursiveCallbackCache(io, allocator, &env, &timer, timeout_ms), @@ -9254,12 +9254,12 @@ fn customSplicedProcedureDebugLines( return null; } -/// Every dev object compile under `ROC_DEV_ARTIFACT_ROUNDTRIP` also assembles -/// the program from its own procedure artifacts and panics on any difference -/// in code bytes, relocations, or unwind records (`ProcArtifact.verifyRoundTrip` -/// in `src/backend/dev/ProcArtifact.zig`). A build that panics fails the +/// Every dev object compile under `ROC_DEV_EXCERPT_ROUNDTRIP` also assembles +/// the program from its own procedure excerpts and panics on any difference +/// in code bytes, relocations, or unwind records (`CodeExcerpt.verifyRoundTrip` +/// in `src/backend/dev/CodeExcerpt.zig`). A build that panics fails the /// "successfully building" check. -fn customNativeBuildArtifactRoundTrip( +fn customNativeBuildExcerptRoundTrip( io: std.Io, allocator: Allocator, env: *const CaseEnv, @@ -9272,11 +9272,11 @@ fn customNativeBuildArtifactRoundTrip( return customInfraFailure(allocator, timer, "failed to clone artifact round-trip environment: {}", .{err}), }; defer round_trip_env.env_map.deinit(); - round_trip_env.env_map.put("ROC_DEV_ARTIFACT_ROUNDTRIP", "1") catch |err| - return customInfraFailure(allocator, timer, "failed to enable the artifact round trip: {}", .{err}); + round_trip_env.env_map.put("ROC_DEV_EXCERPT_ROUNDTRIP", "1") catch |err| + return customInfraFailure(allocator, timer, "failed to enable the excerpt round trip: {}", .{err}); for (thread_count_reproducible_apps, 0..) |roc_file, app_index| { - const output_path = std.fmt.allocPrint(allocator, "{s}/artifact_round_trip_{d}", .{ env.dirs.work_dir, app_index }) catch |err| + const output_path = std.fmt.allocPrint(allocator, "{s}/excerpt_round_trip_{d}", .{ env.dirs.work_dir, app_index }) catch |err| return customInfraFailure(allocator, timer, "failed to allocate output path: {}", .{err}); const out_arg = outputArg(allocator, output_path) catch |err| return customInfraFailure(allocator, timer, "failed to allocate output arg: {}", .{err}); @@ -9294,8 +9294,8 @@ fn customNativeBuildArtifactRoundTrip( /// module's closed exports as a pack program and write one object and one /// manifest per module next to the output (`writePackObjects` in /// `src/cli/main.zig`). Two builds must write identical pack files, the -/// platform and app packs must have roots, and with the artifact round trip -/// enabled every pack object must assemble from its own artifacts. +/// platform and app packs must have roots, and with the excerpt round trip +/// enabled every pack object must assemble from its own excerpts. fn customNativeBuildPackObjects( io: std.Io, allocator: Allocator, @@ -9311,8 +9311,8 @@ fn customNativeBuildPackObjects( defer pack_env.env_map.deinit(); pack_env.env_map.put("ROC_DEV_PACK_OBJECTS", "1") catch |err| return customInfraFailure(allocator, timer, "failed to enable pack objects: {}", .{err}); - pack_env.env_map.put("ROC_DEV_ARTIFACT_ROUNDTRIP", "1") catch |err| - return customInfraFailure(allocator, timer, "failed to enable the artifact round trip: {}", .{err}); + pack_env.env_map.put("ROC_DEV_EXCERPT_ROUNDTRIP", "1") catch |err| + return customInfraFailure(allocator, timer, "failed to enable the excerpt round trip: {}", .{err}); const roc_file = "test/fx/hello_world.roc"; const suffixes = [_][]const u8{ "pack_a", "pack_b" }; @@ -11447,7 +11447,7 @@ fn storeBuildsBehaveIdentically( expect: StoreExpectations, ) ?TestResult { const hits_marker = "pack hits: "; - const evaluator_marker = "evaluator artifacts: "; + const evaluator_marker = "evaluator excerpts: "; var stats_env = CaseEnv{ .dirs = env.dirs, .env_map = env.env_map.clone(allocator) catch |err| @@ -11531,7 +11531,7 @@ fn storeBuildsBehaveIdentically( } if (expect.evaluator_artifacts) { const evaluator_at = std.mem.find(u8, built.stderr, evaluator_marker) orelse - return failureFromRun(allocator, timer, built, "build with the object cache did not report evaluator artifacts"); + return failureFromRun(allocator, timer, built, "build with the object cache did not report evaluator excerpts"); if (countAfterMarker(built.stderr[evaluator_at + evaluator_marker.len ..]) == 0) return failureFromRun(allocator, timer, built, "the compile-time evaluator spliced no object-cache entry"); } } diff --git a/src/compile/test/ctfe_code_identity_test.zig b/src/compile/test/ctfe_code_identity_test.zig index c5431872400..51d63e5f927 100644 --- a/src/compile/test/ctfe_code_identity_test.zig +++ b/src/compile/test/ctfe_code_identity_test.zig @@ -16,7 +16,7 @@ const CoreCtx = @import("ctx").CoreCtx; const Coordinator = @import("../coordinator.zig").Coordinator; const is_freestanding = @import("../threading.zig").is_freestanding; -const ProcArtifact = backend.dev.ProcArtifact; +const CodeExcerpt = backend.dev.CodeExcerpt; const echo_platform = [_]struct { path: []const u8, source: []const u8 }{ .{ .path = ".roc_echo_platform/main.roc", .source = @@ -50,7 +50,7 @@ const echo_platform = [_]struct { path: []const u8, source: []const u8 }{ /// sites. Compile-time evaluation's combined artifacts name each constant by /// its content, so a relocation of `expected` to a constant the build /// numbers is compared by the content name `expected_names` gives it. -fn artifactDifference(expected: ProcArtifact.Artifact, expected_names: *const ProcArtifact.ContentNames, actual: ProcArtifact.Artifact) ?[]const u8 { +fn excerptDifference(expected: CodeExcerpt.Excerpt, expected_names: *const CodeExcerpt.ContentNames, actual: CodeExcerpt.Excerpt) ?[]const u8 { if (!std.mem.eql(u8, expected.code, actual.code)) return "code"; if (expected.entry != actual.entry) return "entry"; if (!std.meta.eql(expected.frame, actual.frame)) return "frame"; @@ -165,7 +165,7 @@ test "every procedure compile-time evaluation shares with a dev build is the bui try coord.finishCheckedProgram(.executable_artifacts); try std.testing.expect(!coord.hasUserErrors()); const session = &coord.program_session.?; - var evaluated = try session.completed.?.combinedArtifacts(allocator); + var evaluated = try session.completed.?.combinedExcerpts(allocator); defer evaluated.deinit(); var runtime = try session.takeRuntime(allocator, session.runtime_roots, coord.runtime_lowering.?.target); @@ -196,26 +196,26 @@ test "every procedure compile-time evaluation shares with a dev build is the bui roc_target.RocTarget.detectNative(), ); defer compiled.deinit(); - const built = &(compiled.artifacts orelse return error.TestUnexpectedResult); + const built = &(compiled.excerpts orelse return error.TestUnexpectedResult); - var built_names = try ProcArtifact.ContentNames.init(allocator, built); + var built_names = try CodeExcerpt.ContentNames.init(allocator, built); defer built_names.deinit(); var built_by_identity = std.AutoHashMap(lir.ProcIdentity, usize).init(allocator); defer built_by_identity.deinit(); - for (built.artifacts, 0..) |artifact, index| switch (artifact.kind) { + for (built.excerpts, 0..) |artifact, index| switch (artifact.kind) { .proc => |identity| try built_by_identity.put(identity, index), .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => {}, }; var shared: usize = 0; var differing: usize = 0; - for (evaluated.artifacts) |artifact| { + for (evaluated.excerpts) |artifact| { const identity = switch (artifact.kind) { .proc => |identity| identity, .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => continue, }; const index = built_by_identity.get(identity) orelse continue; shared += 1; - if (artifactDifference(built.artifacts[index], &built_names, artifact)) |difference| { + if (excerptDifference(built.excerpts[index], &built_names, artifact)) |difference| { differing += 1; var name: []const u8 = ""; for (runtime.lir_result.store.getProcSpecs(), 0..) |proc, proc_index| { diff --git a/src/eval/compile_time_finalization.zig b/src/eval/compile_time_finalization.zig index 7d98cbc3c3a..cd1273851f5 100644 --- a/src/eval/compile_time_finalization.zig +++ b/src/eval/compile_time_finalization.zig @@ -113,7 +113,7 @@ pub const Options = struct { timing: ?*Timing = null, object_cache: ?CompileTimeObjectCache = null, /// Keep each stage's code as object-cache artifacts - /// (`CompletedStages.artifacts`): what an object cache publishes. + /// (`CompletedStages.excerpts`): what an object cache publishes. keep_artifacts: bool = false, /// Where a compile-time failure is reported when the source it names /// belongs to a checked module this finalization does not complete: a @@ -754,9 +754,9 @@ pub const CompletedStages = struct { /// The specializations' compile-time reads, which the object cache /// writes beside the program's procedures. spec_comptime_reads: ?lir.CheckedPipeline.SpecComptimeReads = null, - /// Each stage's code as object-cache artifacts, when the evaluation + /// Each stage's code as object-cache excerpts, when the evaluation /// writes to an object cache. - artifacts: std.ArrayList(backend.dev.ProcArtifact.Set) = .empty, + excerpts: std.ArrayList(backend.dev.CodeExcerpt.Set) = .empty, /// The discovery summaries of the evaluation's program (design.md /// "Discovery Summaries"); null when the evaluation produced a message a /// later compilation that skipped its bodies would not repeat. @@ -791,18 +791,18 @@ pub const CompletedStages = struct { self.erased_members.deinit(self.allocator); self.pending.deinit(self.allocator); if (self.spec_comptime_reads) |*reads| reads.deinit(); - for (self.artifacts.items) |*set| set.deinit(); - self.artifacts.deinit(self.allocator); + for (self.excerpts.items) |*set| set.deinit(); + self.excerpts.deinit(self.allocator); if (self.discovery_summaries) |*summaries| summaries.deinit(); self.* = undefined; } - /// The stages' code as one artifact set, one copy of each procedure. - pub fn combinedArtifacts(self: *const CompletedStages, allocator: Allocator) Allocator.Error!backend.dev.ProcArtifact.Set { - const sets = try allocator.alloc(*const backend.dev.ProcArtifact.Set, self.artifacts.items.len); + /// The stages' code as one excerpt set, one copy of each procedure. + pub fn combinedExcerpts(self: *const CompletedStages, allocator: Allocator) Allocator.Error!backend.dev.CodeExcerpt.Set { + const sets = try allocator.alloc(*const backend.dev.CodeExcerpt.Set, self.excerpts.items.len); defer allocator.free(sets); - for (self.artifacts.items, sets) |*set, *ptr| ptr.* = set; - return backend.dev.ProcArtifact.combineDistinct(allocator, sets); + for (self.excerpts.items, sets) |*set, *ptr| ptr.* = set; + return backend.dev.CodeExcerpt.combineDistinct(allocator, sets); } /// The stages' programs, in evaluation order. Each one's code was @@ -2012,9 +2012,9 @@ fn evaluateStaged( if (stage.frozen_static_data) |*read| read.deinit(); stage.frozen_static_data = frozen; if (ProgramPtr == *DevProgram and options.keep_artifacts) { - var set = try program.extractArtifacts(stage); + var set = try program.extractExcerpts(stage); errdefer set.deinit(); - try completed.artifacts.append(allocator, set); + try completed.excerpts.append(allocator, set); } try completed.finishStage(); if (options.object_cache) |cache| if (cache.stage_sink) |sink| if (prepared.spec_comptime_reads) |*reads| { @@ -4763,7 +4763,7 @@ const StaticSlotEnvironment = struct { /// roots it runs, and of every procedure of the program that the /// object-cache code it splices calls, to a fixed point. A spliced entry's /// call to a procedure the program has binds to the program's own code -/// (`ProcArtifact.splice`), so that procedure is compiled too, and the +/// (`CodeExcerpt.splice`), so that procedure is compiled too, and the /// object-cache entries its closure reaches are spliced in turn. fn evaluationDemand( allocator: Allocator, @@ -4786,7 +4786,7 @@ fn evaluationDemand( var added = collections.DenseMap(lir.LIR.LirProcSpecId, void).init(allocator); defer added.deinit(); for (roots) |root| try added.put(root, {}); - const Visit = struct { set: *const backend.dev.ProcArtifact.Set, index: u32 }; + const Visit = struct { set: *const backend.dev.CodeExcerpt.Set, index: u32 }; var visited = std.AutoHashMapUnmanaged(Visit, void).empty; defer visited.deinit(allocator); var stack = std.ArrayList(Visit).empty; @@ -4801,14 +4801,14 @@ fn evaluationDemand( const located = source.find(source.context, key) orelse continue; try stack.append(allocator, .{ .set = located.set, .index = located.index }); } - // Every artifact the spliced closures place, walked once, as - // `ProcArtifact.splice` places them: set-local references, and + // Every excerpt the spliced closures place, walked once, as + // `CodeExcerpt.splice` places them: set-local references, and // symbolic references to code the program does not have. while (stack.pop()) |visit| { if ((try visited.getOrPut(allocator, visit)).found_existing) continue; - const artifact = visit.set.artifacts[visit.index]; - for (artifact.refs) |ref| try stack.append(allocator, .{ .set = visit.set, .index = ref.target }); - for (artifact.symbolic_refs) |ref| { + const excerpt = visit.set.excerpts[visit.index]; + for (excerpt.refs) |ref| try stack.append(allocator, .{ .set = visit.set, .index = ref.target }); + for (excerpt.symbolic_refs) |ref| { switch (ref.target) { .proc => |identity| if (procs_by_identity.get(identity)) |proc_id| { if ((try added.getOrPut(proc_id)).found_existing) continue; @@ -4836,7 +4836,7 @@ const DevProgram = struct { splice: backend.dev.HostSplice, executable: backend.ExecutableMemory, entry_offsets: collections.DenseMap(lir.LIR.LirProcSpecId, usize), - artifacts: ?NativeProcCompiler.Retained, + excerpts: ?NativeProcCompiler.Retained, /// The bytes the program's erased callables reserve before their /// captures (`LirProgram.Result.erased_capture_prefix`). capture_prefix: u32, @@ -4986,7 +4986,7 @@ const DevProgram = struct { .splice = splice, .executable = executable, .entry_offsets = entry_offsets, - .artifacts = artifacts, + .excerpts = artifacts, .capture_prefix = lowered.lir_result.erased_capture_prefix, }; } @@ -5073,9 +5073,9 @@ const DevProgram = struct { /// The image's code as object-cache artifacts, carrying the completed /// values it reads. Its procedures are a dev build's object code, so a /// build splices them as its own. - fn extractArtifacts(self: *DevProgram, lowered: *const lir.CheckedPipeline.LoweredProgram) Allocator.Error!backend.dev.ProcArtifact.Set { + fn extractExcerpts(self: *DevProgram, lowered: *const lir.CheckedPipeline.LoweredProgram) Allocator.Error!backend.dev.CodeExcerpt.Set { const frozen = lowered.frozen_static_data orelse finalizationInvariant("compile-time stage froze no completed values"); - return backend.dev.ProcArtifact.extract( + return backend.dev.CodeExcerpt.extract( backend.HostLirCodeGen, self.allocator, &self.codegen, @@ -5095,7 +5095,7 @@ const DevProgram = struct { self.executable.deinit(); self.splice.deinit(); self.codegen.deinit(); - if (self.artifacts) |*artifacts| artifacts.deinit(); + if (self.excerpts) |*excerpts| excerpts.deinit(); self.slots.deinit(); self.static_strings.deinit(); self.* = undefined; diff --git a/src/lir/lir_image.zig b/src/lir/lir_image.zig index c0fe7348989..ac850636eaf 100644 --- a/src/lir/lir_image.zig +++ b/src/lir/lir_image.zig @@ -803,20 +803,20 @@ pub fn buildSidecarBlob( return serializeGrowing(gpa, serializeSidecarInto, .{lowered}); } -/// The sidecar of a canonical dev image (design.md "Canonical Dev Images"): +/// The sidecar of a arranged dev image (design.md "Deterministic Dev Images"): /// its layouts are the fixed ones, then those `order` lists, numbered as the /// image's code loads them, then every layout those and the Boxy tables /// reach, in the order a walk from them first meets each; its erased /// argument layouts are `order`'s runs. Which procedures this program /// lowered therefore changes nothing in it. -pub fn buildCanonicalSidecarBlob( +pub fn buildArrangedSidecarBlob( gpa: std.mem.Allocator, lowered: *const Program.Result, order: *const RuntimeTables.Order, ) (ImageError || std.mem.Allocator.Error)!SidecarBlob { var canonical = try CanonicalLayouts.init(gpa, lowered, order); defer canonical.deinit(gpa); - return serializeGrowing(gpa, serializeCanonicalSidecarInto, .{ lowered, &canonical, order }); + return serializeGrowing(gpa, serializeArrangedSidecarInto, .{ lowered, &canonical, order }); } /// Serialize into a buffer that grows until the sidecar fits, then copy it @@ -920,7 +920,7 @@ const CanonicalLayouts = struct { } }; -fn serializeCanonicalSidecarInto( +fn serializeArrangedSidecarInto( gpa: std.mem.Allocator, buffer: []align(16) u8, lowered: *const Program.Result, @@ -1729,7 +1729,7 @@ test "persisted image bytes do not depend on what store memory held before" { // Canonicalization happens in the image's copy; the store keeps its bytes. try std.testing.expectEqualSlices(u8, source_stmts, std.mem.sliceAsBytes(store.cf_stmts.unsafeRawItemsForView())); - // The canonical image still holds the values the store holds. + // The arranged image still holds the values the store holds. var view = try viewMappedImageWithAllocator(header, buffer.ptr, fba.end_index, .u64, gpa); defer view.deinit(); try std.testing.expectEqual(local, view.store.getCFStmtImmediate(ret).ret.value); diff --git a/src/lir/mod.zig b/src/lir/mod.zig index 7c7ec4923f4..a3fdba1cef4 100644 --- a/src/lir/mod.zig +++ b/src/lir/mod.zig @@ -72,7 +72,7 @@ pub const ErasedOwner = @import("erased_owner.zig"); pub const ArcDismantle = @import("arc_dismantle.zig"); /// Shared-memory ARC-inserted LIR image for interpreter-shim execution. pub const LirImage = @import("lir_image.zig"); -/// The Boxy runtime table numbers a canonical dev image uses. +/// The Boxy runtime table numbers a arranged dev image uses. pub const RuntimeTables = @import("runtime_tables.zig"); test "LIR image tests" { diff --git a/src/lir/runtime_tables.zig b/src/lir/runtime_tables.zig index 546fe61f90d..de707b31fc6 100644 --- a/src/lir/runtime_tables.zig +++ b/src/lir/runtime_tables.zig @@ -1,14 +1,14 @@ -//! The Boxy runtime table numbers a canonical dev image uses (design.md -//! "Canonical Dev Images"). +//! The Boxy runtime table numbers a arranged dev image uses (design.md +//! "Deterministic Dev Images"). //! //! A program numbers its layouts, and the runs of erased argument layouts its //! erased procedures take, in the order lowering happened to reach them, and //! which procedures a build lowered depends on what the object cache served //! it. The image's code passes those numbers to the Boxy runtime, which reads -//! the sidecar tables by them, so a canonical image renumbers what its placed -//! code uses: each number in the order the canonical code first loads it. +//! the sidecar tables by them, so a arranged image renumbers what its placed +//! code uses: each number in the order the arranged code first loads it. //! The sidecar is then built from exactly those entries -//! (`LirImage.buildCanonicalSidecarBlob`). +//! (`LirImage.buildArrangedSidecarBlob`). const std = @import("std"); const core = @import("lir_core"); @@ -21,12 +21,12 @@ const Allocator = std.mem.Allocator; /// program and never renumbered. pub const fixed_layout_count: u32 = @backingInt(layout.Idx.i64x2) + 1; -/// What a canonical image's code loads, in canonical order. +/// What a arranged image's code loads, in arranged order. pub const Order = struct { /// The program layout each canonical layout index past the fixed ones /// names, by canonical index minus `fixed_layout_count`. layouts: std.ArrayList(layout.Idx) = .empty, - /// The program's erased argument layout runs, in canonical order; each + /// The program's erased argument layout runs, in arranged order; each /// one's canonical start is the sum of the lengths before it. erased_arg_layouts: std.ArrayList(LIR.BoxySpan) = .empty, From 9d18359c20cc407de90945a51a10341ddd061c14 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 10:19:30 -0400 Subject: [PATCH 12/97] Reuse immutable specialization interfaces across application edits --- design.md | 88 ++++- src/base/dev_diagnostics.zig | 3 + src/cli/main.zig | 10 +- src/cli/pack_store.zig | 242 +++++++++++- src/cli/test/parallel_cli_runner.zig | 47 ++- src/postcheck/common.zig | 11 + src/postcheck/mod.zig | 1 + src/postcheck/monotype/ast.zig | 34 ++ src/postcheck/monotype/interface_summary.zig | 356 ++++++++++++++++++ src/postcheck/monotype/lower.zig | 225 ++++++++++- src/postcheck/monotype/specialize.zig | 6 + test/cli/cache_work_bench.py | 2 +- test/cli/interface_cache_bench.py | 115 ++++++ .../persistent_interface_cache/Generic.roc | 10 + test/cli/persistent_interface_cache/main.roc | 18 + 15 files changed, 1150 insertions(+), 18 deletions(-) create mode 100644 src/postcheck/monotype/interface_summary.zig create mode 100644 test/cli/interface_cache_bench.py create mode 100644 test/cli/persistent_interface_cache/Generic.roc create mode 100644 test/cli/persistent_interface_cache/main.roc diff --git a/design.md b/design.md index b05fd19b104..ad4b66bee42 100644 --- a/design.md +++ b/design.md @@ -680,12 +680,96 @@ Same-program retained fragments remain exact-target for now. Their separate `rejected_target` metric exposes this limitation; revision, transferred LIR and layout ownership, CPU, static-data and runtime-context checks are unchanged. +### Persistent Specialization Interface Summaries + +The earliest reusable result of an open specialization request is its settled +interface relation, before the callee's body reservation and machine-code +lookup. Closed interfaces already have a complete checked root and publish no +persistent record. Checking still supplies the template, dispatch relations and +evidence; the consumer still instantiates its own request and substitution cells. A +producer records the relation only after its recursive component has reached +its fixed point, using the same detached, parametric inputs as in-process +interface replay. A multi-request recursive component with parametric holes +has no independently reusable summary. + +An interface summary is either an explicit unchanged result or immutable +constraints over locally numbered nodes and field-kind cells. Persistent +constraints refer to settled types through the current request's leaf positions +or explicit primitive types; they do not serialize solver stores or retain +producer-owned type ids. A producer whose result requires another settled type +publishes no persistent summary; checked default-expression addresses likewise +remain unrepresented. Named checked provenance in open constraints +must still name a currently present exact checked module. Consumers validate +all indices, tags, lengths and provenance before instantiating fresh nodes. +Missing, incompatible, corrupt or unrepresentable optional records are misses. + +The provider key includes the exact checked callee code-generation key, +the method scope's checked value context and ordered +lookup-scope contexts, the program method context, the callee's explicit import +closure, and lowering +policy. These use the identities already produced for literal value reuse: +top modules contribute their checked type/method world and imports; other +modules contribute exact checked keys. Platform requirements reached from +methods contribute their exported definition identities. The callee's transitive +import closure additionally contributes exact code-generation keys and the +requirement identities reached from those modules' exports. Unrelated exports +(such as the platform's call to application main) do not become dependencies +of every imported callee. This retains transitive method/conversion/body +dependencies without making an +unrelated top-level application expression part of every imported callee's +interface key. A scope without that explicit context has no persistent offer. +Paired platform templates keep their code-generation key, which includes the +application bindings. The request key includes the procedure/template ordinals, +the full captured input and +canonical evidence identity. Evidence views of top modules use the checker's +explicit value context, which describes their type/method world independently +of unrelated expression bodies. Procedure targets retain their exact module and +template identities; local-procedure evidence has no persistent offer because +its view also owns its body address. Checked callable-type ordinals are +provenance, not evidence identity; their producer-issued canonical keys remain +part of that digest, as in `Ast.fnEvidenceDigest`. Compiler, target and optimization namespaces remain those +of the object store. + +This boundary is a type relation, not a code or demand certificate. It neither +evaluates roots nor publishes their observations, changes demand, or promises +machine code. Exact callable specialization, compile-time-read demand, +ownership interfaces, native-only code-provider admission and source/debug +validation still happen at the existing body/code boundary. Request leaf +references preserve current-caller type provenance. After an application edit, +a callee in another unchanged module can reuse the relation; +changing the callee's own module still changes its identity. Finer reuse within +that module requires producer-issued template identities and relocatable +checked provenance, not deletion of its checked-module guard. + +The post-evaluation normalization pass remains a specialization against stored +completed constants. It may consume these immutable interface summaries and +existing validated normalized machine-code offers, but never reuse the first +pass's slot-read bodies. Interface relations do not carry compile-time values: +constant-function evidence and the current request still identify the selected +relation. Thus a fresh value and a restored value enter the same normalization +path, and unused failing roots remain undemanded. + +Persistent records are batched by checked module and context in +`interfaces/.ris` under the object store's procedure domain. +Different procedures remain separate request keys. Compatible native procedure +domains share them; specialization strategies and incompatible targets stay +separate. The binary container validates its provider, lengths and payload +digest before exposing any request. A build reads each provider once, including +absent providers; explicit index refresh invalidates that snapshot. Accepted +coordinator results update the resident provider immediately, +so local publication invalidates absence. Publication writes each changed +provider once in the reported runtime-cache phase and leaves identical records +alone. Concurrent atomic replacements can discard optional variants but cannot +expose partial records or change correctness. No historical providers are scanned. +`ROC_DISABLE_PERSISTENT_INTERFACES` is a developer-diagnostic-only comparison +switch, read solely through `base.dev_diagnostics`. + ### Object Cache Store The store has two namespaces under `// objects-v/`. Every dev procedure record (offers, shared metadata, -payloads, completion markers and discovery summaries) lives in the procedure -namespace `proc--dev/` of the build's `NativeProcDomain` (see "Native +payloads, completion markers, discovery summaries and interface summaries) lives +in the procedure namespace `proc--dev/` of the build's `NativeProcDomain` (see "Native dev procedure compatibility"), so publication and lookup both use it, and a musl and a glibc build at the same CPU floor read each other's records. The exact-target namespace `-/` holds recorded program outputs diff --git a/src/base/dev_diagnostics.zig b/src/base/dev_diagnostics.zig index d7aa4bb30bb..c51121e8262 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -24,6 +24,8 @@ pub const Var = enum { pack_trace, /// Print how many procedures a dev build served from cached packs. pack_stats, + /// Measure specialization with persistent interface replay disabled. + disable_persistent_interfaces, /// Print every compile-time demand decision. ctfe_demand_trace, /// Print what each module's compile-time evaluation reused, verified, and stored. @@ -44,6 +46,7 @@ pub const Var = enum { .spec_census => "ROC_SPEC_CENSUS", .pack_trace => "ROC_PACK_TRACE", .pack_stats => "ROC_PACK_STATS", + .disable_persistent_interfaces => "ROC_DISABLE_PERSISTENT_INTERFACES", .ctfe_demand_trace => "ROC_CTFE_DEMAND_TRACE", .ctfe_reuse_trace => "ROC_CTFE_REUSE_TRACE", .dev_pack_objects => "ROC_DEV_PACK_OBJECTS", diff --git a/src/cli/main.zig b/src/cli/main.zig index 5ad224f5365..8caee2f07a1 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -11852,7 +11852,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu for (lowered.lir_result.store.getProcSpecs()) |proc| { if (proc.c_abi_entry) linked_procs += 1; } - std.debug.print("pack hits: {d} external procs: {d} linked procs: {d} evaluator artifacts: {d} packs loaded: {d} keys: {d} cache bytes loaded: {d}\n", .{ packs.hits, external_procs, linked_procs, packs.artifacts_served, packs.packs.items.len, packs.specs.count(), packs.bytes_loaded }); + std.debug.print("pack hits: {d} external procs: {d} linked procs: {d} evaluator artifacts: {d} packs loaded: {d} keys: {d} cache bytes loaded: {d}\n", .{ packs.hits, external_procs, linked_procs, packs.artifacts_served, packs.packs.items.len, packs.specs.count(), packs.bytes_loaded +| packs.interface_bytes_loaded }); } const entrypoints = try nativeBuildEntrypoints(ctx, root_artifact, &lowered); @@ -11936,6 +11936,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu if (object_store != null and diag.errors == 0) { const store = object_store.?; reporter.begin("Publishing Runtime Cache"); + if (loaded_packs) |packs| try packs.publishInterfaces(); try writePacksToStore(ctx, &build_env, store, root_artifact, &lowered, if (object_compiler.captured_artifacts) |*set| set else null); reporter.end(); } @@ -17909,10 +17910,15 @@ fn monotypeGraphCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [28] }; } -fn monotypeBodyCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [26]progress.Counter { +fn monotypeBodyCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [31]progress.Counter { const body = diagnostics.body; return .{ .{ .name = "Interface summary hits", .count = diagnostics.specialization.interface_summary_hits }, + .{ .name = "Persistent interface hits", .count = diagnostics.specialization.interface_persistent_hits }, + .{ .name = "Persistent interface misses", .count = diagnostics.specialization.interface_persistent_misses }, + .{ .name = "Persistent interface invalid", .count = diagnostics.specialization.interface_persistent_invalid }, + .{ .name = "Persistent interface records", .count = diagnostics.specialization.interface_persistent_records }, + .{ .name = "Persistent interface unrepresentable", .count = diagnostics.specialization.interface_persistent_unrepresentable }, .{ .name = "Interface summary expansions", .count = diagnostics.specialization.interface_summary_expansions }, .{ .name = "Body contexts created", .count = body.body_contexts_created }, .{ .name = "Type instantiation scopes", .count = body.instantiation_scopes_created }, diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 3e65912daa5..e1d466548d5 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -659,7 +659,7 @@ test "object cache interleaved writers expose only complete final bytes" { try std.testing.expectEqual(@as(usize, 1), count); } -test "native procedure domain shares packs but isolates final outputs and linked objects" { +test "native procedure domain shares packs and interfaces but isolates final outputs and linked objects" { const a = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); @@ -677,6 +677,17 @@ test "native procedure domain shares packs but isolates final outputs and linked const key: [32]u8 = @splat(2); try musl.write(.local, identity, key, "procedure pack"); try std.testing.expect(try glibc.has(.local, identity, key)); + var interface_producer = LoadedPacks.init(a); + defer interface_producer.deinit(); + interface_producer.interface_store = &musl; + try LoadedPacks.recordInterface(&interface_producer, identity, key, "interface relation"); + try interface_producer.publishInterfaces(); + var interface_consumer = LoadedPacks.init(a); + defer interface_consumer.deinit(); + interface_consumer.interface_store = &glibc; + const interface_bytes = (try LoadedPacks.loadInterface(&interface_consumer, a, identity, key)).?; + defer a.free(interface_bytes); + try std.testing.expectEqualStrings("interface relation", interface_bytes); // Indexed offers, metadata, payloads and completion markers share the // procedure domain too. const offered_key: [32]u8 = @splat(3); @@ -705,6 +716,8 @@ test "native procedure domain shares packs but isolates final outputs and linked try std.testing.expect(!try incompatible.hasIndexed(.local, identity, key)); var packs = LoadedPacks.init(a); defer packs.deinit(); + packs.interface_store = &incompatible; + try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&packs, a, identity, key)); try incompatible.loadIdentity(io, &packs, .local, identity); try packs.indexPacks(); try std.testing.expect(packs.specCacheLookup().lookup(offered_key, .dev_code, &.{}) == null); @@ -713,6 +726,10 @@ test "native procedure domain shares packs but isolates final outputs and linked defer boxy.deinit(); try std.testing.expect(!try boxy.has(.local, identity, key)); try std.testing.expect(!try boxy.hasIndexed(.local, identity, key)); + var boxy_interfaces = LoadedPacks.init(a); + defer boxy_interfaces.deinit(); + boxy_interfaces.interface_store = &boxy; + try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&boxy_interfaces, a, identity, key)); for ([_][]const u8{ "speed", "size" }) |level| { var left = try Store.init(a, config, .x64musl, level); defer left.deinit(); @@ -1003,6 +1020,17 @@ pub const LoadedPacks = struct { }; const IndexedResult = enum { hit, miss, unavailable }; + /// One checked module/context provider, shared by its procedure requests. + const InterfaceFamily = struct { + records: std.AutoArrayHashMapUnmanaged([32]u8, []const u8) = .empty, + loaded: bool = false, + dirty: bool = false, + }; + interface_mutex: std.Io.Mutex = .init, + /// Worker reads update this under interface_mutex; reported after joining. + interface_bytes_loaded: u64 = 0, + interface_families: std.AutoHashMapUnmanaged([32]u8, *InterfaceFamily) = .empty, + interface_store: ?*const Store = null, allocator: Allocator, index_dirs: std.ArrayList([]const u8) = .empty, index_io: ?std.Io = null, @@ -1112,6 +1140,8 @@ pub const LoadedPacks = struct { /// or in the next compilation; a miss never promises code availability. pub fn refreshIndex(self: *LoadedPacks) void { self.negative.clearRetainingCapacity(); + var families = self.interface_families.valueIterator(); + while (families.next()) |family| family.*.loaded = false; } /// Register freshly published offers in the checked program's provider order. @@ -1740,6 +1770,13 @@ pub const LoadedPacks = struct { const stats = self.index_stats; std.debug.print("pack index: index_bytes={d} bucket_opens={d} bucket_absent={d} negative_hits={d} metadata_opens={d} metadata_bytes={d} candidates={d} duplicates={d} payload_opens={d} payload_files={d} payload_bytes={d} decoded_artifacts={d} requested_artifacts={d} served_artifacts={d} allocation_failures={d} requested={d} served={d} misses={d} source_ineligible={d} linked_ineligible={d} corrupt={d} missing={d}\n", .{ stats.index_bytes, stats.bucket_opens, stats.bucket_absent, stats.negative_hits, stats.metadata_opens, stats.metadata_bytes, stats.candidates, stats.duplicates, stats.payload_opens, stats.payload_files, stats.payload_bytes, stats.decoded_artifacts, stats.requested_artifacts, stats.served_artifacts, stats.allocation_failures, stats.requested, stats.served, stats.misses, stats.source_ineligible, stats.linked_ineligible, stats.corrupt, stats.missing }); } + var interface_families = self.interface_families.valueIterator(); + while (interface_families.next()) |family| { + for (family.*.records.values()) |bytes| self.allocator.free(bytes); + family.*.records.deinit(self.allocator); + self.allocator.destroy(family.*); + } + self.interface_families.deinit(self.allocator); self.negative.deinit(self.allocator); self.provider_ranks.deinit(self.allocator); self.index_roots.deinit(self.allocator); @@ -1781,7 +1818,143 @@ pub const LoadedPacks = struct { /// The lookup Monotype consults at reservation. pub fn specCacheLookup(self: *LoadedPacks) Common.SpecCacheLookup { - return .{ .context = @ptrCast(self), .find = findSpec, .find_discovery = findDiscovery }; + return .{ .context = @ptrCast(self), .find = findSpec, .find_discovery = findDiscovery, .interfaces = if (!base.dev_diagnostics.isSet(.disable_persistent_interfaces)) .{ .context = self, .load = loadInterface, .record = recordInterface } else null }; + } + + fn interfaceStore(self: *const LoadedPacks) ?*const Store { + // The checked-module session binds its store after the runtime + // capability is declared, before any specialization task runs. + return if (self.pending) |pending| pending.store else self.interface_store; + } + + fn interfacePath(store: *const Store, allocator: Allocator, family: [32]u8) Allocator.Error![]u8 { + const name = std.fmt.bytesToHex(family, .lower) ++ ".ris".*; + return std.fs.path.join(allocator, &.{ store.procedure_root, "interfaces", &name }); + } + + /// Caller holds interface_mutex. One read per family and snapshot, including + /// absence. Publications update the resident family directly. + fn interfaceFamily(self: *LoadedPacks, store: *const Store, key: [32]u8) Allocator.Error!*InterfaceFamily { + const entry = try self.interface_families.getOrPut(self.allocator, key); + if (!entry.found_existing) { + errdefer _ = self.interface_families.remove(key); + const family = try self.allocator.create(InterfaceFamily); + family.* = .{}; + entry.value_ptr.* = family; + } + const family = entry.value_ptr.*; + if (family.loaded) return family; + const path = try interfacePath(store, self.allocator, key); + defer self.allocator.free(path); + const bytes = std.Io.Dir.cwd().readFileAlloc(store.roc_ctx.std_io, path, self.allocator, .limited(64 * 1024 * 1024)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.FileNotFound => { + family.loaded = true; + return family; + }, + else => return family, + }; + defer self.allocator.free(bytes); + self.interface_bytes_loaded +|= bytes.len; + self.readInterfaceFamily(family, key, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidSummary => return family, + }; + family.loaded = true; + return family; + } + + const InterfaceDecodeError = Allocator.Error || error{InvalidSummary}; + + fn readInterfaceFamily(self: *LoadedPacks, family: *InterfaceFamily, key: [32]u8, bytes: []const u8) InterfaceDecodeError!void { + if (bytes.len < 76 or !std.mem.eql(u8, bytes[0..8], "RIFACE01") or !std.mem.eql(u8, bytes[8..40], &key)) return error.InvalidSummary; + if (!std.mem.eql(u8, &Index.digest(bytes[72..]), bytes[40..72])) return error.InvalidSummary; + const count = std.mem.readInt(u32, bytes[72..76], .little); + var offset: usize = 76; + // Validate the whole container before installing any member. + for (0..count) |_| { + if (bytes.len - offset < 36) return error.InvalidSummary; + const len = std.mem.readInt(u32, bytes[offset + 32 ..][0..4], .little); + offset += 36; + if (len > bytes.len - offset) return error.InvalidSummary; + offset += len; + } + if (offset != bytes.len) return error.InvalidSummary; + offset = 76; + for (0..count) |_| { + const request = bytes[offset..][0..32].*; + const len = std.mem.readInt(u32, bytes[offset + 32 ..][0..4], .little); + offset += 36; + if (!family.records.contains(request)) { + const owned = try self.allocator.dupe(u8, bytes[offset..][0..len]); + errdefer self.allocator.free(owned); + try family.records.put(self.allocator, request, owned); + } + offset += len; + } + } + + fn loadInterface(context: *anyopaque, allocator: Allocator, family_key: [32]u8, request: [32]u8) Allocator.Error!?[]u8 { + const self: *LoadedPacks = @ptrCast(@alignCast(context)); + if (self.defer_code_lookup) return null; + const store = self.interfaceStore() orelse return null; + self.interface_mutex.lockUncancelable(store.roc_ctx.std_io); + defer self.interface_mutex.unlock(store.roc_ctx.std_io); + const family = try self.interfaceFamily(store, family_key); + const bytes = family.records.get(request) orelse return null; + return try allocator.dupe(u8, bytes); + } + + fn recordInterface(context: *anyopaque, family_key: [32]u8, request: [32]u8, bytes: []const u8) Allocator.Error!void { + const self: *LoadedPacks = @ptrCast(@alignCast(context)); + if (self.defer_code_lookup) return; + const store = self.interfaceStore() orelse return; + self.interface_mutex.lockUncancelable(store.roc_ctx.std_io); + defer self.interface_mutex.unlock(store.roc_ctx.std_io); + const family = try self.interfaceFamily(store, family_key); + if (family.records.get(request)) |existing| if (std.mem.eql(u8, existing, bytes)) return; + const owned = try self.allocator.dupe(u8, bytes); + errdefer self.allocator.free(owned); + const entry = try family.records.getOrPut(self.allocator, request); + if (entry.found_existing) self.allocator.free(entry.value_ptr.*); + entry.value_ptr.* = owned; + family.dirty = true; + } + + /// Publish once per changed module/context provider, within the reported cache + /// publication phase. Concurrent writers can lose optional variants, never + /// mix partial records: every replacement is complete and atomic. + pub fn publishInterfaces(self: *LoadedPacks) Allocator.Error!void { + const store = self.interfaceStore() orelse return; + self.interface_mutex.lockUncancelable(store.roc_ctx.std_io); + defer self.interface_mutex.unlock(store.roc_ctx.std_io); + var families = self.interface_families.iterator(); + while (families.next()) |entry| { + const family = entry.value_ptr.*; + if (!family.dirty) continue; + var bytes = std.ArrayList(u8).empty; + defer bytes.deinit(self.allocator); + try bytes.appendSlice(self.allocator, "RIFACE01"); + try bytes.appendSlice(self.allocator, entry.key_ptr); + try bytes.appendNTimes(self.allocator, 0, 32); + var number: [4]u8 = undefined; + std.mem.writeInt(u32, &number, @intCast(family.records.count()), .little); + try bytes.appendSlice(self.allocator, &number); + for (family.records.keys(), family.records.values()) |request, payload| { + try bytes.appendSlice(self.allocator, &request); + std.mem.writeInt(u32, &number, @intCast(payload.len), .little); + try bytes.appendSlice(self.allocator, &number); + try bytes.appendSlice(self.allocator, payload); + } + bytes.items[40..72].* = Index.digest(bytes.items[72..]); + const path = try interfacePath(store, self.allocator, entry.key_ptr.*); + defer self.allocator.free(path); + _ = store.publishIndexedPath(path, bytes.items) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.PackWriteFailed => continue, + }; + family.dirty = false; + } } /// The artifact source the object compiler splices from. @@ -2538,3 +2711,68 @@ test "indexed object cache routes a package offer to producer storage outside it _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; try std.testing.expectEqualStrings("package code", packs.artifacts.get(@splat(1)).?.set.artifacts[0].code); } + +test "interface provider capability stays stable before store registration" { + var packs = LoadedPacks.init(std.testing.allocator); + defer packs.deinit(); + const before = packs.specCacheLookup(); + var fixture: IndexFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + packs.interface_store = &fixture.store; + try std.testing.expect(std.meta.eql(before, packs.specCacheLookup())); +} + +test "persistent interface families invalidate misses, share records, and reject corruption" { + var fixture: IndexFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + const allocator = std.testing.allocator; + const family: [32]u8 = @splat(42); + const first: [32]u8 = @splat(1); + const second: [32]u8 = @splat(2); + var producer = LoadedPacks.init(allocator); + defer producer.deinit(); + producer.interface_store = &fixture.store; + try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&producer, allocator, family, first)); + try LoadedPacks.recordInterface(&producer, family, first, "one"); + try LoadedPacks.recordInterface(&producer, family, second, "two"); + try producer.publishInterfaces(); + try std.testing.expectEqual(@as(usize, 1), producer.interface_families.count()); + try std.testing.expect(!producer.interface_families.get(family).?.dirty); + try LoadedPacks.recordInterface(&producer, family, first, "one"); + try std.testing.expect(!producer.interface_families.get(family).?.dirty); + var consumer = LoadedPacks.init(allocator); + defer consumer.deinit(); + consumer.interface_store = &fixture.store; + const one = (try LoadedPacks.loadInterface(&consumer, allocator, family, first)).?; + defer allocator.free(one); + try std.testing.expectEqualStrings("one", one); + const two = (try LoadedPacks.loadInterface(&consumer, allocator, family, second)).?; + defer allocator.free(two); + try std.testing.expectEqualStrings("two", two); + const third: [32]u8 = @splat(3); + try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&consumer, allocator, family, third)); + try LoadedPacks.recordInterface(&producer, family, third, "three"); + try producer.publishInterfaces(); + try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&consumer, allocator, family, third)); + consumer.refreshIndex(); + const three = (try LoadedPacks.loadInterface(&consumer, allocator, family, third)).?; + defer allocator.free(three); + try std.testing.expectEqualStrings("three", three); + const path = try LoadedPacks.interfacePath(&fixture.store, allocator, family); + defer allocator.free(path); + try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = path, .data = "RIFACE01truncated" }); + var corrupt = LoadedPacks.init(allocator); + defer corrupt.deinit(); + corrupt.interface_store = &fixture.store; + try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&corrupt, allocator, family, first)); + try LoadedPacks.recordInterface(&corrupt, family, first, "repaired"); + try corrupt.publishInterfaces(); + var repaired = LoadedPacks.init(allocator); + defer repaired.deinit(); + repaired.interface_store = &fixture.store; + const value = (try LoadedPacks.loadInterface(&repaired, allocator, family, first)).?; + defer allocator.free(value); + try std.testing.expectEqualStrings("repaired", value); +} diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 857091ca556..e0cda3a5f14 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -447,6 +447,7 @@ const CustomCase = enum { native_build_pack_objects, native_build_pack_hits, early_ctfe_cache, + persistent_interface_cache, check_compile_time_pack, discovery_summaries, ctfe_cache_publication, @@ -594,6 +595,7 @@ const dev_diagnostics_cases = [_]CustomCase{ .native_build_pack_hits, .early_ctfe_cache, .compatible_native_procedure_cache, + .persistent_interface_cache, .shared_early_object_cache, .issue_11627_static_data_names_cache, .issue_11673_callable_cache, @@ -3230,6 +3232,7 @@ const subcommand_cases = [_]CliCase{ // repro for https://github.com/roc-lang/roc/issues/12400: a compiler without developer diagnostics must not print pack statistics for ROC_PACK_STATS. .{ .id = 0, .suite = .subcommands, .name = "issue 12400: roc run --opt=dev ignores ROC_PACK_STATS without developer diagnostics", .backend = .dev, .skip = without_dev_diagnostics_skip, .body = .{ .command = .{ .args = &.{"--opt=dev"}, .roc_file = "test/fx/hello_world.roc", .env = &.{.{ .name = "ROC_PACK_STATS", .value = "1" }}, .stdout_exact = "Hello, world!\n", .not_contains = &.{.{ .stream = .stderr, .text = "pack hits:" }} } } }, .{ .id = 0, .suite = .subcommands, .name = "roc build native dev serves closed specializations from a previous build's packs", .timeout_ms = 600_000, .body = .{ .custom = .native_build_pack_hits } }, + .{ .id = 0, .suite = .subcommands, .name = "persistent interface summaries survive an app edit without changing compile-time demand", .timeout_ms = 600_000, .body = .{ .custom = .persistent_interface_cache } }, .{ .id = 0, .suite = .subcommands, .name = "roc build early CTFE cache skips Monotype bodies on an app-only rebuild", .timeout_ms = 600_000, .body = .{ .custom = .early_ctfe_cache } }, .{ .id = 0, .suite = .subcommands, .name = "roc check reuses the compile-time pack an earlier check published", .timeout_ms = 600_000, .body = .{ .custom = .check_compile_time_pack } }, .{ .id = 0, .suite = .subcommands, .name = "roc check parks settled discovery and still reports an edited literal", .timeout_ms = 600_000, .body = .{ .custom = .discovery_summaries } }, @@ -5213,6 +5216,7 @@ fn runCustomCase( .issue_11627_static_data_names_cache => customIssue11627StaticDataNamesCache(io, allocator, &env, &timer, timeout_ms), .native_build_pack_hits => customNativeBuildPackHits(io, allocator, &env, &timer, timeout_ms), .early_ctfe_cache => customEarlyCtfeCache(io, allocator, &env, &timer, timeout_ms), + .persistent_interface_cache => customPersistentInterfaceCache(io, allocator, &env, &timer, timeout_ms), .check_compile_time_pack => customCheckCompileTimePack(io, allocator, &env, &timer, timeout_ms), .discovery_summaries => customDiscoverySummaries(io, allocator, &env, &timer, timeout_ms), .ctfe_cache_publication => customCtfeCachePublication(io, allocator, &env, &timer, timeout_ms), @@ -9474,6 +9478,35 @@ fn customNativeBuildPackHits( return null; } +/// Reuse interfaces across runtime-only and compile-time application edits. +fn customPersistentInterfaceCache(io: std.Io, allocator: Allocator, env: *const CaseEnv, timer: *harness.Timer, timeout_ms: u64) ?TestResult { + var app: []const u8 = undefined; + if (stageEditableApp(io, allocator, env, timer, .{ + .dir_name = "persistent_interface_cache", + .platform = "test/fx-open/platform/main.roc", + .platform_spelling = "../../fx-open/platform/main.roc", + .sources = &.{ "test/cli/persistent_interface_cache/Generic.roc", "test/cli/persistent_interface_cache/main.roc" }, + .app_name = "main.roc", + }, &app)) |failure| return failure; + if (storeBuildsBehaveIdentically(io, allocator, env, timer, timeout_ms, app, env.dirs.work_dir, "interfaces", .{ + .edit_between_builds = true, + .source_edit = .{ .from = "Generic.f7(args.len().to_u64() + 10)", .to = "Generic.f7(args.len().to_u64() + 9) + 1" }, + .persistent_interface_reuse = true, + .compile_debug = "[dbg] \"persistent interface observation\"", + .uncached_baseline = true, + .stdout = "29\n", + })) |failure| return failure; + // Now force fresh compile-time evaluation and normalization too. The + // unchanged callees must reuse their settled interfaces in both paths. + return storeBuildsBehaveIdentically(io, allocator, env, timer, timeout_ms, app, env.dirs.work_dir, "runtime_interfaces", .{ + .edit_between_builds = true, + .source_edit = .{ .from = "Generic.f7(2)", .to = "Generic.f7(1) + 1" }, + .persistent_interface_reuse = true, + .compile_debug = "[dbg] \"persistent interface observation\"", + .stdout = "29\n", + }); +} + /// A real checked-artifact/object-store workflow, with a changed app so the /// warm build must evaluate roots again rather than replay their stored values. fn customEarlyCtfeCache( @@ -9508,6 +9541,9 @@ fn customEarlyCtfeCache( std.Io.Dir.cwd().writeFile(io, .{ .sub_path = app, .data = with_debug }) catch |err| return customInfraFailure(allocator, timer, "failed to write CTFE app: {}", .{err}); if (storeBuildsBehaveIdentically(io, allocator, env, timer, timeout_ms, app, env.dirs.work_dir, "early_ctfe", .{ + // Body-context counts include speculative worker work. Compare the + // same sequential schedule before and after the edit. + .extra_args = &.{"--jobs=1"}, .edit_between_builds = true, .source_edit = .{ .from = "Closed.total(4)", .to = "Closed.total(3) + 3" }, .evaluator_artifacts = true, @@ -11556,6 +11592,8 @@ const StoreExpectations = struct { evaluator_artifacts: bool = false, /// Require less Monotype body construction, not merely less native emission. monotype_body_reduction: bool = false, + /// Reuse settled interfaces before reconstructing the callee relation. + persistent_interface_reuse: bool = false, /// Root debug output must appear once on every build, including cache reuse. compile_debug: ?[]const u8 = null, /// An undemanded root must produce no observation. @@ -11629,7 +11667,7 @@ fn storeBuildsBehaveIdentically( std.Io.Dir.cwd().writeFile(io, .{ .sub_path = roc_file, .data = edited }) catch |err| return customInfraFailure(allocator, timer, "failed to write {s}: {}", .{ roc_file, err }); } - const build_args: []const []const u8 = if (expect.monotype_body_reduction) + const build_args: []const []const u8 = if (expect.monotype_body_reduction or expect.persistent_interface_reuse) if (expect.uncached_baseline and index == 0) &.{ "build", "--no-cache", "--opt=dev", "--timings", out_arg } else &.{ "build", "--opt=dev", "--timings", out_arg } else if (expect.uncached_baseline and index == 0) &.{ "build", "--no-cache", "--opt=dev", out_arg } else &.{ "build", "--opt=dev", out_arg }; if (forgetRecordedProgramOutputs(io, allocator, env, timer)) |failure| return failure; @@ -11678,6 +11716,13 @@ fn storeBuildsBehaveIdentically( } } } + if (last and expect.persistent_interface_reuse) { + const hits = timingCounter(built.stderr, "Shared Monotype body + dispatch", "Persistent interface hits") orelse + return failureFromRun(allocator, timer, built, "build did not report persistent interface reuse"); + if (hits == 0) return failureFromRun(allocator, timer, built, "app edit reused no persistent interface summary"); + if (std.mem.find(u8, built.stderr, "unused failing interface root") != null) + return failureFromRun(allocator, timer, built, "interface replay demanded an unused failing root"); + } const require_hits = switch (expect.cache_hit_build) { .first_cached => index == @intFromBool(expect.uncached_baseline), .last => last, diff --git a/src/postcheck/common.zig b/src/postcheck/common.zig index ca08190615b..995751afb59 100644 --- a/src/postcheck/common.zig +++ b/src/postcheck/common.zig @@ -561,10 +561,21 @@ pub fn sortProgramModules(identities: [][32]u8) ProgramModules { return identities[0..kept]; } +/// Optional immutable interface records. Reads may run on specialization +/// workers; publication is staged by the coordinator after accepting a result. +/// Loaded bytes belong to the requesting allocator. +pub const InterfaceSummaryCache = struct { + context: *anyopaque, + load: *const fn (*anyopaque, std.mem.Allocator, [32]u8, [32]u8) std.mem.Allocator.Error!?[]u8, + record: *const fn (*anyopaque, [32]u8, [32]u8, []const u8) std.mem.Allocator.Error!void, +}; + /// The object cache's answer for a specialization key, asked when Monotype /// reserves the specialization and before its body exists. The consumer that /// owns the cache supplies the context and the lookup. +/// Also supplies optional earlier specialization-interface summaries. pub const SpecCacheLookup = struct { + interfaces: ?InterfaceSummaryCache = null, context: *anyopaque, /// `takes`: which entries can serve the asking consumer; `modules`: the /// asking program's modules. diff --git a/src/postcheck/mod.zig b/src/postcheck/mod.zig index aeaeac899e7..f65e66e688e 100644 --- a/src/postcheck/mod.zig +++ b/src/postcheck/mod.zig @@ -58,6 +58,7 @@ test "postcheck declarations are referenced" { std.testing.refAllDecls(@import("monotype/lower.zig")); std.testing.refAllDecls(@import("monotype/worker_inputs.zig")); std.testing.refAllDecls(@import("monotype/solve.zig")); + std.testing.refAllDecls(@import("monotype/interface_summary.zig")); std.testing.refAllDecls(@import("monotype/specialize.zig")); std.testing.refAllDecls(@import("monotype/layout_key.zig")); std.testing.refAllDecls(@import("monotype_lifted/ast.zig")); diff --git a/src/postcheck/monotype/ast.zig b/src/postcheck/monotype/ast.zig index 86a029d605d..34bcb6a2600 100644 --- a/src/postcheck/monotype/ast.zig +++ b/src/postcheck/monotype/ast.zig @@ -619,6 +619,33 @@ pub fn fnEvidenceDigest( return .{ .bytes = hasher.finalResult() }; } +/// Name evidence's checked type views by explicit producer value contexts. +/// Procedure targets keep their exact body identities. Callers must exclude +/// local-procedure targets, whose view also owns the local body address. +pub fn fnEvidenceContextDigest( + evidence: check.ConstStore.ConstFnEvidenceVector, + frames: []const check.ConstStore.ConstFnEvidenceFrame, + head: ?u32, + contexts: anytype, +) EvidenceDigest { + const ContextNames = struct { + contexts: @TypeOf(contexts), + fn module(_: @This(), _: *const [32]u8) ?void { + return null; + } + fn template(_: @This(), _: names.ProcTemplate) ?names.TypeDigest { + return null; + } + /// Producer context for an evidence view, when supplied. + pub fn moduleContext(self: @This(), key: *const [32]u8) ?[32]u8 { + return self.contexts.get(key.*); + } + }; + var hasher = TypeDigestHasher.init(); + writeFnEvidence(&hasher, evidence, frames, head, ContextNames{ .contexts = contexts }); + return .{ .bytes = hasher.finalResult() }; +} + /// Names evidence's modules and method templates by content. const ContentEvidenceNames = struct { fn module(_: ContentEvidenceNames, _: *const [32]u8) ?void { @@ -840,6 +867,13 @@ fn methodTargetIdentityEql( } fn writeEvidenceModule(hasher: anytype, bytes: *const [32]u8, site_names: anytype) void { + if (comptime @hasDecl(@TypeOf(site_names), "moduleContext")) { + if (site_names.moduleContext(bytes)) |context| { + writeBytes(hasher, "checked-value-context"); + writeBytes(hasher, &context); + return; + } + } if (site_names.module(bytes)) |_| { writeBytes(hasher, "app-module"); } else { diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig new file mode 100644 index 00000000000..bde3159a850 --- /dev/null +++ b/src/postcheck/monotype/interface_summary.zig @@ -0,0 +1,356 @@ +//! Persistent, request-relative interface constraints. No solver or producer +//! type-store identity crosses this boundary. +const std = @import("std"); +const check = @import("check"); +const base = @import("base"); +const Type = @import("type.zig"); +const Solve = @import("solve.zig"); +const names = check.CheckedNames; +const Allocator = std.mem.Allocator; +const Constraints = Solve.InterfaceConstraints; + +/// A settled relation, independent of mutable solver state. +pub const Summary = union(enum) { unchanged, constraints: Constraints }; +/// Allocation failure or a result outside the persistent summary contract. +pub const EncodeError = Allocator.Error || error{NotRepresentable}; +/// Allocation failure or an invalid optional cache record. +pub const DecodeError = Allocator.Error || error{InvalidSummary}; + +/// Encode settled leaves relative to the producer's captured input. +pub fn encode(allocator: Allocator, types: *Type.Store, ns: *const names.NameStore, leaves: []const Type.TypeId, summary: Summary) EncodeError![]u8 { + var writer: Writer = .{ .allocator = allocator, .types = types, .names = ns, .leaves = leaves }; + defer writer.bytes.deinit(allocator); + const counts: [3]u32 = switch (summary) { + .unchanged => .{ 0, 0, 0 }, + .constraints => |c| .{ @intCast(c.nodes.len), @intCast(c.open_nodes.len), @intCast(c.kinds.len) }, + }; + try writer.write([3]u32, counts); + try writer.write(Summary, summary); + return writer.bytes.toOwnedSlice(allocator); +} + +/// `context.validNamedType` validates checked provenance against the current +/// exact module versions. Returned storage belongs to `arena`. +pub fn decode(arena: Allocator, types: *Type.Store, ns: *names.NameStore, leaves: []const Type.TypeId, bytes: []const u8, context: anytype) DecodeError!Summary { + var reader: Reader(@TypeOf(context)) = .{ .arena = arena, .types = types, .names = ns, .leaves = leaves, .bytes = bytes, .context = context }; + reader.counts = try reader.read([3]u32); + const summary = try reader.read(Summary); + if (reader.bytes.len != 0) return error.InvalidSummary; + switch (summary) { + .unchanged => if (!std.meta.eql(reader.counts, [3]u32{ 0, 0, 0 })) return error.InvalidSummary, + .constraints => |c| { + if (c.nodes.len != reader.counts[0] or c.open_nodes.len != reader.counts[1] or c.kinds.len != reader.counts[2]) return error.InvalidSummary; + for (c.nodes) |node| if (node == .open and node.open >= c.open_nodes.len) return error.InvalidSummary; + for (c.open_nodes) |node| { + if (node.content == .redirect or node.content == .leaf) return error.InvalidSummary; + if (node.related_group != .none and @backingInt(node.related_group) >= c.open_nodes.len) return error.InvalidSummary; + } + }, + } + return summary; +} + +/// Procedure ordinals, captured input and context-qualified evidence identity. +/// Checked callable-type ordinals are provenance; canonical keys carry their +/// relation, as in `Ast.fnEvidenceDigest`. +pub fn requestKey(request: Constraints.Identity, evidence: [32]u8, procedure: [8]u8) [32]u8 { + var hash = std.crypto.hash.sha2.Sha256.init(.{}); + hash.update("roc.interface-request.v2"); + hash.update(&procedure); + hash.update(request.bytes); + hash.update(&evidence); + const result = hash.finalResult(); + if (base.dev_diagnostics.isSet(.pack_trace)) std.debug.print("interface request key={x} evidence={x} leaves={d}\n", .{ result, evidence, request.leaves.len }); + return result; +} + +const Writer = struct { + allocator: Allocator, + types: *Type.Store, + names: *const names.NameStore, + leaves: []const Type.TypeId, + bytes: std.ArrayList(u8) = .empty, + + fn write(self: *Writer, comptime T: type, value: T) EncodeError!void { + if (T == Type.TypeId) { + for (self.leaves, 0..) |leaf, index| if (leaf == value or + (std.meta.eql(self.types.representationDigestCached(self.names, leaf, null), self.types.representationDigestCached(self.names, value, null)) and + try self.types.typeEql(self.names, leaf, value))) + { + try self.write(u8, 0); + return self.write(u32, @intCast(index)); + }; + const content = self.types.get(value); + if (content != .primitive) return error.NotRepresentable; + try self.write(u8, 1); + return self.write(Type.Primitive, content.primitive); + } + if (T == Type.MaybeTypeId) return self.write(?Type.TypeId, value.get()); + if (T == names.ModuleIdentityId) return self.write([32]u8, self.names.moduleIdentityBytes(value).*); + if (T == names.TypeNameId) return self.write([]const u8, self.names.typeNameText(value)); + if (T == names.RecordFieldNameId) return self.write([]const u8, self.names.recordFieldLabelText(value)); + if (T == names.TagNameId) return self.write([]const u8, self.names.tagLabelText(value)); + // A default's expression address needs a separate relocatable producer + // contract; it is not inferred from its current type or source spelling. + if (T == Type.FieldDefault) return error.NotRepresentable; + switch (@typeInfo(T)) { + .@"struct" => |info| inline for (info.field_names, info.field_types) |field, F| try self.write(F, @field(value, field)), + .@"union" => |info| { + try self.write(info.tag_type.?, std.meta.activeTag(value)); + inline for (info.field_names, info.field_types) |field, F| if (std.meta.activeTag(value) == @field(info.tag_type.?, field)) return self.write(F, @field(value, field)); + unreachable; // activeTag(value) names a field handled by the loop above. + }, + .optional => |info| { + try self.write(bool, value != null); + if (value) |v| try self.write(info.child, v); + }, + .pointer => |info| switch (info.size) { + .slice => { + try self.write(u32, std.math.cast(u32, value.len) orelse return error.NotRepresentable); + for (value) |v| try self.write(info.child, v); + }, + .one => try self.write(info.child, value.*), + .many, .c => @compileError("unbounded summary pointer"), + }, + .array => |info| for (value) |v| try self.write(info.child, v), + .@"enum" => |info| try self.write(info.tag_type, @backingInt(value)), + .bool => try self.write(u8, @intFromBool(value)), + .int => { + const U = @Int(.unsigned, @bitSizeOf(T)); + const size = (@bitSizeOf(T) + 7) / 8; + var bits: U = @bitCast(value); + for (0..size) |_| { + try self.bytes.append(self.allocator, @truncate(@as(u64, bits))); + bits = std.math.shr(U, bits, 8); + } + }, + .void => {}, + .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), + } + } +}; + +fn Reader(comptime Context: type) type { + return struct { + const Self = @This(); + arena: Allocator, + types: *Type.Store, + names: *names.NameStore, + leaves: []const Type.TypeId, + bytes: []const u8, + context: Context, + counts: [3]u32 = .{ 0, 0, 0 }, + depth: usize = 0, + + fn read(self: *Self, comptime T: type) DecodeError!T { + if (self.depth == 128) return error.InvalidSummary; + self.depth += 1; + defer self.depth -= 1; + if (T == Type.TypeId) return switch (try self.read(u8)) { + 0 => blk: { + const index = try self.read(u32); + if (index >= self.leaves.len) return error.InvalidSummary; + break :blk self.leaves[index]; + }, + 1 => try self.types.internPrimitive(self.names, try self.read(Type.Primitive)), + else => error.InvalidSummary, + }; + if (T == Type.MaybeTypeId) return .of(try self.read(?Type.TypeId)); + if (T == names.ModuleIdentityId) { + const digest = try self.read([32]u8); + if (!self.context.validModuleIdentity(digest)) return error.InvalidSummary; + return self.names.internModuleIdentity(&digest); + } + if (T == names.TypeNameId) return self.names.internTypeName(try self.read([]const u8)); + if (T == names.RecordFieldNameId) return self.names.internRecordFieldLabel(try self.read([]const u8)); + if (T == names.TagNameId) return self.names.internTagLabel(try self.read([]const u8)); + if (T == Type.FieldDefault) return error.InvalidSummary; + if (T == Solve.NodeId or T == Solve.MaybeNodeId or T == Solve.FieldKindId) { + const index = try self.read(u32); + if (T == Solve.MaybeNodeId and index == std.math.maxInt(u32)) return @fromBackingInt(index); + if (index >= self.counts[if (T == Solve.FieldKindId) 2 else 0]) return error.InvalidSummary; + return @fromBackingInt(index); + } + const result: T = switch (@typeInfo(T)) { + .@"struct" => |info| blk: { + var value: T = undefined; + inline for (info.field_names, info.field_types) |field, F| @field(value, field) = try self.read(F); + break :blk value; + }, + .@"union" => |info| blk: { + const tag = try self.read(info.tag_type.?); + inline for (info.field_names, info.field_types) |field, F| if (tag == @field(info.tag_type.?, field)) break :blk @unionInit(T, field, try self.read(F)); + return error.InvalidSummary; + }, + .optional => |info| if (try self.read(bool)) try self.read(info.child) else null, + .pointer => |info| blk: { + if (info.size == .one) { + const value = try self.arena.create(info.child); + value.* = try self.read(info.child); + break :blk value; + } + if (info.size != .slice) @compileError("unbounded summary pointer"); + const len = try self.read(u32); + // Every supported element consumes at least one byte. + if (len > self.bytes.len) return error.InvalidSummary; + const values = try self.arena.alloc(info.child, len); + for (values) |*value| value.* = try self.read(info.child); + break :blk values; + }, + .array => |info| blk: { + var value: T = undefined; + for (&value) |*v| v.* = try self.read(info.child); + break :blk value; + }, + .@"enum" => |info| blk: { + const value = try self.read(info.tag_type); + if (info.mode == .exhaustive) { + inline for (info.field_values) |v| if (value == v) break :blk @fromBackingInt(value); + return error.InvalidSummary; + } + break :blk @fromBackingInt(value); + }, + .bool => switch (try self.read(u8)) { + 0 => false, + 1 => true, + else => return error.InvalidSummary, + }, + .int => blk: { + const U = @Int(.unsigned, @bitSizeOf(T)); + const size = (@bitSizeOf(T) + 7) / 8; + if (size > self.bytes.len) return error.InvalidSummary; + if (@bitSizeOf(T) % 8 != 0 and self.bytes[size - 1] >= (@as(u16, 1) << (@bitSizeOf(T) % 8))) return error.InvalidSummary; + var value: U = 0; + for (self.bytes[0..size], 0..) |b, i| value |= std.math.shl(U, @as(U, @truncate(b)), i * 8); + self.bytes = self.bytes[size..]; + break :blk @bitCast(value); + }, + .void => {}, + .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), + }; + if (T == Type.NamedType) if (!self.context.validNamedType(result)) return error.InvalidSummary; + return result; + } + }; +} + +test "persistent interface constraints relocate request leaves and preserve cycles" { + const allocator = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + var ns = names.NameStore.init(allocator); + defer ns.deinit(); + var source = Type.Store.init(allocator); + defer source.deinit(); + var destination = Type.Store.init(allocator); + defer destination.deinit(); + const source_ty = try source.internPrimitive(&ns, .u64); + _ = try destination.internPrimitive(&ns, .bool); + const destination_ty = try destination.internPrimitive(&ns, .u64); + const input: Summary = .{ .constraints = .{ + .roots = &.{@fromBackingInt(0)}, + .nodes = &.{ .{ .open = 0 }, .{ .mono = source_ty } }, + .open_nodes = &.{.{ .content = .{ .func = .{ .args = @constCast(&[_]Solve.NodeId{@fromBackingInt(1)}), .ret = @fromBackingInt(0) } } }}, + .kinds = &.{}, + } }; + const bytes = try encode(allocator, &source, &ns, &.{source_ty}, input); + defer allocator.free(bytes); + const Provenance = struct { + pub fn validNamedType(_: @This(), _: Type.NamedType) bool { + return false; + } + pub fn validModuleIdentity(_: @This(), _: [32]u8) bool { + return false; + } + }; + const result = (try decode(arena.allocator(), &destination, &ns, &.{destination_ty}, bytes, Provenance{})).constraints; + try std.testing.expectEqual(destination_ty, result.nodes[1].mono); + try std.testing.expectEqual(@as(u32, 0), @backingInt(result.open_nodes[0].content.func.ret)); + for (0..bytes.len) |len| try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &destination, &ns, &.{destination_ty}, bytes[0..len], Provenance{})); + const bad = try allocator.dupe(u8, bytes); + defer allocator.free(bad); + // Header declares no node slots, but the root still references slot zero. + @memset(bad[0..4], 0); + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &destination, &ns, &.{destination_ty}, bad, Provenance{})); +} + +test "persistent interface leaves preserve representation beyond semantic equality" { + const allocator = std.testing.allocator; + var ns = names.NameStore.init(allocator); + defer ns.deinit(); + var types = Type.Store.init(allocator); + defer types.deinit(); + const u64_ty = try types.internPrimitive(&ns, .u64); + const alias = try types.add(.{ .named = .{ + .named_type = .{ .module = .{}, .ty = @fromBackingInt(0), .ty_module = .{} }, + .def = .{ .module = try ns.internModuleIdentity(&@as([32]u8, @splat(1))), .type_name = try ns.internTypeName("Alias") }, + .kind = .alias, + .args = Type.Span.empty(), + .backing = .{ .ty = u64_ty, .use = .inspectable }, + } }); + try std.testing.expect(try types.typeEql(&ns, alias, u64_ty)); + const summary: Summary = .{ .constraints = .{ + .roots = &.{@fromBackingInt(0)}, + .nodes = &.{.{ .mono = alias }}, + .open_nodes = &.{}, + .kinds = &.{}, + } }; + try std.testing.expectError(error.NotRepresentable, encode(allocator, &types, &ns, &.{u64_ty}, summary)); +} + +test "persistent interface requests use canonical evidence instead of checked ordinals" { + const Ast = @import("ast.zig"); + const nodes = [_]check.ConstStore.ConstFnEvidence{.{ .target = 0 }}; + var targets = [_]check.ConstStore.ConstFnTargetEvidence{.{ + .view = .{}, + .method = .{ .module_idx = 3, .def_idx = @fromBackingInt(4), .kind = .{ .structural = .parser }, .callable_ty = @fromBackingInt(5) }, + .method_callable_key = .{ .bytes = @splat(1) }, + .instantiation = .{ .view = .{}, .callable_key = .{ .bytes = @splat(2) }, .callable_ty = @fromBackingInt(6) }, + .nested = .from_callable, + }}; + const evidence: check.ConstStore.ConstFnEvidenceVector = .{ .nodes = &nodes, .targets = &targets }; + const frames = [_]check.ConstStore.ConstFnEvidenceFrame{.init(.root, null, 0, 1)}; + const request: Constraints.Identity = .{ .bytes = &.{ 1, 2, 3 }, .leaves = &.{} }; + const original = requestKey(request, Ast.fnEvidenceDigest(evidence, &frames, 0).bytes, @splat(0)); + // Procedures in the same provider file must keep distinct request keys. + try std.testing.expect(!std.meta.eql(original, requestKey(request, Ast.fnEvidenceDigest(evidence, &frames, 0).bytes, .{ 1, 0, 0, 0, 0, 0, 0, 0 }))); + try std.testing.expect(!std.meta.eql(original, requestKey(request, Ast.fnEvidenceDigest(evidence, &frames, 0).bytes, .{ 0, 0, 0, 0, 1, 0, 0, 0 }))); + targets[0].method.callable_ty = @fromBackingInt(7); + targets[0].instantiation.?.callable_ty = @fromBackingInt(8); + try std.testing.expectEqual(original, requestKey(request, Ast.fnEvidenceDigest(evidence, &frames, 0).bytes, @splat(0))); + targets[0].method_callable_key.bytes[0] = 9; + try std.testing.expect(!std.meta.eql(original, requestKey(request, Ast.fnEvidenceDigest(evidence, &frames, 0).bytes, @splat(0)))); +} + +test "persistent interface evidence contexts preserve types and exact procedure bodies" { + const Ast = @import("ast.zig"); + const first: [32]u8 = @splat(1); + const second: [32]u8 = @splat(2); + var contexts = std.AutoHashMap([32]u8, [32]u8).init(std.testing.allocator); + defer contexts.deinit(); + try contexts.put(first, @splat(3)); + try contexts.put(second, @splat(3)); + const nodes = [_]check.ConstStore.ConstFnEvidence{.{ .target = 0 }}; + var targets = [_]check.ConstStore.ConstFnTargetEvidence{.{ + .view = .{ .bytes = first }, + .method = .{ .module_idx = 0, .def_idx = @fromBackingInt(0), .kind = .{ .structural = .parser }, .callable_ty = @fromBackingInt(0) }, + .method_callable_key = .{ .bytes = @splat(4) }, + .instantiation = .{ .view = .{ .bytes = first }, .callable_key = .{ .bytes = @splat(5) }, .callable_ty = @fromBackingInt(0) }, + .nested = .from_callable, + }}; + const evidence: check.ConstStore.ConstFnEvidenceVector = .{ .nodes = &nodes, .targets = &targets }; + const original = Ast.fnEvidenceContextDigest(evidence, &.{}, null, &contexts); + targets[0].view.bytes = second; + targets[0].instantiation.?.view.bytes = second; + try std.testing.expectEqual(original, Ast.fnEvidenceContextDigest(evidence, &.{}, null, &contexts)); + try contexts.put(second, @splat(6)); + try std.testing.expect(!std.meta.eql(original, Ast.fnEvidenceContextDigest(evidence, &.{}, null, &contexts))); + try contexts.put(second, @splat(3)); + targets[0].method.kind = .{ .procedure = .{ + .proc = .{ .artifact = .{ .bytes = first }, .proc_base = @fromBackingInt(0) }, + .template = .{ .artifact = .{ .bytes = first }, .proc_base = @fromBackingInt(0), .template = @fromBackingInt(0) }, + } }; + const procedure = Ast.fnEvidenceContextDigest(evidence, &.{}, null, &contexts); + targets[0].method.kind.procedure.template.artifact.bytes = second; + try std.testing.expect(!std.meta.eql(procedure, Ast.fnEvidenceContextDigest(evidence, &.{}, null, &contexts))); +} diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 4125fbf9d49..2146a0b8d66 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -293,6 +293,7 @@ pub const Options = struct { /// linked, so a linked-object entry (`Common.SpecCacheHit.c_abi_entry`) /// can, and whether its emitter splices dev code. spec_cache_takes: Common.SpecCacheTakes = .dev_code, + interface_cache: ?Common.InterfaceSummaryCache = null, /// Optional deterministic counters for specialization-shape tests. specialization_counters: ?*SpecializationCounters = null, /// Optional deterministic workload diagnostics. The checked pipeline @@ -1570,6 +1571,7 @@ const ModuleViewData = struct { static_dispatch_plans: *const static_dispatch.StaticDispatchPlanTable, method_registry: *const static_dispatch.MethodRegistry, method_lookup_scope: []const checked.ModuleId, + direct_import_module_keys: []const checked.CheckedModuleArtifactKey, resolved_refs: *const checked.ResolvedValueTable, nested_proc_sites: *const checked.NestedProcSiteTable, exported_procedure_bindings: checked.ExportedProcedureBindingView, @@ -4898,6 +4900,7 @@ const SpecJobWorkerInputs = struct { proc_debug_names: bool, interface_summaries: *const SharedSummaries, interface_summary_end: usize, + interface_cache: ?Common.InterfaceSummaryCache, collect_counters: bool, collect_diagnostics: bool, inline_expects: InlineExpectMode, @@ -5315,6 +5318,7 @@ const Builder = struct { field_kind_scope_pool: std.ArrayList(*collections.DenseMap(checked.CheckedTypeId, InstantiatedFieldKind)) = .empty, /// The object cache consulted for closed specializations, if any. spec_cache: ?Common.SpecCacheLookup, + interface_cache: ?Common.InterfaceSummaryCache, spec_cache_takes: Common.SpecCacheTakes, /// The module identities of the checked modules this lowering reads, which /// the object cache serves entries for (`Common.ProgramModules`); built @@ -5339,6 +5343,8 @@ const Builder = struct { /// The program's literal value context, once computed; see /// `literalProgramContext`. literal_program_context: ??[32]u8 = null, + interface_program_context: ??[32]u8 = null, + interface_callee_contexts: std.AutoHashMapUnmanaged([32]u8, ?[32]u8) = .empty, /// See `literalTopModules`; outer null until computed. literal_top_modules: ??std.AutoHashMapUnmanaged([32]u8, [32]u8) = null, /// Top-level statements of each top module whose types a literal value @@ -5648,6 +5654,7 @@ const Builder = struct { .current_region = program.current_region, .proc_debug_names = options.proc_debug_names, .spec_cache = options.spec_cache, + .interface_cache = options.interface_cache orelse if (options.spec_cache) |cache| cache.interfaces else null, .spec_cache_takes = options.spec_cache_takes, .counters = counters, .diagnostics = options.diagnostics, @@ -5817,6 +5824,7 @@ const Builder = struct { .comptime_value_reads = inputs.comptime_value_reads, .literal_roots = inputs.literal_roots, .replay_interface_summaries = inputs.replay_interface_summaries, + .interface_cache = inputs.interface_cache, .post_check_executor = null, .timing = null, }); @@ -5952,6 +5960,7 @@ const Builder = struct { if (self.shared_summaries) |*summaries| summaries.deinit(); self.worker_inputs.deinit(self.allocator); self.interface_summaries.deinit(); + self.interface_callee_contexts.deinit(self.allocator); self.spec_store.deinit(); self.pending_spec_jobs.deinit(self.allocator); self.parked_spec_jobs.deinit(self.allocator); @@ -6158,14 +6167,152 @@ const Builder = struct { defer scratch.deinit(); for (entries) |entry| { _ = scratch.reset(.retain_capacity); - _ = try self.interface_summaries.insertRelocated( + const summary = try self.interface_summaries.insertRelocated( &self.program.types, &self.program.names, entry, try entry.request.copy(scratch.allocator(), relocation), relocation, ); + try self.recordPersistentInterface(.{ .address = entry.address, .evidence = entry.evidence, .request = try entry.request.copy(scratch.allocator(), relocation), .summary = summary }, &self.program.types, &self.program.names); + } + } + + const PersistentInterfaceFamily = struct { provider: [32]u8, procedure: [8]u8 }; + + fn persistentInterfaceFamily(self: *Builder, address: InterfaceReplayAddress) Allocator.Error!?PersistentInterfaceFamily { + if (self.interface_cache == null or address.kind != .procedure) return null; + const callee = self.moduleForKeyBytes(address.family.module) orelse return null; + const template = callee.templates.get(@fromBackingInt(address.family.template)); + // Checking already supplies the complete relation of a closed + // interface; it has no callee relation program to reconstruct. + if (templateInterfaceIsClosed(callee, &template)) return null; + const scope = self.moduleForKeyBytes(address.family.method_scope) orelse return null; + const scope_context = scope.compile_time_root_identities.valueContext() orelse return null; + const program_context = (try self.programValueContext(false)) orelse return null; + var hash = std.crypto.hash.sha2.Sha256.init(.{}); + hash.update("roc.interface-provider.v1"); + hash.update(&(callee.code_generation_key orelse callee.key).bytes); + hash.update(&scope_context); + // Method lookup order is producer data too; a sorted module set does + // not establish the precedence of an ordered lookup scope. + var scope_count: [4]u8 = undefined; + std.mem.writeInt(u32, &scope_count, @intCast(scope.method_lookup_scope.len), .little); + hash.update(&scope_count); + const top = (try self.literalTopModules()).?; + for (scope.method_lookup_scope) |module| { + if (top.get(module.bytes)) |context| { + hash.update(&.{1}); + hash.update(&context); + } else { + hash.update(&.{0}); + hash.update(&module.bytes); + } + } + hash.update(&program_context); + hash.update(&((try self.interfaceCalleeContext(callee)) orelse return null)); + var ordinals: [8]u8 = undefined; + std.mem.writeInt(u32, ordinals[0..4], address.family.proc_base, .little); + std.mem.writeInt(u32, ordinals[4..8], address.family.template, .little); + hash.update(@tagName(self.inline_expects)); + hash.update(&.{ @intFromBool(self.static_data_literals), @intFromBool(self.comptime_value_reads), @intFromBool(self.literal_roots) }); + const family = hash.finalResult(); + if (base.dev_diagnostics.isSet(.pack_trace)) std.debug.print("interface family module={s} proc={d} template={d} code={x} scope={x} program={x} family={x}\n", .{ callee.module_env.module_name, address.family.proc_base, address.family.template, (callee.code_generation_key orelse callee.key).bytes, scope_context, program_context, family }); + return .{ .provider = family, .procedure = ordinals }; + } + + /// Explicit import closure and requirement identities supplied by checking. + /// Unlike the literal-value program context this does not include exports + /// of unrelated modules (in particular the platform's call to app main). + fn interfaceCalleeContext(self: *Builder, callee: ModuleView) Allocator.Error!?[32]u8 { + if (self.interface_callee_contexts.get(callee.key.bytes)) |known| return known; + const top = (try self.literalTopModules()).?; + var visited = std.AutoHashMap([32]u8, void).init(self.allocator); + defer visited.deinit(); + var pending = std.ArrayList(ModuleView).empty; + defer pending.deinit(self.allocator); + var dependencies = std.ArrayList([32]u8).empty; + defer dependencies.deinit(self.allocator); + var requirements = std.ArrayList([64]u8).empty; + defer requirements.deinit(self.allocator); + try pending.append(self.allocator, callee); + while (pending.pop()) |view| { + const seen = try visited.getOrPut(view.key.bytes); + if (seen.found_existing) continue; + try dependencies.append(self.allocator, (view.code_generation_key orelse view.key).bytes); + for (view.direct_import_module_keys) |key| { + const imported = self.moduleForKeyBytes(key.bytes) orelse { + // A type-only dependency need not be in the lowering's + // module set. Without its explicit context no record is offered. + try self.interface_callee_contexts.put(self.allocator, callee.key.bytes, null); + return null; + }; + try pending.append(self.allocator, imported); + } + const identities = view.compile_time_root_identities; + for ([_][]const [32]u8{ identities.method_requires, identities.export_requires }) |required| for (required) |name| { + var providers = top.keyIterator(); + while (providers.next()) |key| { + const provider = self.moduleForKeyBytes(key.*) orelse Common.invariant("interface requirement has no checked provider view"); + for (provider.compile_time_root_identities.exports) |exported| { + if (!std.mem.eql(u8, &name, &exported.name)) continue; + var pair: [64]u8 = undefined; + pair[0..32].* = name; + pair[32..].* = exported.value; + try requirements.append(self.allocator, pair); + } + } + }; } + const Order = struct { + fn dependency(_: void, a: [32]u8, b: [32]u8) bool { + return std.mem.order(u8, &a, &b) == .lt; + } + fn requirement(_: void, a: [64]u8, b: [64]u8) bool { + return std.mem.order(u8, &a, &b) == .lt; + } + }; + std.mem.sort([32]u8, dependencies.items, {}, Order.dependency); + std.mem.sort([64]u8, requirements.items, {}, Order.requirement); + var hash = std.crypto.hash.sha2.Sha256.init(.{}); + hash.update("roc.interface-dependencies.v1"); + var counts: [8]u8 = undefined; + std.mem.writeInt(u32, counts[0..4], @intCast(dependencies.items.len), .little); + std.mem.writeInt(u32, counts[4..8], @intCast(requirements.items.len), .little); + hash.update(&counts); + for (dependencies.items) |key| hash.update(&key); + hash.update("requirements"); + for (requirements.items) |pair| hash.update(&pair); + const digest = hash.finalResult(); + try self.interface_callee_contexts.put(self.allocator, callee.key.bytes, digest); + return digest; + } + + fn recordPersistentInterface(self: *Builder, entry: InterfaceSummaryEntry, types_: *Type.Store, ns: *const names.NameStore) Allocator.Error!void { + const family = (try self.persistentInterfaceFamily(entry.address)) orelse return; + const summary: PersistentInterface.Summary = switch (entry.summary) { + .unchanged => .unchanged, + .constraints => |c| .{ .constraints = c }, + }; + const bytes = PersistentInterface.encode(self.allocator, types_, ns, entry.request.leaves, summary) catch |err| switch (err) { + error.NotRepresentable => { + self.count("interface_persistent_unrepresentable"); + return; + }, + error.OutOfMemory => return error.OutOfMemory, + }; + defer self.allocator.free(bytes); + const evidence = (try self.persistentInterfaceEvidence(entry.evidence)) orelse return; + const key = PersistentInterface.requestKey(entry.request, evidence, family.procedure); + const cache = self.interface_cache.?; + try cache.record(cache.context, family.provider, key, bytes); + self.count("interface_persistent_records"); + } + + fn persistentInterfaceEvidence(self: *Builder, evidence: StoredConstFnEvidence) Allocator.Error!?[32]u8 { + for (evidence.entries.targets) |target| if (target.method.kind == .local_proc) return null; + const contexts = (try self.literalTopModules()) orelse return null; + return Ast.fnEvidenceContextDigest(evidence.entries, evidence.frames, evidence.head, contexts).bytes; } fn addDigestStats(self: *Builder, stats: Type.Store.DigestStats) void { @@ -6746,21 +6893,30 @@ const Builder = struct { /// module's methods, or from an imported module's exports, looks up. /// Null when a top module has no value context. fn literalProgramContext(self: *Builder) Allocator.Error!?[32]u8 { - if (self.literal_program_context) |known| return known; + return self.programValueContext(true); + } + + fn programValueContext(self: *Builder, comptime include_export_requires: bool) Allocator.Error!?[32]u8 { + const cached = if (include_export_requires) &self.literal_program_context else &self.interface_program_context; + if (cached.*) |known| return known; const top = (try self.literalTopModules()) orelse { - self.literal_program_context = @as(?[32]u8, null); + cached.* = @as(?[32]u8, null); return null; }; var names_list = std.ArrayList([33]u8).empty; defer names_list.deinit(self.allocator); - for (self.program.view().lowering_modules) |module| { + // Workers borrow module views, not the coordinator's output tables. + // Persistent interfaces must name the same input set in both domains. + const module_count = if (include_export_requires) self.program.view().lowering_modules.len else self.moduleViews().len; + for (0..module_count) |index| { + const key = if (include_export_requires) self.program.view().lowering_modules[index].bytes else self.moduleViews()[index].key.bytes; var name: [33]u8 = undefined; - if (top.get(module.bytes)) |context| { + if (top.get(key)) |context| { name[0] = 1; name[1..].* = context; } else { name[0] = 0; - name[1..].* = module.bytes; + name[1..].* = key; } try names_list.append(self.allocator, name); } @@ -6777,7 +6933,7 @@ const Builder = struct { defer required.deinit(self.allocator); for (self.moduleViews()) |*requirer| { const identities = requirer.compile_time_root_identities; - const export_requires = if (top.contains(requirer.key.bytes)) &[_][32]u8{} else identities.export_requires; + const export_requires = if (!include_export_requires or top.contains(requirer.key.bytes)) &[_][32]u8{} else identities.export_requires; for ([_][]const [32]u8{ identities.method_requires, export_requires }) |requirements| for (requirements) |name| { var providers = top.keyIterator(); while (providers.next()) |provider_key| { @@ -6798,13 +6954,13 @@ const Builder = struct { } }.lessThan); var hasher = TypeDigestHasher.init(); - hasher.update("roc.literal-value.program-context.v1"); + hasher.update(if (include_export_requires) "roc.literal-value.program-context.v1" else "roc.interface-method-context.v1"); hashU32(&hasher, @intCast(names_list.items.len)); for (names_list.items) |name| hasher.update(&name); hashU32(&hasher, @intCast(required.items.len)); for (required.items) |pair| hasher.update(&pair); const digest = hasher.finalResult(); - self.literal_program_context = @as(?[32]u8, digest); + cached.* = @as(?[32]u8, digest); return digest; } @@ -7310,6 +7466,7 @@ const Builder = struct { .proc_debug_names = self.proc_debug_names, .interface_summaries = &self.shared_summaries.?, .interface_summary_end = self.shared_summaries.?.count, + .interface_cache = self.interface_cache, .replay_interface_summaries = self.replay_interface_summaries, .collect_counters = self.counters != null, .collect_diagnostics = self.diagnostics != null, @@ -7522,6 +7679,7 @@ const Builder = struct { .proc_debug_names = self.proc_debug_names, .interface_summaries = &self.shared_summaries.?, .interface_summary_end = self.shared_summaries.?.count, + .interface_cache = self.interface_cache, .collect_counters = self.counters != null, .collect_diagnostics = self.diagnostics != null, .inline_expects = self.inline_expects, @@ -9660,6 +9818,7 @@ const Builder = struct { .proc_debug_names = self.proc_debug_names, .interface_summaries = &self.shared_summaries.?, .interface_summary_end = self.shared_summaries.?.count, + .interface_cache = self.interface_cache, .collect_counters = self.counters != null, .collect_diagnostics = self.diagnostics != null, .inline_expects = self.inline_expects, @@ -21622,6 +21781,8 @@ const ActiveConstBindingScope = struct { entered: bool = false, }; +const PersistentInterface = @import("interface_summary.zig"); + const InterfaceReplayStatus = enum { expanding, expanded, ready }; const InterfaceReplayAddress = struct { @@ -27768,6 +27929,47 @@ const BodyContext = struct { } fn findInterfaceSummary(self: *BodyContext, address: InterfaceReplayAddress, evidence: StoredConstFnEvidence, request: InterfaceConstraints.Identity) Allocator.Error!?FoundInterfaceSummary { + if (!self.builder.replay_interface_summaries.enabled()) return null; + if (try self.findMemoryInterfaceSummary(address, evidence, request)) |found| return found; + const family = (try self.builder.persistentInterfaceFamily(address)) orelse return null; + const cache = self.builder.interface_cache.?; + const evidence_key = (try self.builder.persistentInterfaceEvidence(evidence)) orelse return null; + const key = PersistentInterface.requestKey(request, evidence_key, family.procedure); + const bytes = (try cache.load(cache.context, self.allocator, family.provider, key)) orelse { + self.builder.count("interface_persistent_misses"); + return null; + }; + defer self.allocator.free(bytes); + const decoded = PersistentInterface.decode(self.graph.arena(), self.typeStore(), self.nameStoreMut(), request.leaves, bytes, PersistentProvenance{ .builder = self.builder }) catch |err| switch (err) { + error.InvalidSummary => { + self.builder.count("interface_persistent_invalid"); + return null; + }, + error.OutOfMemory => return error.OutOfMemory, + }; + const summary: InterfaceSummary = switch (decoded) { + .unchanged => .unchanged, + .constraints => |c| .{ .constraints = c }, + }; + self.builder.count("interface_persistent_hits"); + // This cache owns the record independently of the graph arena. Worker + // records cross to the coordinator only with their accepted shard. + return .{ .summary = try self.insertInterfaceSummary(.{ .address = address, .evidence = evidence, .request = request, .summary = summary }), .domain = .graph }; + } + + const PersistentProvenance = struct { + builder: *Builder, + pub fn validModuleIdentity(self: PersistentProvenance, identity: [32]u8) bool { + return self.builder.moduleForIdentityHash(&identity) != null; + } + pub fn validNamedType(self: PersistentProvenance, named: Type.NamedType) bool { + if (self.builder.moduleForKeyBytes(named.module.bytes) == null) return false; + const occurrence = self.builder.moduleForKeyBytes(named.ty_module.bytes) orelse return false; + return @backingInt(named.ty) < occurrence.types.payloadCount(); + } + }; + + fn findMemoryInterfaceSummary(self: *BodyContext, address: InterfaceReplayAddress, evidence: StoredConstFnEvidence, request: InterfaceConstraints.Identity) Allocator.Error!?FoundInterfaceSummary { if (!self.builder.replay_interface_summaries.enabled()) return null; const local = self.interfaceSummaryCache().cache; if (try local.find(address, evidence, request, self.typeStore(), self.nameStore())) |summary| { @@ -27835,7 +28037,9 @@ const BodyContext = struct { const binding = self.interfaceSummaryCache(); if (binding.types_are_durable) { // Coordinator leaves already belong to permanent program storage. - return try binding.cache.insert(self.typeStore(), self.nameStore(), entry); + const summary = try binding.cache.insert(self.typeStore(), self.nameStore(), entry); + try self.builder.recordPersistentInterface(.{ .address = entry.address, .evidence = entry.evidence, .request = entry.request, .summary = summary }, self.typeStore(), self.nameStore()); + return summary; } // Retain only settled leaves; open cells remain explicit constraints. var sealer = GraphTypeFinals.initRetainedTypeView(self.graph); @@ -68176,6 +68380,7 @@ fn moduleViewData(view: checked.ImportedModuleView) ModuleViewData { .static_dispatch_plans = view.static_dispatch_plans, .method_registry = view.method_registry, .method_lookup_scope = view.method_lookup_scope, + .direct_import_module_keys = view.direct_import_module_keys, .resolved_refs = view.resolved_value_refs, .nested_proc_sites = view.nested_proc_sites, .exported_procedure_bindings = view.exported_procedure_bindings, diff --git a/src/postcheck/monotype/specialize.zig b/src/postcheck/monotype/specialize.zig index 4d5e307b87d..6aa45b7f3e3 100644 --- a/src/postcheck/monotype/specialize.zig +++ b/src/postcheck/monotype/specialize.zig @@ -65,6 +65,12 @@ pub const Counters = struct { interface_replay_digest_root_requests: u64 = 0, interface_replay_digest_node_misses: u64 = 0, interface_summary_hits: u64 = 0, + interface_persistent_hits: u64 = 0, + interface_persistent_misses: u64 = 0, + interface_persistent_invalid: u64 = 0, + interface_persistent_records: u64 = 0, + interface_persistent_unrepresentable: u64 = 0, + /// Summary hits whose expansion contributed no constraint beyond its input. interface_summary_unchanged_hits: u64 = 0, /// Relation requests whose settled parametric instantiations were captured as holes. diff --git a/test/cli/cache_work_bench.py b/test/cli/cache_work_bench.py index a793f00d2e2..59fb5b3d244 100644 --- a/test/cli/cache_work_bench.py +++ b/test/cli/cache_work_bench.py @@ -98,7 +98,7 @@ def main(): stats[k] = stats.get(k, 0) + int(v) rss = int((run_dir / "rss").read_text()) results.setdefault(phase, []).append((elapsed, rss, publication / 1e6)) - files = [f for f in cache.rglob("*") if f.suffix in (".rpi", ".rpa", ".rpm")] + files = [f for f in cache.rglob("*") if f.suffix in (".rpi", ".rpa", ".rpm", ".ris")] print(a.label, repeat, phase, f"wall_ms={elapsed:.3f} rss_kib={rss} publication_ms={publication / 1e6:.3f} records={len(files)} bytes={sum(f.stat().st_size for f in files)}", stats, flush=True) if a.trace and repeat == 0: (source / "main.roc").write_text(app.replace("+ 10", "+ 12")) diff --git a/test/cli/interface_cache_bench.py b/test/cli/interface_cache_bench.py new file mode 100644 index 00000000000..311a1a66b5f --- /dev/null +++ b/test/cli/interface_cache_bench.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Exercise and measure persistent interface replay on a synthetic application. + +Requires a compiler built with -Ddev-diagnostics and a built fx-open platform. +Uses the diagnostic comparison switch to isolate this optimization in the same +binary. Work directories must be new and should live under TMPDIR. No external +project is downloaded or benchmarked. Logs retain timings and work counters. +""" +import argparse +import os +from pathlib import Path +import re +import shutil +import statistics +import subprocess +import time + + +def counter(log, label): + return sum(int(n.replace(",", "")) for n in re.findall(rf"^\s+{re.escape(label)}\s+([\d,]+)\s*$", log, re.M)) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("roc", type=Path) + parser.add_argument("--platform", type=Path, required=True) + parser.add_argument("--work", type=Path, required=True) + parser.add_argument("--depth", type=int, default=32) + parser.add_argument("--repeats", type=int, default=5) + args = parser.parse_args() + roc = args.roc.resolve() + work = args.work.resolve() + work.mkdir(parents=True, exist_ok=False) + package_hash = "FakeHashAbcDefGhiJkLmNoPqRsTuVwXyZ123456789o" + samples = {} + for repeat in range(args.repeats): + # Alternate the first mode to avoid a systematic ordering advantage. + modes = ("before", "after") if repeat % 2 == 0 else ("after", "before") + for mode in modes: + run = work / f"{mode}-{repeat}" + source = run / "source" + source.mkdir(parents=True) + cache = run / "cache" + shutil.copytree(args.platform, cache / "roc/packages" / package_hash) + env = dict(os.environ, ROC_CACHE_DIR=str(cache), XDG_CACHE_HOME=str(cache)) + env.pop("ROC_DISABLE_PERSISTENT_INTERFACES", None) + if mode == "before": + env["ROC_DISABLE_PERSISTENT_INTERFACES"] = "1" + for phase in ("cold", "app_edit", "callee_edit", "dependency_edit", "ctfe_edit", "corrupt"): + delta = 1 if phase in ("cold", "app_edit") else 2 + dependency_delta = 1 if phase in ("dependency_edit", "ctfe_edit", "corrupt") else 0 + offset_arg = 3 if phase in ("ctfe_edit", "corrupt") else 2 + main_arg = 10 if phase == "cold" else 11 + (source / "Leaf.roc").write_text(f"Leaf := [].{{\nadd = |x| x + {dependency_delta}\n}}\n") + helpers = [f"f0 = |x| Leaf.add(x) + {delta}"] + helpers += [f"f{i} = |x| f{i - 1}(x) + 1" for i in range(1, args.depth)] + (source / "Generic.roc").write_text("import Leaf\nGeneric := [].{\n" + "\n".join(helpers) + "\n}\n") + app = f'''app [main!] {{ pf: platform "https://example.com/interface/{package_hash}.tar.zst" }} +import pf.Stdout +import Generic + +offset : U64 +offset = {{ + dbg "interface cache observation" + Generic.f{args.depth - 1}({offset_arg}) +}} + +unused : U64 +unused = crash "unused failing interface root" + +main! = |args| {{ + total = Generic.f{args.depth - 1}(args.len().to_u64() + {main_arg}) + offset + Stdout.line!(total.to_str()) + Ok({{}}) +}} +''' + (source / "main.roc").write_text(app) + if phase == "corrupt": + for record in cache.rglob("*.ris"): + record.write_bytes(b"RIFACE01truncated") + for output in cache.rglob("*.rpo"): + output.unlink() + exe = run / f"{phase}-app" + command = [str(roc), "build", "--opt=dev", "--jobs=2", "--timings", str(source / "main.roc"), f"--output={exe}"] + start = time.perf_counter_ns() + result = subprocess.run(command, env=env, capture_output=True, text=True) + elapsed = (time.perf_counter_ns() - start) / 1e6 + log = result.stdout + result.stderr + (run / f"{phase}.log").write_text(log) + if result.returncode: + raise RuntimeError(log) + assert "unused failing interface root" not in log, log + assert log.count("interface cache observation") == 1, log + expected = 1 + main_arg + offset_arg + 2 * (args.depth - 1 + delta + dependency_delta) + actual = subprocess.check_output([str(exe)], text=True) + assert actual == f"{expected}\n", (phase, expected, actual) + hits = counter(log, "Persistent interface hits") + expansions = counter(log, "Interface summary expansions") + if mode == "before": + assert hits == 0, log + if mode == "after" and phase == "app_edit": + assert hits > 0, "application edit reused no persistent interface" + key = (mode, phase) + samples.setdefault(key, []).append((elapsed, hits, expansions)) + records = list(cache.rglob("*.ris")) + print(mode, repeat, phase, f"wall_ms={elapsed:.3f} hits={hits} expansions={expansions} files={len(records)} bytes={sum(p.stat().st_size for p in records)}", flush=True) + for (mode, phase), rows in samples.items(): + print(mode, phase, "median", *(f"{statistics.median(row[i] for row in rows):.3f}" for i in range(3)), flush=True) + before = statistics.median(row[2] for row in samples["before", "app_edit"]) + after = statistics.median(row[2] for row in samples["after", "app_edit"]) + assert after < before, ("persistent replay did not reduce interface expansion", before, after) + + +if __name__ == "__main__": + main() diff --git a/test/cli/persistent_interface_cache/Generic.roc b/test/cli/persistent_interface_cache/Generic.roc new file mode 100644 index 00000000000..0f1418f1aa5 --- /dev/null +++ b/test/cli/persistent_interface_cache/Generic.roc @@ -0,0 +1,10 @@ +Generic := [].{ + f0 = |x| x + 1 + f1 = |x| f0(x) + 1 + f2 = |x| f1(x) + 1 + f3 = |x| f2(x) + 1 + f4 = |x| f3(x) + 1 + f5 = |x| f4(x) + 1 + f6 = |x| f5(x) + 1 + f7 = |x| f6(x) + 1 +} diff --git a/test/cli/persistent_interface_cache/main.roc b/test/cli/persistent_interface_cache/main.roc new file mode 100644 index 00000000000..38a9f7f3055 --- /dev/null +++ b/test/cli/persistent_interface_cache/main.roc @@ -0,0 +1,18 @@ +app [main!] { pf: platform "../../fx-open/platform/main.roc" } +import pf.Stdout +import Generic + +offset : U64 +offset = { + dbg "persistent interface observation" + Generic.f7(2) +} + +unused : U64 +unused = crash "unused failing interface root" + +main! = |args| { + total = Generic.f7(args.len().to_u64() + 10) + offset + Stdout.line!(total.to_str()) + Ok({}) +} From d5baa13aba329739b9cf0f941e9bf26bdf6ff24c Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 11:33:39 -0400 Subject: [PATCH 13/97] Replace cache sweeps with bounded indexed maintenance --- build.zig | 2 + design.md | 135 +++- src/cli/CliCtx.zig | 4 + src/cli/linker.zig | 1 + src/cli/main.zig | 337 +++------ src/cli/pack_store.zig | 529 ++++---------- src/cli/test/parallel_cli_runner.zig | 275 +++---- src/compile/artifact_store.zig | 924 +++++++++++++++++++++++ src/compile/cache_cleanup.zig | 1009 +++----------------------- src/compile/cache_config.zig | 57 +- src/compile/cache_index.zig | 522 +++++++++++++ src/compile/cache_maintenance.zig | 578 +++++++++++++++ src/compile/cache_manager.zig | 425 ++++------- src/compile/coordinator.zig | 150 ++-- src/compile/mod.zig | 29 +- src/compile/test/cache_test.zig | 187 +---- src/glue/glue.zig | 84 +-- src/ipc/coordination.zig | 18 +- 18 files changed, 2848 insertions(+), 2418 deletions(-) create mode 100644 src/compile/artifact_store.zig create mode 100644 src/compile/cache_index.zig create mode 100644 src/compile/cache_maintenance.zig diff --git a/build.zig b/build.zig index 2062b719291..579d868484e 100755 --- a/build.zig +++ b/build.zig @@ -2761,6 +2761,7 @@ pub fn build(b: *std.Build) void { .{ .name = "bytebox", .module = bytebox.module("bytebox") }, .{ .name = "build_options", .module = roc_modules.build_options }, .{ .name = "msvc_runtime", .module = b.createModule(.{ .root_source_file = b.path("src/echo_platform/msvc_runtime.zig") }) }, + .{ .name = "artifact_store", .module = b.createModule(.{ .root_source_file = b.path("src/compile/artifact_store.zig") }) }, }, }), }); @@ -4136,6 +4137,7 @@ pub fn build(b: *std.Build) void { .{ .name = "bytebox", .module = bytebox.module("bytebox") }, .{ .name = "build_options", .module = roc_modules.build_options }, .{ .name = "msvc_runtime", .module = b.createModule(.{ .root_source_file = b.path("src/echo_platform/msvc_runtime.zig") }) }, + .{ .name = "artifact_store", .module = b.createModule(.{ .root_source_file = b.path("src/compile/artifact_store.zig") }) }, }, }), .filters = test_filters, diff --git a/design.md b/design.md index 59231fa5bd6..4a4fec9c57f 100644 --- a/design.md +++ b/design.md @@ -666,10 +666,10 @@ is made portable by this contract. Linked-only LLVM objects remain exact-target inputs and cannot serve CTFE. Source-location, transitive code/data closure, compile-time reads and demand/observation eligibility checks still apply. -Dev procedure packs and discovery records live under `proc--dev` within -the versioned object namespace. Program outputs stay under `-dev`, and -optimized packs stay under their exact target and optimization level. Both -publication and lookup use the procedure namespace. A compatible runtime build +Dev procedure packs and discovery records use `proc--dev` as their +logical group scope in the indexed artifact store. Program outputs use +`-dev`, and optimized packs retain their exact target and optimization +level. Both publication and lookup use the procedure scope. A compatible runtime build owns its target-specific Store while borrowing CTFE's heap-owned LoadedPacks for both specialization lookup and splicing; that owner outlives lowering and emission. Shared body elision still requires the exact same provider capability. @@ -679,24 +679,119 @@ Same-program retained fragments remain exact-target for now. Their separate `rejected_target` metric exposes this limitation; revision, transferred LIR and layout ownership, CPU, static-data and runtime-context checks are unchanged. +### Indexed cache storage and maintenance + +Cache storage and cache maintenance share one publication protocol. The native +store uses a new storage-format namespace; it neither imports nor scans legacy +cache directories. Compiler compatibility is part of artifact identity, not +maintenance ownership: one nonblocking, kernel-managed lock at the shared cache +root elects the maintenance owner across all compiler compatibility namespaces. +The coordination file is permanent and is never replaced or unlinked. Its +existence does not mean that a process owns the lock. Process death releases +kernel ownership; no PID timeout or persistent lock marker establishes liveness. + +The store has sixteen shard files with 512-byte metadata records and fixed-width +crit-bit indexes for logical artifacts, allocation generations, and recency. A +lookup visits at most 512 branch records regardless of cache size. Lookups and +updates use positional I/O, not whole-catalog reads or rewrites. Transactions +have at most 24 changed records in one checksummed redo record, published before +changing catalog records. Recovery +finishes that transaction before admitting readers. There is no accumulating +log to replay at startup. Payload generation and streaming happen outside +catalog locks. A corrupt record or an I/O failure is an ordinary cache failure, +never a compiler invariant. Corrupt catalogs disable that shard rather than +trusting its paths or scanning all payloads during a foreground operation. +These are process-interruption guarantees, not a promise of power-loss durability. +Freed metadata slots are reused; catalog capacity and stable lease files remain +at their historical high-water mark, so no unbounded compaction is hidden in GC. + +Every staging artifact is registered before its payload is created. Stable +slot IDs and reuse generations identify reserved, published, and retiring +artifacts. Atomic publication exposes complete payloads only. Retirement is +idempotent: an interrupted deletion can be repeated, and a generation check +prevents a stale checkpoint from deleting a replacement. Scratch ownership is +an OS-backed lease, not an inference from age. Native child consumers inherit scratch leases. POSIX uses open-file-description +locks; Windows uses file-sharing denial, which survives handle inheritance +unlike process-owned byte-range locks. Closing the parent's handle does not +release a lease still owned by a child. + +Artifact acquisition opens storage and a shared lease under the shard lock, +then releases the shard lock before reading payloads. It returns owned bytes or +retained storage. A pathname that +another consumer will open later must be pinned for that consumer's lifetime. +An artifact disappearing before acquisition is a cache miss. Existing cache +header, key, layout, and dependency-closure validation remains authoritative; +maintenance metadata never makes an otherwise invalid cache hit acceptable. + +Maintenance checkpoints name logical slots and operation phases, never opaque +filesystem directory positions. A pass captures a finite allocation boundary; +new allocations cannot extend that pass indefinitely, and reused slots behind +the cursor are covered by the next pass. Each unit bounds metadata I/O, +enumeration, index maintenance, and deletion work. Namespace retirement, +recovery, and reclamation obey the same units; none contains recursive tree +deletion, whole-store inventory, sorting, or compaction. Accounting and eviction +indexes are persisted and reconciled incrementally with missing payloads and +interrupted publications. A scratch step examines at most one directory entry +and deletes at most one leaf or empty directory. Its checksummed relative-path +cursor has a platform-path-length bound; every ancestor is opened without +following symlinks. Restarting after a lost cursor is safe because all remaining +leaves of an unowned scratch directory are eligible. + +Optional maintenance runs as one synchronous task immediately before a +coordinator waits for a result, only when its declared queued-plus-executing +worker demand is below its configured budget and that budget exceeds one. +The maintenance task occupies the otherwise waiting coordinator's slot. Serial +coordination and linking do not grant optional capacity. No compiler scheduling +lock spans filesystem work. Returning foreground demand stops subsequent +admissions, not a unit already executing. There is no detached worker and no +owner waiting for capacity while holding maintenance ownership. + +A completed pass sets its next due time five minutes ahead. An unfinished pass +keeps its due time. Once a pass is one minute overdue, every successful grant +completes and checkpoints one compulsory unit synchronously at command startup, +completed-task safe points, or command shutdown. Missing state starts overdue. +The checkpoint permits at most one compulsory grant per second per shared root, +so parallel invocations share that allowance. A one-worker build receives the +same compulsory allowance. Individual invocations inside that one-second +window need not do work; continued invocations spanning subsequent windows +advance a finite pass. Persisted pressure raises each grant to eight units. + +Retention keeps the five-minute abandoned-scratch, one-day local-pack/program, +and thirty-day persistent/package-pack windows. Artifact family and eligibility +are explicit metadata. Cache use is recorded on acquisition, independently of +filesystem access-time policy. Published, reserved, and retiring payload bytes +are accounted separately from scratch. Publishers update shard counters in the +same transaction as their records. Each maintenance unit refreshes one shard's +observed total, and age traversal reconciles external payload removal. Pressure +is based on this persisted, incrementally refreshed observation, not a new +inventory. Eviction merges one persisted recency candidate per shard; candidates +are revalidated under shard ownership, and live leases are skipped for this pass. +The limit is eventual for a finite, unpinned workload: publication can outpace +maintenance indefinitely, and pinned files, metadata, scratch, external changes, +and delayed reclamation also consume storage. Optional cache publication skips +busy shards instead of waiting; required scratch reservation waits for its short +metadata transaction. OS failures stop the current attempt rather than create +retry loops. Continued successful invocations and filesystem operations +guarantee progress through a finite workload; process termination and absence +of future invocations do not. + +These are operation-count guarantees, not wall-clock deadlines. A single OS +operation may block. Spare capacity in participating builds also does not prove +that unrelated applications have left the machine idle. Compulsory maintenance +intentionally consumes a bounded amount of foreground capacity. + ### Object Cache Store -Dev packs live under `//objects-v/ -proc--dev///.rpk`, and the pack an -optimized build files for its program, which each optimized build of the -program replaces, under `-/optimized/.rpk`. Every -other pack's path is a function of its content, so a writer stages it beside its path in a file it -alone created (`CoreCtx.writeTempFile`), so two writers never share a partly -written file, renames it into place, and leaves a pack already present alone; builds with runtime -safety compare the bytes they would have written with the pack in place. The -background sweep (`cache_cleanup.sweep`) deletes every `objects-v*` namespace -of a format other than the compiler's own, ages the current namespace's -`local/` packs (rewritten by every edit) and recorded program outputs -(`programs/`, see "Recorded Program Output") on one day and its `pkg/` and -`optimized/` packs on thirty, by last use (`max(atime, mtime)`), and then deletes the least -recently used persistent cache files of every family until the cache fits -`CacheConfig.max_size_mb`. Eviction only deletes files; it never changes what -a build writes. +Packs use explicit artifact families in the indexed store. Compiler +compatibility, procedure domain or exact target, optimization level, module +placement identity, and artifact key determine their logical identity. An optimized program pack +replaces its previous generation. Module pack offers remain sorted by artifact +key, independent of their physical slots or publication order. Decoded offers +own their bytes; an optimization-state digest hashes logical identities and +input keys rather than physical payload paths. Format obsolescence and the +one-day local/program and thirty-day package/optimized last-use policies are +handled by the same bounded traversal as other families. Eviction never changes +what a build writes. A pack entry's code is a function of the procedure alone: every reference from it into code placed with it, and every reference by identity to code diff --git a/src/cli/CliCtx.zig b/src/cli/CliCtx.zig index 525cb0079a2..b981c422a08 100644 --- a/src/cli/CliCtx.zig +++ b/src/cli/CliCtx.zig @@ -207,6 +207,8 @@ pub const Command = enum { /// Shared context for CLI operations. /// Contains allocators, I/O, and accumulated problems. pub const CliCtx = struct { + /// Registered scratch leases survive until explicit release or CLI teardown. + scratch: std.ArrayList(@import("compile").artifacts.Scratch) = .empty, /// General purpose allocator for long-lived allocations gpa: Allocator, /// Arena allocator for temporary/scoped allocations @@ -334,6 +336,8 @@ pub const CliCtx = struct { /// Clean up resources and flush I/O pub fn deinit(self: *Self) void { + for (self.scratch.items) |*scratch| scratch.deinit(); + self.scratch.deinit(self.gpa); self.io.flush(); self.problems.deinit(self.gpa); } diff --git a/src/cli/linker.zig b/src/cli/linker.zig index 79cf1336f33..ff4b399bec9 100644 --- a/src/cli/linker.zig +++ b/src/cli/linker.zig @@ -968,6 +968,7 @@ fn buildLinkArgs(ctx: *CliCtx, config: LinkConfig) LinkError!std.array_list.Mana /// Link object files into an executable using LLD pub fn link(ctx: *CliCtx, config: LinkConfig) LinkError!void { + @import("compile").cleanup.safePoint(.linking); // Check if LLVM is available at compile time if (comptime !llvm_available) { return LinkError.LLVMNotAvailable; diff --git a/src/cli/main.zig b/src/cli/main.zig index acc2ceec9b0..a465b1b86d9 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -1014,107 +1014,30 @@ fn createExecutableSharedMemory(io: std.Io, page_size: usize) error{ CreateFileM return SharedMemoryAllocator.createExecutableWithMinSize(io, SHARED_MEMORY_SIZE, SHARED_MEMORY_MIN_SIZE, page_size); } -/// Cross-platform hardlink creation -fn createHardlink(ctx: *CliCtx, source: []const u8, dest: []const u8) (Allocator.Error || error{ InvalidUtf8, PathAlreadyExists, Unexpected })!void { - if (comptime builtin.target.os.tag == .windows) { - // On Windows, use CreateHardLinkW - const source_w = try std.unicode.utf8ToUtf16LeAllocZ(ctx.arena, source); - const dest_w = try std.unicode.utf8ToUtf16LeAllocZ(ctx.arena, dest); - - // Declare CreateHardLinkW since it's not in all versions of std - const kernel32 = struct { - extern "kernel32" fn CreateHardLinkW( - lpFileName: [*:0]const u16, - lpExistingFileName: [*:0]const u16, - lpSecurityAttributes: ?*anyopaque, - ) callconv(.winapi) std.os.windows.BOOL; - }; - - if (kernel32.CreateHardLinkW(dest_w, source_w, null) == .FALSE) { - const err = std.os.windows.GetLastError(); - if (err == .ALREADY_EXISTS) return error.PathAlreadyExists; - return error.Unexpected; - } - } else { - // On POSIX systems, use the link system call - const source_c = try ctx.arena.dupeSentinel(u8, source, 0); - const dest_c = try ctx.arena.dupeSentinel(u8, dest, 0); - - const result = c.link(source_c, dest_c); - if (result != 0) { - const errno = c._errno().*; - switch (errno) { - 17 => return error.PathAlreadyExists, // EEXIST - else => return error.Unexpected, - } - } - } -} - -/// Generate a cryptographically secure random ASCII string for directory names -fn generateRandomSuffix(ctx: *CliCtx) Allocator.Error![]u8 { - // TODO: Consider switching to a library like https://github.com/abhinav/temp.zig - // for more robust temporary file/directory handling - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - - const suffix = try ctx.arena.alloc(u8, 32); - - // Fill with cryptographically secure random bytes - ctx.io.std_io.random(suffix); - - // Convert to ASCII characters from our charset - for (suffix) |*byte| { - byte.* = charset[byte.* % charset.len]; - } - - return suffix; -} - -/// Create a unique scratch directory under {cache}/{version}/tmp/{random}/. +/// Reserve a unique, leased scratch generation in the indexed cache store. /// Returns the path to the directory (allocated from arena, no need to free). pub fn createUniqueTempDir(ctx: *CliCtx) (Allocator.Error || std.Io.Dir.CreateDirPathError || std.Io.Dir.CreateDirError || error{ FailedToCreateUniqueTempDir, NoHomeDirectory })![]const u8 { - const cache_config = cache_config_mod.CacheConfig{ .roc_ctx = ctx.coreCtx() }; - const scratch_dir = try cache_config.getScratchDir(ctx.arena); - - // createDirPath treats an already-existing directory as success - try std.Io.Dir.cwd().createDirPath(ctx.io.std_io, scratch_dir); - - // Try to create a unique subdirectory with random suffix - var attempt: u8 = 0; - while (attempt < 6) : (attempt += 1) { - const random_suffix = try generateRandomSuffix(ctx); - const dir_path = try std.fs.path.join(ctx.arena, &.{ scratch_dir, random_suffix }); - - // Try to create the directory - std.Io.Dir.cwd().createDir(ctx.io.std_io, dir_path, .default_dir) catch |err| switch (err) { - error.PathAlreadyExists => { - // Directory already exists, try again with a new random suffix - continue; - }, - error.AccessDenied, - error.BadPathName, - error.Canceled, - error.DiskQuota, - error.FileNotFound, - error.LinkQuotaExceeded, - error.NameTooLong, - error.NetworkNotFound, - error.NoDevice, - error.NoSpaceLeft, - error.NotDir, - error.PermissionDenied, - error.ReadOnlyFileSystem, - error.SymLinkLoop, - error.SystemResources, - error.Unexpected, - => return err, - }; + const config = CacheConfig{ .roc_ctx = ctx.coreCtx() }; + const root = try config.getEffectiveCacheDir(ctx.arena); + var scratch = compile.artifacts.Scratch.create(ctx.gpa, ctx.io.std_io, root, compile.artifacts.hashBytes(build_options.compiler_compatibility_id)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.FailedToCreateUniqueTempDir, + }; + errdefer scratch.deinit(); + // The caller's arena path remains valid after explicitly releasing the lease. + const path = try ctx.arena.dupe(u8, scratch.path()); + try ctx.scratch.append(ctx.gpa, scratch); + return path; +} - return dir_path; +fn releaseScratchDir(ctx: *CliCtx, path: []const u8) void { + for (ctx.scratch.items, 0..) |*scratch, index| { + if (std.mem.eql(u8, scratch.path(), path)) { + scratch.deinit(); + _ = ctx.scratch.swapRemove(index); + return; + } } - - // Failed after 6 attempts - return error.FailedToCreateUniqueTempDir; } /// Write shared memory coordination file (.txt) next to the executable. @@ -1272,43 +1195,20 @@ pub fn main(init: std.process.Init) Allocator.Error!void { } } -fn parsedArgsStartBackgroundCleanup(args: cli_args.CliArgs) bool { +fn parsedArgsUseCache(args: cli_args.CliArgs) bool { return switch (args) { .run, .build, .check, .test_cmd, .docs, .deps, .bump, .glue, .install, .experimental_lsp => true, .fmt, .bundle, .unbundle, .repl, .version, .help, .licenses, .problem => false, }; } -fn startBackgroundCacheCleanup(gpa: Allocator, arena: Allocator, std_io: std.Io) void { - // Start background cache cleanup on a separate thread. - // This is a fire-and-forget thread that: - // - Cleans up stale scratch directories (>5 min old) - // - Cleans up old persistent cache files (>30 days old) - // - Exits automatically when done - // - // We intentionally don't join the thread. If the main process exits before - // cleanup completes, the OS will automatically terminate the cleanup thread. - // This ensures cleanup never delays compilation or execution. - // - // Resolve the cache root here using the same resolver the cache writer - // uses, so cleanup can never target a different directory than where - // artifacts are written. The background thread itself is CoreCtx-free and - // allocation-free; it only borrows this path (copied in by value). - const cleanup_ctx = CoreCtx.default(gpa, arena, std_io); - const cfg = cache_config_mod.CacheConfig{ .roc_ctx = cleanup_ctx }; - const cache_base: []const u8 = cfg.getEffectiveCacheDir(arena) catch ""; - if (cache_base.len != 0) { - const limits: compile.CacheCleanup.Limits = .{ - .object_pack_format_version = backend.dev.PackFile.format_version, - .max_size_bytes = @as(u64, cfg.max_size_mb) * 1024 * 1024, - }; - if (compile.CacheCleanup.startBackgroundCleanup(cache_base, std_io, limits)) |_| { - // Thread started successfully, will run in background. - } else |_| { - // Non-fatal: cleanup failure shouldn't prevent compilation. - std.log.debug("Failed to start background cleanup thread", .{}); - } - } +fn initCacheMaintenance(gpa: Allocator, arena: Allocator, std_io: std.Io) ?compile.CacheCleanup.Session { + const cfg = cache_config_mod.CacheConfig{ .roc_ctx = CoreCtx.default(gpa, arena, std_io) }; + const root = cfg.getEffectiveCacheDir(arena) catch return null; + return compile.CacheCleanup.Session.init(gpa, std_io, root, .{ + .object_pack_format_version = backend.dev.PackFile.format_version, + .max_size_bytes = @as(u64, cfg.max_size_mb) * 1024 * 1024, + }) catch null; } fn mainArgs(gpa: Allocator, arena: Allocator, args: []const []const u8, std_io: std.Io) CliMainError!void { @@ -1329,9 +1229,9 @@ fn mainArgs(gpa: Allocator, arena: Allocator, args: []const []const u8, std_io: const parsed = try cli_args.parseWithGlobalOptions(arena, std_io, args[1..]); const parsed_args = parsed.command; - if (parsedArgsStartBackgroundCleanup(parsed_args)) { - startBackgroundCacheCleanup(gpa, arena, std_io); - } + var maintenance = if (parsedArgsUseCache(parsed_args)) initCacheMaintenance(gpa, arena, std_io) else null; + if (maintenance) |*session| session.start(); + defer if (maintenance) |*session| session.deinit(); // Determine command for context const command: Command = switch (parsed_args) { @@ -1694,28 +1594,6 @@ fn generatePlatformHostShim( ); } -/// Removes a cached executable, if one exists, so a fresh link can take its place. -fn deleteExistingCacheFile(ctx: *CliCtx, exe_cache_path: []const u8) void { - std.Io.Dir.cwd().deleteFile(ctx.io.std_io, exe_cache_path) catch |err| switch (err) { - error.FileNotFound => {}, - error.AccessDenied, - error.BadPathName, - error.Canceled, - error.FileBusy, - error.FileSystem, - error.IsDir, - error.NameTooLong, - error.NetworkNotFound, - error.NotDir, - error.PermissionDenied, - error.ReadOnlyFileSystem, - error.SymLinkLoop, - error.SystemResources, - error.Unexpected, - => std.log.debug("Could not delete existing cache file: {}", .{err}), - }; -} - fn ensureCompilerCacheDirExists(std_io: std.Io, path: []const u8) std.Io.Dir.CreateDirPathError!void { // This helper is only for compiler-owned internal cache directories. // User-facing output paths should still fail normally if the parent directory is missing. @@ -2841,10 +2719,6 @@ fn rocRunSharedMemoryShim(ctx: *CliCtx, args: cli_args.RunArgs, arg0: []const u8 return ctx.fail(.{ .cache_dir_unavailable = .{ .reason = @errorName(err) } }); }; - ensureCompilerCacheDirExists(ctx.io.std_io, exe_cache_dir) catch |err| { - return ctx.fail(.{ .directory_create_failed = .{ .path = exe_cache_dir, .err = err } }); - }; - // The final executable name seen in `ps` is the roc filename (e.g., "app.roc") const exe_display_name = std.fs.path.basename(args.path); @@ -3019,27 +2893,8 @@ fn rocRunSharedMemoryShim(ctx: *CliCtx, args: cli_args.RunArgs, arg0: []const u8 return ctx.fail(.{ .cache_dir_unavailable = .{ .reason = @errorName(err) } }); }; - // Check if the interpreter executable already exists in cache - const cache_exists = if (args.no_cache) false else blk: { - std.Io.Dir.cwd().access(ctx.io.std_io, exe_cache_path, .{}) catch { - break :blk false; - }; - break :blk true; - }; - - if (cache_exists) { - // Cached executable exists - hardlink from cache to temp dir + if (!args.no_cache and restoreCachedFile(ctx, .executable, exe_cache_path, exe_path)) { std.log.debug("Using cached executable: {s}", .{exe_cache_path}); - createHardlink(ctx, exe_cache_path, exe_path) catch |err| { - // If hardlinking fails, fall back to copying - std.log.debug("Hardlink from cache failed, copying: {}", .{err}); - std.Io.Dir.cwd().copyFile(exe_cache_path, std.Io.Dir.cwd(), exe_path, ctx.io.std_io, .{}) catch |copy_err| { - return ctx.fail(.{ .file_write_failed = .{ - .path = exe_path, - .err = copy_err, - } }); - }; - }; } else { // Extract shim library to temp dir to avoid race conditions @@ -3230,18 +3085,7 @@ fn rocRunSharedMemoryShim(ctx: *CliCtx, args: cli_args.RunArgs, arg0: []const u8 return failLink(ctx, err, @tagName(platform_link.target)); }; - // After building, hardlink to cache for future runs - // Force-hardlink (delete existing first) since hash collision means identical content - std.log.debug("Caching executable to: {s}", .{exe_cache_path}); - deleteExistingCacheFile(ctx, exe_cache_path); - createHardlink(ctx, exe_path, exe_cache_path) catch |err| { - // If hardlinking fails, fall back to copying - std.log.debug("Hardlink to cache failed, copying: {}", .{err}); - std.Io.Dir.cwd().copyFile(exe_path, std.Io.Dir.cwd(), exe_cache_path, ctx.io.std_io, .{}) catch |copy_err| { - // Non-fatal - just means future runs won't be cached - std.log.debug("Failed to copy to cache: {}", .{copy_err}); - }; - }; + if (!args.no_cache) publishCachedFile(ctx, .executable, exe_cache_path, exe_path); } if (args.opt == .dev) { @@ -3294,7 +3138,7 @@ fn rocRunBuildAndExec(ctx: *CliCtx, args: cli_args.RunArgs, _: []const u8) CliMa }; var cleanup_temp_dir = true; defer if (cleanup_temp_dir) { - compile.CacheCleanup.deleteTempDir(ctx.io.std_io, temp_dir); + releaseScratchDir(ctx, temp_dir); }; const output_filename = try compiledRunOutputFilename(ctx, args.path); @@ -3322,7 +3166,7 @@ fn rocRunBuildAndExec(ctx: *CliCtx, args: cli_args.RunArgs, _: []const u8) CliMa const term = try runCompiledExecutable(ctx, build_result.output_path, args.path, args.app_args); - compile.CacheCleanup.deleteTempDir(ctx.io.std_io, temp_dir); + releaseScratchDir(ctx, temp_dir); cleanup_temp_dir = false; try finishCompiledRun(ctx, build_result.output_path, term, build_result.diagnostics); @@ -3703,16 +3547,13 @@ fn rocRunDefaultAppSharedMemoryShim(ctx: *CliCtx, args: cli_args.RunArgs, staged const exe_cache_dir = cache_manager.config.getExeCacheDir(ctx.arena) catch |err| { return ctx.fail(.{ .cache_dir_unavailable = .{ .reason = @errorName(err) } }); }; - ensureCompilerCacheDirExists(ctx.io.std_io, exe_cache_dir) catch |err| { - return ctx.fail(.{ .directory_create_failed = .{ .path = exe_cache_dir, .err = err } }); - }; const temp_dir = createUniqueTempDir(ctx) catch |err| { return ctx.fail(.{ .temp_dir_failed = .{ .err = err } }); }; var cleanup_temp_dir = true; defer if (cleanup_temp_dir) { - compile.CacheCleanup.deleteTempDir(ctx.io.std_io, temp_dir); + releaseScratchDir(ctx, temp_dir); }; const platform_dir = try std.fs.path.join(ctx.arena, &.{ temp_dir, default_app.platform_dir_name }); @@ -3781,15 +3622,8 @@ fn rocRunDefaultAppSharedMemoryShim(ctx: *CliCtx, args: cli_args.RunArgs, staged try ctx.arena.dupe(u8, exe_display_name); const exe_path = try std.fs.path.join(ctx.arena, &.{ temp_dir, exe_display_name_with_ext }); - const cache_exists = if (args.no_cache) false else blk: { - std.Io.Dir.cwd().access(ctx.io.std_io, exe_cache_path, .{}) catch break :blk false; - break :blk true; - }; - - if (cache_exists) { - createHardlink(ctx, exe_cache_path, exe_path) catch { - try std.Io.Dir.cwd().copyFile(exe_cache_path, std.Io.Dir.cwd(), exe_path, ctx.io.std_io, .{}); - }; + if (!args.no_cache and restoreCachedFile(ctx, .executable, exe_cache_path, exe_path)) { + std.log.debug("Using cached executable: {s}", .{exe_cache_path}); } else { const shim_filename = if (builtin.target.os.tag == .windows) "roc_machine_code_shim.lib" else "libroc_machine_code_shim.a"; const shim_path = try std.fs.path.join(ctx.arena, &.{ temp_dir, shim_filename }); @@ -3844,12 +3678,7 @@ fn rocRunDefaultAppSharedMemoryShim(ctx: *CliCtx, args: cli_args.RunArgs, staged return failLink(ctx, err, @tagName(selected_target)); }; - deleteExistingCacheFile(ctx, exe_cache_path); - createHardlink(ctx, exe_path, exe_cache_path) catch { - std.Io.Dir.cwd().copyFile(exe_path, std.Io.Dir.cwd(), exe_cache_path, ctx.io.std_io, .{}) catch |copy_err| { - std.log.debug("Failed to copy default run executable to cache: {}", .{copy_err}); - }; - }; + if (!args.no_cache) publishCachedFile(ctx, .executable, exe_cache_path, exe_path); } // Default apps never hot reload—they compile through throwaway synthetic @@ -4048,7 +3877,7 @@ fn runWithWindowsHandleInheritance( // On Windows, clean up temp files after the child process exits. // (Unlike Unix, Windows locks files while they're being executed) if (std.fs.path.dirname(exe_path)) |temp_dir_path| { - compile.CacheCleanup.deleteTempDir(ctx.io.std_io, temp_dir_path); + releaseScratchDir(ctx, temp_dir_path); std.log.debug("Cleaned up temp directory: {s}", .{temp_dir_path}); } @@ -4151,7 +3980,7 @@ fn runWithPosixFdInheritance( // file to find the shared memory before it can run. // The background cleanup thread will also clean up old temp directories. if (std.fs.path.dirname(exe_path)) |temp_dir_path| { - compile.CacheCleanup.deleteTempDir(ctx.io.std_io, temp_dir_path); + releaseScratchDir(ctx, temp_dir_path); std.log.debug("Cleaned up temp directory: {s}", .{temp_dir_path}); } @@ -5118,7 +4947,7 @@ fn runHotReloadDevShim( } if (std.fs.path.dirname(exe_path)) |temp_dir_path| { - compile.CacheCleanup.deleteTempDir(ctx.io.std_io, temp_dir_path); + releaseScratchDir(ctx, temp_dir_path); std.log.debug("Cleaned up temp directory: {s}", .{temp_dir_path}); } @@ -7510,15 +7339,9 @@ fn rocInstall(ctx: *CliCtx, args: cli_args.InstallArgs) CliMainError!void { } } -/// Path of a complete copy of the selected shim library that the calling build -/// may link: `cache_path` or `staged_path`. -/// -/// `cache_path` names the library's content-addressed entry in the shared -/// build cache. An entry is published by writing it in full at `staged_path`, -/// which must lie in a directory only the calling build writes to, and -/// renaming it into the cache, so a build that finds the entry never reads one -/// that is still being written. When the rename is refused, the staged copy -/// is itself complete and is the path returned. +/// Materialize a complete shim library in this invocation's leased scratch +/// directory. Shared cache publication and acquisition use logical identity; +/// the linker always owns the returned path for its lifetime. fn publishedShimLibraryPath( ctx: *CliCtx, kind: ShimLibraryKind, @@ -7526,15 +7349,41 @@ fn publishedShimLibraryPath( cache_path: []const u8, staged_path: []const u8, ) (std.Io.File.OpenError || std.Io.File.Writer.Error)![]const u8 { - const published = blk: { - std.Io.Dir.cwd().access(ctx.io.std_io, cache_path, .{}) catch break :blk false; - break :blk true; - }; - if (published) return cache_path; - + if (restoreCachedFile(ctx, .executable, cache_path, staged_path)) return staged_path; try extractShimLibrary(ctx, kind, staged_path, target); - std.Io.Dir.cwd().rename(staged_path, std.Io.Dir.cwd(), cache_path, ctx.io.std_io) catch return staged_path; - return cache_path; + publishCachedFile(ctx, .executable, cache_path, staged_path); + return staged_path; +} + +/// Logical names identify artifacts; returned files belong to this invocation's +/// leased scratch directory, so subsequent linker/process opens cannot race GC. +fn cachedFileIdentity(family: compile.artifacts.Family, name: []const u8) compile.artifacts.Identity { + var id: compile.artifacts.Identity = .{ .namespace = undefined, .family = family, .key = undefined }; + std.crypto.hash.sha2.Sha256.hash(build_options.compiler_compatibility_id, &id.namespace, .{}); + std.crypto.hash.sha2.Sha256.hash(name, &id.key, .{}); + return id; +} + +fn fileArtifactStore(ctx: *CliCtx) ?compile.artifacts.Store { + const cfg = cache_config_mod.CacheConfig{ .roc_ctx = ctx.coreCtx() }; + const root = cfg.getEffectiveCacheDir(ctx.gpa) catch return null; + defer ctx.gpa.free(root); + return compile.artifacts.Store.init(ctx.gpa, ctx.io.std_io, root) catch null; +} + +fn restoreCachedFile(ctx: *CliCtx, family: compile.artifacts.Family, name: []const u8, destination: []const u8) bool { + var store = fileArtifactStore(ctx) orelse return false; + defer store.deinit(); + var artifact = (store.acquire(cachedFileIdentity(family, name), store.now()) catch return false) orelse return false; + defer artifact.deinit(); + std.Io.Dir.cwd().copyFile(artifact.path, std.Io.Dir.cwd(), destination, ctx.io.std_io, .{}) catch return false; + return true; +} + +fn publishCachedFile(ctx: *CliCtx, family: compile.artifacts.Family, name: []const u8, source: []const u8) void { + var store = fileArtifactStore(ctx) orelse return; + defer store.deinit(); + store.importFile(cachedFileIdentity(family, name), source) catch return; } /// Extract the selected embedded shim library to the specified path for the given target. @@ -9079,7 +8928,7 @@ fn objectPackBytes( defer bitcode.deinit(); const scratch_dir = try createUniqueTempDir(ctx); - defer compile.CacheCleanup.deleteTempDir(ctx.io.std_io, scratch_dir); + defer releaseScratchDir(ctx, scratch_dir); const bitcode_path = try std.fs.path.join(ctx.arena, &.{ scratch_dir, "pack.bc" }); const object_path = try std.fs.path.join(ctx.arena, &.{ scratch_dir, "pack.o" }); backend.writeFileWindowsAvSafe(ctx.io.std_io, bitcode_path, std.mem.sliceAsBytes(bitcode.bitcode)) catch |err| { @@ -10634,7 +10483,7 @@ fn rocBuildWasm( const scratch_dir = createUniqueTempDir(ctx) catch |err| { return ctx.fail(.{ .temp_dir_failed = .{ .err = err } }); }; - defer if (!args.keep_temp) compile.CacheCleanup.deleteTempDir(ctx.io.std_io, scratch_dir); + defer if (!args.keep_temp) releaseScratchDir(ctx, scratch_dir); if (link_type == .archive) { // Archives package whatever inputs the platform declared (possibly @@ -11486,6 +11335,9 @@ fn buildCommandStaticData( /// Reports that `--keep-temp` kept the temporary directory `dir`. fn reportKeptTempDir(ctx: *CliCtx, dir: []const u8) Allocator.Error!void { + for (ctx.scratch.items) |*scratch| { + if (std.mem.eql(u8, scratch.path(), dir)) scratch.keep_until_expired = true; + } const config = ctx.reportConfig(.stderr); const palette = reporting.ColorUtils.getPaletteForConfig(config); var report = try reporting.Report.initWithPath(ctx.arena, "Kept Temporary Directory", "Kept temporary directory: ", dir, ".", .warning); @@ -11736,16 +11588,6 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu const output_path = try buildOutputBasePath(ctx, args); - const cache_config = CacheConfig{ - .enabled = !args.no_cache, - .verbose = args.verbose, - .roc_ctx = ctx.coreCtx(), - }; - var cache_manager = CacheManager.init(ctx.gpa, cache_config, ctx.coreCtx()); - const cache_dir = try cache_manager.config.getCacheEntriesDir(ctx.arena); - const build_cache_dir = try std.fs.path.join(ctx.arena, &.{ cache_dir, "roc_build" }); - try ensureCompilerCacheDirExists(ctx.io.std_io, build_cache_dir); - var build_env = try initBuildCommandEnv(ctx, args); defer build_env.deinit(); // Registered after build_env.deinit() so it runs first (LIFO), while build_env is still @@ -11885,7 +11727,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu const build_scratch_dir = createUniqueTempDir(ctx) catch |err| { return ctx.fail(.{ .temp_dir_failed = .{ .err = err } }); }; - defer compile.CacheCleanup.deleteTempDir(ctx.io.std_io, build_scratch_dir); + defer releaseScratchDir(ctx, build_scratch_dir); const obj_filename = try std.fmt.allocPrint(ctx.arena, "roc_app_{s}.o", .{@tagName(target)}); const obj_path = try std.fs.path.join(ctx.arena, &.{ build_scratch_dir, obj_filename }); backend.writeFileWindowsAvSafe(ctx.io.std_io, obj_path, output.object) catch { @@ -11975,7 +11817,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu }; const cleanup_build_scratch_dir = true; defer if (cleanup_build_scratch_dir) { - compile.CacheCleanup.deleteTempDir(ctx.io.std_io, build_scratch_dir); + releaseScratchDir(ctx, build_scratch_dir); }; const obj_filename = try std.fmt.allocPrint(ctx.arena, "roc_app_{s}.o", .{@tagName(target)}); @@ -12326,7 +12168,6 @@ fn rocBuildEmbedded(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildRe var cache_manager = CacheManager.init(ctx.gpa, cache_config, ctx.coreCtx()); const cache_dir = try cache_manager.config.getCacheEntriesDir(ctx.arena); const build_cache_dir = try std.fs.path.join(ctx.arena, &.{ cache_dir, "roc_build" }); - try ensureCompilerCacheDirExists(ctx.io.std_io, build_cache_dir); var build_env = try initBuildCommandEnv(ctx, args); defer build_env.deinit(); @@ -12427,7 +12268,7 @@ fn rocBuildEmbedded(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildRe return ctx.fail(.{ .temp_dir_failed = .{ .err = err } }); }; if (args.keep_temp) try reportKeptTempDir(ctx, build_scratch_dir); - defer if (!args.keep_temp) compile.CacheCleanup.deleteTempDir(ctx.io.std_io, build_scratch_dir); + defer if (!args.keep_temp) releaseScratchDir(ctx, build_scratch_dir); const shim_filename = try shimLibraryCacheFilename(ctx, .lir, target); const shim_path = publishedShimLibraryPath( @@ -12975,7 +12816,7 @@ fn storeCliTestResultsInCache( const entries_dir = try manager.config.getTestCacheDir(ctx.gpa); defer ctx.gpa.free(entries_dir); - manager.storeRawBytes(cliTestCacheKey(artifact.key, specialization_strategy, opt), bytes.items, entries_dir, artifact.moduleEnvConst().module_name); + manager.storeFamilyBytes(.test_result, cliTestCacheKey(artifact.key, specialization_strategy, opt), bytes.items, entries_dir, artifact.moduleEnvConst().module_name); } fn loadCliTestTranscriptEvents( @@ -13042,7 +12883,7 @@ fn loadCachedCliTestResults( const entries_dir = try manager.config.getTestCacheDir(ctx.gpa); defer ctx.gpa.free(entries_dir); - const data = manager.loadRawBytes(cliTestCacheKey(artifact.key, specialization_strategy, opt), entries_dir) orelse return null; + const data = manager.loadFamilyBytes(.test_result, cliTestCacheKey(artifact.key, specialization_strategy, opt), entries_dir) orelse return null; defer ctx.gpa.free(data); var offset: usize = 0; diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index c4378070549..d252bfcc5d4 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -16,233 +16,157 @@ const PackFile = backend.dev.PackFile; const Common = postcheck.Common; const CoreCtx = ctx_mod.CoreCtx; -/// The pack files of one compiler build, target, and optimization level, -/// under the cache root. Dev packs use the declared procedure domain; linked -/// packs and program outputs retain the exact target. Packs are filed by -/// module identity so an -/// edited module's previous packs stay beside its new one until the sweep -/// removes them, which is what lets unchanged specializations hit across an -/// edit. Every file is a pure function of the module and its transitive -/// imports, so two writers of one path write identical bytes and rename -/// into place without coordination. +/// Content-addressed packs and discovery summaries in the shared indexed store. +/// Module identity groups remain ordered by artifact key for deterministic offers. +/// Dev packs share a procedure domain; linked packs and outputs use exact targets. pub const Store = struct { allocator: Allocator, roc_ctx: CoreCtx, /// Exact final-link target and optimization level. root: []u8, - /// Procedure packs and discovery summaries, independently of final output. + /// Logical scope for procedure packs and discovery summaries. procedure_root: []u8, + native: compile.artifacts.Store, + variant: [32]u8, verbose: bool, pub const InitError = Allocator.Error || error{NoHomeDirectory}; - pub fn init(allocator: Allocator, cache_config: compile.CacheConfig, target: RocTarget, opt_name: []const u8) InitError!Store { - return initForStrategy(allocator, cache_config, target, opt_name, .lss); + pub fn init(allocator: Allocator, config: compile.CacheConfig, target: RocTarget, opt_name: []const u8) InitError!Store { + return initForStrategy(allocator, config, target, opt_name, .lss); } - pub fn initForStrategy(allocator: Allocator, cache_config: compile.CacheConfig, target: RocTarget, opt_name: []const u8, strategy: base.SpecializationStrategy) InitError!Store { - const version_dir = try cache_config.getVersionCacheDir(allocator); - defer allocator.free(version_dir); - const mode = try std.fmt.allocPrint(allocator, "{s}-{s}", .{ @tagName(target), opt_name }); - defer allocator.free(mode); - const namespace = compile.config.objectPackNamespace(PackFile.format_version); - const root = try std.fs.path.join(allocator, &.{ version_dir, namespace, mode }); + pub fn initForStrategy(allocator: Allocator, config: compile.CacheConfig, target: RocTarget, opt_name: []const u8, strategy: base.SpecializationStrategy) InitError!Store { + const root = try std.fmt.allocPrint(allocator, "{s}-{s}", .{ @tagName(target), opt_name }); errdefer allocator.free(root); const procedure_root = if (std.mem.eql(u8, opt_name, "dev")) blk: { const domain = backend.dev.NativeProcDomain.init(target, strategy); - const procedure_mode = try std.fmt.allocPrint(allocator, "proc-{s}-dev", .{std.fmt.bytesToHex(domain.key(), .lower)}); - defer allocator.free(procedure_mode); - break :blk try std.fs.path.join(allocator, &.{ version_dir, namespace, procedure_mode }); + break :blk try std.fmt.allocPrint(allocator, "proc-{s}-dev", .{std.fmt.bytesToHex(domain.key(), .lower)}); } else try allocator.dupe(u8, root); - return .{ .allocator = allocator, .roc_ctx = cache_config.roc_ctx, .root = root, .procedure_root = procedure_root, .verbose = cache_config.verbose }; + errdefer allocator.free(procedure_root); + const cache_root = try config.getEffectiveCacheDir(allocator); + defer allocator.free(cache_root); + return .{ .allocator = allocator, .roc_ctx = config.roc_ctx, .root = root, .procedure_root = procedure_root, .variant = compile.artifacts.hashBytes(opt_name), .native = try compile.artifacts.Store.init(allocator, config.roc_ctx.std_io, cache_root), .verbose = config.verbose }; } pub fn deinit(self: *Store) void { + self.native.deinit(); self.allocator.free(self.root); self.allocator.free(self.procedure_root); } - /// The directory of every pack filed under a module identity. - pub fn identityDir(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8) Allocator.Error![]u8 { - return std.fs.path.join(self.allocator, &.{ self.procedure_root, @tagName(origin), &std.fmt.bytesToHex(identity, .lower) }); + fn identity(self: *const Store, family: compile.artifacts.Family, module: [32]u8, key: [32]u8) compile.artifacts.Identity { + var result: compile.artifacts.Identity = .{ .namespace = undefined, .family = family, .group = undefined, .key = key, .pack_format = PackFile.format_version, .variant = self.variant }; + std.crypto.hash.sha2.Sha256.hash(@import("build_options").compiler_compatibility_id, &result.namespace, .{}); + var hash = std.crypto.hash.sha2.Sha256.init(.{}); + hash.update(switch (family) { + .local_pack, .package_pack, .local_discovery, .package_discovery => self.procedure_root, + else => self.root, + }); + hash.update(&module); + hash.final(&result.group); + return result; } - fn packPath(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8) Allocator.Error![]u8 { - return self.filePath(origin, identity, key, ".rpk"); + fn packIdentity(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, key: [32]u8) compile.artifacts.Identity { + return self.identity(if (origin == .local) .local_pack else .package_pack, module, key); } - fn filePath(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, comptime extension: []const u8) Allocator.Error![]u8 { - const name = std.fmt.bytesToHex(key, .lower) ++ extension[0..extension.len].*; - return std.fs.path.join(self.allocator, &.{ self.procedure_root, @tagName(origin), &std.fmt.bytesToHex(identity, .lower), &name }); + fn read(self: *const Store, allocator: Allocator, id: compile.artifacts.Identity) Allocator.Error!?[]u8 { + return self.native.read(allocator, id, self.native.now()) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => null, + }; } - /// File a program's discovery summaries (design.md "Discovery - /// Summaries") under a module identity, named by their content, beside - /// that module's packs. Summaries already filed are left alone. - pub fn writeDiscovery(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { - var name: [32]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(bytes, &name, .{}); - const path = try self.filePath(origin, identity, name, discovery_extension); - defer self.allocator.free(path); - try self.writePath(path, bytes); + fn writeEntry(self: *const Store, id: compile.artifacts.Identity, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + self.native.write(id, bytes, self.native.now()) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Busy => return, + else => { + if (self.verbose) { + const message = try std.fmt.allocPrint(self.allocator, "Failed to publish object cache at {s} ({d} bytes): {}\n", .{ self.native.root, bytes.len, err }); + defer self.allocator.free(message); + self.roc_ctx.writeStderr(message) catch {}; + } + return error.PackWriteFailed; + }, + }; } - fn programPath(self: *const Store, key: [32]u8) Allocator.Error![]u8 { - const name = std.fmt.bytesToHex(key, .lower) ++ ".rpo".*; - return std.fs.path.join(self.allocator, &.{ self.root, "programs", &name }); + pub fn writeDiscovery(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + var key: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &key, .{}); + try self.writeEntry(self.identity(if (origin == .local) .local_discovery else .package_discovery, module, key), bytes); } - /// The runtime output recorded for the program `key` names, or null. - /// An unreadable record is a miss. pub fn readProgram(self: *const Store, key: [32]u8, allocator: Allocator) Allocator.Error!?[]u8 { - const path = try self.programPath(key); - defer self.allocator.free(path); - return self.roc_ctx.readFile(path, allocator) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => null, - }; + return self.read(allocator, self.identity(.program_output, @splat(0), key)); } - /// Record the runtime output of the program `key` names. A record - /// already present holds the same output, and is left alone. pub fn writeProgram(self: *const Store, key: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { - const path = try self.programPath(key); - defer self.allocator.free(path); - try self.writePath(path, bytes); + try self.writeEntry(self.identity(.program_output, @splat(0), key), bytes); } - /// The object pack an optimized build of the program whose root module - /// has placement identity `identity` filed, which each optimized build of - /// that program replaces. - pub fn optimizedPackPath(self: *const Store, identity: [32]u8) Allocator.Error![]u8 { - const name = std.fmt.bytesToHex(identity, .lower) ++ ".rpk".*; - return std.fs.path.join(self.allocator, &.{ self.root, "optimized", &name }); + pub fn optimizedInputKey(self: *const Store, module: [32]u8) Allocator.Error!?[32]u8 { + const bytes = (try self.read(self.allocator, self.identity(.optimized_pack, module, @splat(0)))) orelse return null; + defer self.allocator.free(bytes); + return PackFile.objectInputKey(bytes); } - /// The object pack a URL-package module's artifact `key` names under - /// placement identity `identity`. - pub fn packagePackPath(self: *const Store, identity: [32]u8, key: [32]u8) Allocator.Error![]u8 { - return self.packPath(.pkg, identity, key); + pub fn replaceOptimized(self: *const Store, module: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + try self.writeEntry(self.identity(.optimized_pack, module, @splat(0)), bytes); } - /// The input key of the optimized pack filed for root `identity` - /// (`PackFile.ObjectBinding.input_key`), or null when there is none. - pub fn optimizedInputKey(self: *const Store, identity: [32]u8) Allocator.Error!?[32]u8 { - const path = try self.optimizedPackPath(identity); - defer self.allocator.free(path); - const bytes = self.roc_ctx.readFile(path, self.allocator) catch |err| switch (err) { + pub fn has(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, key: [32]u8) Allocator.Error!bool { + var artifact = (self.native.acquire(self.packIdentity(origin, module, key), self.native.now()) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, - else => return null, - }; - defer self.allocator.free(bytes); - return PackFile.objectInputKey(bytes); + else => return false, + }) orelse return false; + defer artifact.deinit(); + return true; } - /// File the optimized pack of root `identity`, replacing the one an - /// earlier optimized build filed. - pub fn replaceOptimized(self: *const Store, identity: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { - const path = try self.optimizedPackPath(identity); - defer self.allocator.free(path); - const dir = std.fs.path.dirname(path).?; - self.roc_ctx.makePath(dir) catch |err| { - self.reportWriteFailure("create directory", dir, bytes.len, err); - return error.PackWriteFailed; - }; - const temp_path = self.roc_ctx.writeTempFile(self.allocator, path, bytes) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => |write_err| { - self.reportWriteFailure("write staging file", path, bytes.len, write_err); - return error.PackWriteFailed; - }, - }; - defer self.allocator.free(temp_path); - errdefer self.roc_ctx.deleteFile(temp_path) catch {}; - self.roc_ctx.rename(temp_path, path) catch |err| { - self.reportWriteFailure("publish staging file", temp_path, bytes.len, err); - return error.PackWriteFailed; - }; + pub fn write(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, key: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + try self.writeEntry(self.packIdentity(origin, module, key), bytes); } - /// Whether the pack for `key` is already in the store. - pub fn has(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8) Allocator.Error!bool { - const path = try self.packPath(origin, identity, key); - defer self.allocator.free(path); - return self.roc_ctx.fileExists(path); + pub fn loadIdentity(self: *const Store, _: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, module: [32]u8) LoadedPacks.LoadError!void { + try self.loadGroup(packs, self.packIdentity(origin, module, @splat(0)), .code); } - /// Stage the pack beside its final path and rename it into place. A - /// pack already present is left alone: it holds the same bytes, which - /// builds with runtime safety check. - pub fn write(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { - const path = try self.packPath(origin, identity, key); - defer self.allocator.free(path); - try self.writePath(path, bytes); + pub fn loadIdentityDiscovery(self: *const Store, _: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, module: [32]u8) LoadedPacks.LoadError!void { + try self.loadGroup(packs, self.identity(if (origin == .local) .local_discovery else .package_discovery, module, @splat(0)), .discovery); } - /// Stage `bytes` in a file only this writer owns, beside `path`, and - /// rename it into place, so a reader or another writer never sees a - /// partly written file. - fn writePath(self: *const Store, path: []const u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { - const dir = std.fs.path.dirname(path).?; - self.roc_ctx.makePath(dir) catch |err| { - self.reportWriteFailure("create directory", dir, bytes.len, err); - return error.PackWriteFailed; - }; - if (self.roc_ctx.fileExists(path)) { - if (std.debug.runtime_safety) try self.verifyCollision(path, bytes); - return; - } - const temp_path = self.roc_ctx.writeTempFile(self.allocator, path, bytes) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => |write_err| { - self.reportWriteFailure("write staging file", path, bytes.len, write_err); - return error.PackWriteFailed; - }, - }; - defer self.allocator.free(temp_path); - errdefer self.roc_ctx.deleteFile(temp_path) catch {}; - // A concurrent writer that published first wrote the same bytes, so - // replacing its file is harmless. - self.roc_ctx.rename(temp_path, path) catch |err| { - self.reportWriteFailure("publish staging file", temp_path, bytes.len, err); - return error.PackWriteFailed; - }; - } - - /// A pack's path is a function of its content, so the pack a writer - /// finds already in place holds the bytes it would have written. A pack - /// the sweep removed in the meantime has nothing left to compare. - fn verifyCollision(self: *const Store, path: []const u8, bytes: []const u8) Allocator.Error!void { - const existing = self.roc_ctx.readFile(path, self.allocator) catch |err| switch (err) { + fn loadGroup(self: *const Store, packs: *LoadedPacks, id: compile.artifacts.Identity, files: LoadedPacks.Files) LoadedPacks.LoadError!void { + const keys = self.native.groupKeys(self.allocator, id) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return, }; - defer self.allocator.free(existing); - if (!std.mem.eql(u8, existing, bytes)) { - @import("base").invariant("object-cache pack {s} already holds different bytes", .{path}); + defer self.allocator.free(keys); + for (keys) |key| { + var artifact = (self.native.acquireKey(key, self.native.now()) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + }) orelse continue; + defer artifact.deinit(); + var arena = base.SingleThreadArena.init(self.allocator); + const bytes = artifact.read(arena.allocator()) catch |err| { + arena.deinit(); + if (err == error.OutOfMemory) return error.OutOfMemory; + continue; + }; + packs.bytes_loaded +|= bytes.len; + if (files == .discovery) { + defer arena.deinit(); + try packs.appendDiscovery(bytes); + } else packs.appendPack(arena, bytes) catch |err| switch (err) { + error.UnsupportedPackVersion => continue, + else => return err, + }; } } - - fn reportWriteFailure(self: *const Store, operation: []const u8, path: []const u8, byte_count: usize, err: (CoreCtx.MakePathError || CoreCtx.WriteError || CoreCtx.RenameError)) void { - if (!self.verbose) return; - const message = std.fmt.allocPrint(self.allocator, "Failed to {s} for object cache at {s} ({d} bytes): {}\n", .{ operation, path, byte_count, err }) catch return; - defer self.allocator.free(message); - self.roc_ctx.writeStderr(message) catch {}; - } - - /// Every pack filed under a module identity, in name order. - pub fn loadIdentity(self: *const Store, io: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, identity: [32]u8) LoadedPacks.LoadError!void { - const dir = try self.identityDir(origin, identity); - defer self.allocator.free(dir); - try packs.loadDirInto(io, dir, .cache_offers); - } - - /// Read the discovery summary files filed under a module identity. - pub fn loadIdentityDiscovery(self: *const Store, io: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, identity: [32]u8) LoadedPacks.LoadError!void { - const dir = try self.identityDir(origin, identity); - defer self.allocator.free(dir); - try packs.loadDirFiles(io, dir, .cache_offers, .discovery); - } }; test "object cache loading owns decoded packs and commits both indexes atomically" { @@ -384,147 +308,6 @@ test "object cache accepts new offers after indexing without moving served artif } } -test "object cache write failures preserve quiet behavior and report verbose causes" { - const Capture = struct { - stderr: std.ArrayList(u8) = .empty, - stage: enum { directory, write, rename }, - deleted: usize = 0, - - fn makePath(context: ?*anyopaque, _: std.Io, _: []const u8) CoreCtx.MakePathError!void { - const self: *@This() = @ptrCast(@alignCast(context.?)); - if (self.stage == .directory) return error.ReadOnlyFileSystem; - } - - fn fileExists(_: ?*anyopaque, _: std.Io, _: []const u8) bool { - return false; - } - - fn writeTempFile(context: ?*anyopaque, _: std.Io, allocator: Allocator, path: []const u8, _: []const u8) (Allocator.Error || CoreCtx.WriteError)![]u8 { - const self: *@This() = @ptrCast(@alignCast(context.?)); - if (self.stage == .write) return error.NoSpaceLeft; - return std.fmt.allocPrint(allocator, "{s}.owned.tmp", .{path}); - } - - fn deleteFile(context: ?*anyopaque, _: std.Io, path: []const u8) CoreCtx.DeleteError!void { - const self: *@This() = @ptrCast(@alignCast(context.?)); - std.debug.assert(std.mem.endsWith(u8, path, ".owned.tmp")); - self.deleted += 1; - } - - fn rename(context: ?*anyopaque, _: std.Io, _: []const u8, _: []const u8) CoreCtx.RenameError!void { - const self: *@This() = @ptrCast(@alignCast(context.?)); - if (self.stage == .rename) return error.DiskQuota; - } - - fn writeStderr(context: ?*anyopaque, _: std.Io, bytes: []const u8) CoreCtx.StdioError!void { - const self: *@This() = @ptrCast(@alignCast(context.?)); - self.stderr.appendSlice(std.testing.allocator, bytes) catch return error.IoError; - } - }; - const allocator = std.testing.allocator; - for ([_]bool{ false, true }) |verbose| { - for (std.enums.values(@FieldType(Capture, "stage"))) |stage| { - var capture = Capture{ .stage = stage }; - defer capture.stderr.deinit(allocator); - var filesystem = CoreCtx.testing(allocator, allocator); - filesystem.ctx = &capture; - filesystem.vtable.makePath = &Capture.makePath; - filesystem.vtable.fileExists = &Capture.fileExists; - filesystem.vtable.writeTempFile = &Capture.writeTempFile; - filesystem.vtable.deleteFile = &Capture.deleteFile; - filesystem.vtable.rename = &Capture.rename; - filesystem.vtable.writeStderr = &Capture.writeStderr; - var store = try Store.init(allocator, .{ - .cache_dir = "cache", - .roc_ctx = filesystem, - .verbose = verbose, - }, .arm64mac, "dev"); - defer store.deinit(); - for (0..2) |_| { - try std.testing.expectError(error.PackWriteFailed, store.write(.local, @as([32]u8, @splat(1)), @as([32]u8, @splat(2)), "pack")); - } - try std.testing.expectEqual(@as(usize, if (stage == .rename) 2 else 0), capture.deleted); - if (verbose) { - const cause = switch (stage) { - .directory => "error.ReadOnlyFileSystem", - .write => "error.NoSpaceLeft", - .rename => "error.DiskQuota", - }; - try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, capture.stderr.items, cause)); - try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, capture.stderr.items, "(4 bytes)")); - try std.testing.expect(std.mem.containsAtLeast(u8, capture.stderr.items, 1, store.procedure_root)); - } else { - try std.testing.expectEqualStrings("", capture.stderr.items); - } - } - } -} - -test "object cache interleaved writers expose only complete final bytes" { - const Hook = struct { - store: *Store, - base: CoreCtx, - pending: ?[]const u8 = null, - distinct: bool = false, - observed_complete: bool = false, - - fn writeTempFile(context: ?*anyopaque, _: std.Io, allocator: Allocator, path: []const u8, bytes: []const u8) (Allocator.Error || CoreCtx.WriteError)![]u8 { - const self: *@This() = @ptrCast(@alignCast(context.?)); - if (self.pending) |pending| { - const staging = try self.base.writeTempFile(allocator, path, bytes); - self.distinct = !std.mem.eql(u8, pending, staging); - return staging; - } - // Suspend writer A after a partial write. Writer B finishes and - // publishes before A resumes, without threads or timing thresholds. - const staging = try self.base.writeTempFile(allocator, path, bytes[0..1]); - errdefer { - self.base.deleteFile(staging) catch {}; - allocator.free(staging); - } - self.pending = staging; - defer self.pending = null; - std.debug.assert(!self.base.fileExists(path)); - self.store.writePath(path, bytes) catch return error.IoError; - const visible = self.base.readFile(path, allocator) catch return error.IoError; - defer allocator.free(visible); - self.observed_complete = std.mem.eql(u8, visible, bytes); - try self.base.writeFile(staging, bytes); - const after = self.base.readFile(path, allocator) catch return error.IoError; - defer allocator.free(after); - self.observed_complete = self.observed_complete and std.mem.eql(u8, after, bytes); - return staging; - } - }; - const allocator = std.testing.allocator; - const io = std.testing.io; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); - defer allocator.free(root); - const base_ctx = CoreCtx.default(allocator, allocator, io); - var store = Store{ .allocator = allocator, .roc_ctx = base_ctx, .root = try allocator.dupe(u8, root), .procedure_root = try allocator.dupe(u8, root), .verbose = false }; - defer store.deinit(); - var hook = Hook{ .store = &store, .base = base_ctx }; - store.roc_ctx.ctx = &hook; - store.roc_ctx.vtable.writeTempFile = &Hook.writeTempFile; - const identity = @as([32]u8, @splat(1)); - try store.write(.local, identity, @as([32]u8, @splat(2)), "whole completed pack"); - try std.testing.expect(hook.distinct); - try std.testing.expect(hook.observed_complete); - const dir_path = try store.identityDir(.local, identity); - defer allocator.free(dir_path); - var dir = try std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }); - defer dir.close(io); - var iterator = dir.iterate(); - var count: usize = 0; - while (try iterator.next(io)) |entry| { - try std.testing.expect(!std.mem.endsWith(u8, entry.name, ".tmp")); - count += 1; - } - try std.testing.expectEqual(@as(usize, 1), count); -} - test "native procedure domain shares packs but isolates final outputs and linked objects" { const a = std.testing.allocator; const io = std.testing.io; @@ -564,62 +347,36 @@ test "native procedure domain shares packs but isolates final outputs and linked defer right.deinit(); try left.write(.pkg, identity, key, "linked-only package"); try std.testing.expect(!try right.has(.pkg, identity, key)); - const left_path = try left.optimizedPackPath(identity); - defer a.free(left_path); - const right_path = try right.optimizedPackPath(identity); - defer a.free(right_path); - try std.testing.expect(!std.mem.eql(u8, left_path, right_path)); + const left_key = left.identity(.optimized_pack, identity, identity).indexedKey(); + const right_key = right.identity(.optimized_pack, identity, identity).indexedKey(); + try std.testing.expect(!std.mem.eql(u8, &left_key, &right_key)); } } -test "the cache sweep finds and ages the packs the store writes" { +test "indexed pack families share retention and preserve accepted bytes" { const allocator = std.testing.allocator; const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - const cache_root = try tmp.dir.realPathFileAlloc(io, ".", allocator); - defer allocator.free(cache_root); - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - const cache_config: compile.CacheConfig = .{ - .cache_dir = cache_root, - .roc_ctx = CoreCtx.default(allocator, arena.allocator(), io), - }; - var store = try Store.init(allocator, cache_config, .x64glibc, "dev"); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var store = try Store.init(allocator, .{ .cache_dir = root, .roc_ctx = CoreCtx.default(allocator, allocator, io) }, .x64glibc, "dev"); defer store.deinit(); - - const local_identity = @as([32]u8, @splat(1)); - const pkg_identity = @as([32]u8, @splat(2)); - const key = @as([32]u8, @splat(3)); - try store.write(.local, local_identity, key, "local pack"); - try store.write(.pkg, pkg_identity, key, "pkg pack"); - // A second writer of the same pack finds it in place with its bytes. - try store.write(.pkg, pkg_identity, key, "pkg pack"); - try std.testing.expect(try store.has(.local, local_identity, key)); - try std.testing.expect(try store.has(.pkg, pkg_identity, key)); - - // A namespace of another pack format beside the store's own. - const version_dir = try cache_config.getVersionCacheDir(allocator); - defer allocator.free(version_dir); - const stale_dir = try std.fs.path.join(allocator, &.{ version_dir, compile.config.objectPackNamespace(PackFile.format_version - 1), "x64glibc-dev", "pkg" }); - defer allocator.free(stale_dir); - try std.Io.Dir.cwd().createDirPath(io, stale_dir); - - const limits: compile.CacheCleanup.Limits = .{ - .object_pack_format_version = PackFile.format_version, - .max_size_bytes = std.math.maxInt(u64), - }; - const now_ns = compile.CacheCleanup.nowNs(io); - var stats: compile.CacheCleanup.CleanupStats = .{}; - compile.CacheCleanup.sweep(io, cache_root, limits, now_ns + 2 * compile.CacheCleanup.Config.LOCAL_PACK_MAX_AGE_NS, &stats); - try std.testing.expectEqual(@as(u32, 1), stats.stale_namespaces_deleted); - try std.testing.expectEqual(@as(u32, 1), stats.cache_files_deleted); - try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().access(io, stale_dir, .{})); - try std.testing.expect(!try store.has(.local, local_identity, key)); - try std.testing.expect(try store.has(.pkg, pkg_identity, key)); - - compile.CacheCleanup.sweep(io, cache_root, limits, now_ns + compile.CacheCleanup.Config.PERSISTENT_MAX_AGE_NS + std.time.ns_per_s, &stats); - try std.testing.expect(!try store.has(.pkg, pkg_identity, key)); + const module: [32]u8 = @splat(1); + const key: [32]u8 = @splat(2); + try store.write(.local, module, key, "local"); + try store.write(.pkg, module, key, "package"); + const owned = (try store.read(allocator, store.packIdentity(.local, module, key))).?; + defer allocator.free(owned); + var now = store.native.now() + 2 * compile.artifacts.day_ns; + for (0..180) |_| { + var stats = compile.maintenance.Stats{}; + compile.maintenance.run(&store.native, .{ .object_pack_format_version = PackFile.format_version, .max_size_bytes = std.math.maxInt(u64) }, now, .compulsory, &stats); + now += std.time.ns_per_s; + } + try std.testing.expect(!try store.has(.local, module, key)); + try std.testing.expect(try store.has(.pkg, module, key)); + try std.testing.expectEqualStrings("local", owned); } /// The stores of LLVM-compiled object packs a dev build for one target links @@ -651,18 +408,12 @@ pub const LinkedStores = struct { hasher.update("roc.optimization-state.v1"); for (&self.stores) |*store| { for (modules) |module| { - const path = try store.packagePackPath(module.identity, module.key); - defer allocator.free(path); - if (!store.roc_ctx.fileExists(path)) continue; - hasher.update(path); - hasher.update(&.{0}); + if (!try store.has(.pkg, module.identity, module.key)) continue; + hasher.update(&store.packIdentity(.pkg, module.identity, module.key).indexedKey()); } const placement = optimizedPackPlacement(build_env, root_artifact) orelse continue; const input_key = try store.optimizedInputKey(placement.identity) orelse continue; - const path = try store.optimizedPackPath(placement.identity); - defer allocator.free(path); - hasher.update(path); - hasher.update(&.{0}); + hasher.update(&store.identity(.optimized_pack, placement.identity, @splat(0)).indexedKey()); hasher.update(&input_key); } var digest: [32]u8 = undefined; @@ -967,6 +718,7 @@ pub const LoadedPacks = struct { var arena = base.SingleThreadArena.init(allocator); const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena.allocator(), .limited(1024 * 1024 * 1024)) catch |err| { arena.deinit(); + if (err == error.FileNotFound and input == .cache_offers) continue; return switch (err) { error.OutOfMemory => error.OutOfMemory, else => error.PackDirectoryUnreadable, @@ -992,19 +744,16 @@ pub const LoadedPacks = struct { } } - /// Read the object pack at `path` without indexing. A missing file, a - /// pack of another format, or a pack that offers nothing is no offer. - fn loadObjectFile(self: *LoadedPacks, io: std.Io, path: []const u8) LoadError!void { + /// Acquire owned bytes before accepting an object-cache offer. + fn loadStoredObject(self: *LoadedPacks, store: *const Store, id: compile.artifacts.Identity) LoadError!void { const allocator = self.allocator; - // The decoded pack aliases the file bytes, which its arena owns. var arena = base.SingleThreadArena.init(allocator); - const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena.allocator(), .limited(1024 * 1024 * 1024)) catch |err| { + const bytes = (store.read(arena.allocator(), id) catch |err| { arena.deinit(); - return switch (err) { - error.OutOfMemory => error.OutOfMemory, - error.FileNotFound => {}, - else => error.PackDirectoryUnreadable, - }; + return err; + }) orelse { + arena.deinit(); + return; }; self.bytes_loaded +|= bytes.len; var pack = PackFile.readObjectOwned(arena, bytes) catch |err| switch (err) { @@ -1181,14 +930,10 @@ pub const LoadedPacks = struct { const root_placement = optimizedPackPlacement(pending.build_env, root_artifact); for (&stores.stores) |*store| { for (modules) |module| { - const path = try store.packagePackPath(module.identity, module.key); - defer self.allocator.free(path); - try self.loadObjectFile(pending.io, path); + try self.loadStoredObject(store, store.packIdentity(.pkg, module.identity, module.key)); } if (root_placement) |placement| { - const path = try store.optimizedPackPath(placement.identity); - defer self.allocator.free(path); - try self.loadObjectFile(pending.io, path); + try self.loadStoredObject(store, store.identity(.optimized_pack, placement.identity, @splat(0))); } } try self.indexObjectPacks(try programBindingKey(self.allocator, pending.build_env, root_artifact)); @@ -1581,16 +1326,16 @@ test "an object pack that offers nothing is no offer and contributes no object t // linked object. const empty = try PackFile.writeObject(allocator, "", &.{}, &.{}, .{ .input_key = @splat(9), .app_bound = &.{} }); defer allocator.free(empty); - try tmp.dir.writeFile(io, .{ .sub_path = "offering.rpo", .data = offering }); - try tmp.dir.writeFile(io, .{ .sub_path = "empty.rpo", .data = empty }); - + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var store = try Store.init(allocator, .{ .cache_dir = root, .roc_ctx = CoreCtx.os(allocator, allocator, io) }, .x64glibc, "dev"); + defer store.deinit(); + const module: [32]u8 = @splat(7); + try store.write(.pkg, module, @splat(1), empty); + try store.write(.pkg, module, @splat(2), offering); var packs = LoadedPacks.init(allocator); defer packs.deinit(); - for ([_][]const u8{ "empty.rpo", "offering.rpo" }) |name| { - const path = try tmp.dir.realPathFileAlloc(io, name, allocator); - defer allocator.free(path); - try packs.loadObjectFile(io, path); - } + for ([_]u8{ 1, 2 }) |key| try packs.loadStoredObject(&store, store.packIdentity(.pkg, module, @splat(key))); try std.testing.expectEqual(@as(usize, 1), packs.object_packs.items.len); try std.testing.expectEqualStrings("object", packs.object_packs.items[0].object); } diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 46de63e39eb..4ec6719778e 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -19,6 +19,7 @@ //! --verbose Print PASS results and timing details const std = @import("std"); +const artifact_store = @import("artifact_store"); const build_options = @import("build_options"); const builtin = @import("builtin"); const posix = std.posix; @@ -43,7 +44,7 @@ const timeout_result_grace_ms: u64 = 5_000; const default_timeout_ms: u64 = 120_000; const glue_timeout_ms: u64 = 240_000; -const CliRunnerError = util.RocRunError || +const CliRunnerError = artifact_store.Error || util.RocRunError || util.ChildTimeoutError || util.TestDirError || std.mem.Allocator.Error || @@ -7520,6 +7521,14 @@ fn runFirstAvailable( ) CliRunnerError!?std.process.RunResult { for (candidates) |argv| { return runRawInEnv(io, allocator, env, argv, project_root_path, null, timeout_ms) catch |err| switch (err) { + error.IoFailure, + error.CorruptCatalog, + error.CapacityExceeded, + error.Busy, + error.Missing, + error.Changed, + error.InvalidArtifact, + error.PathTooLong, error.AccessDenied, error.AntivirusInterference, error.AssignProcessToJobObjectFailed, @@ -8110,6 +8119,31 @@ const pack_store_reproducible_apps = [_][]const u8{ "test/str/app_diamond.roc", }; +/// The tests inspect explicit family/variant metadata, never filename layouts. +/// This is a foreground fixture inventory, not the bounded maintenance path. +fn indexedCacheEntries(io: std.Io, allocator: Allocator, cache_root: []const u8) CliRunnerError![]artifact_store.Metadata { + var store = try artifact_store.Store.init(allocator, io, cache_root); + defer store.deinit(); + var entries: std.ArrayList(artifact_store.Metadata) = .empty; + errdefer entries.deinit(allocator); + for (0..artifact_store.shard_count) |number| { + var counters = artifact_store.catalog.Counters{}; + var shard = artifact_store.Shard.open(&store, @intCast(number), false, &counters) catch |err| switch (err) { + error.Missing => continue, + else => return err, + }; + defer shard.close(); + var tx = shard.index.transaction(); + var cursor: artifact_store.catalog.Key = @splat(0); + while (try tx.lowerBound(.owners, cursor)) |entry| { + try entries.append(allocator, try artifact_store.Metadata.decode(entry.value)); + cursor = entry.key; + if (!artifact_store.advanceKey(&cursor)) break; + } + } + return entries.toOwnedSlice(allocator); +} + /// Every object-cache pack under `cache_root`, by its path relative to it. const PackStoreFiles = struct { paths: std.ArrayList([]u8) = .empty, @@ -8125,18 +8159,24 @@ const PackStoreFiles = struct { fn read(io: std.Io, allocator: Allocator, cache_root: []const u8) CliRunnerError!PackStoreFiles { var result: PackStoreFiles = .{}; errdefer result.deinit(allocator); - var cache = try std.Io.Dir.cwd().openDir(io, cache_root, .{ .iterate = true }); - defer cache.close(io); - var walker = try cache.walk(allocator); - defer walker.deinit(); - while (try walker.next(io)) |entry| { - if (entry.kind != .file or !std.mem.endsWith(u8, entry.basename, ".rpk")) continue; - const path = try allocator.dupe(u8, entry.path); + var store = try artifact_store.Store.init(allocator, io, cache_root); + defer store.deinit(); + const entries = try indexedCacheEntries(io, allocator, cache_root); + defer allocator.free(entries); + for (entries) |entry| { + if (entry.state != .published) continue; + switch (entry.family) { + .local_pack, .package_pack, .optimized_pack => {}, + else => continue, + } + const path = try allocator.dupe(u8, &std.fmt.bytesToHex(entry.key, .lower)); result.paths.append(allocator, path) catch |err| { allocator.free(path); return err; }; - const bytes = try cache.readFileAlloc(io, entry.path, allocator, .limited(1024 * 1024 * 1024)); + var artifact = (try store.acquireKey(entry.key, store.now())) orelse return error.InvalidArtifact; + defer artifact.deinit(); + const bytes = try artifact.read(allocator); result.contents.append(allocator, bytes) catch |err| { allocator.free(bytes); return err; @@ -9761,8 +9801,7 @@ fn customSharedEarlyObjectCache( } /// Delete the object packs optimized builds filed in the case's cache -/// (`objects-v*/-speed/` and `-size/`), its optimization -/// state. +/// in the speed and size variants, its optimization state. fn forgetOptimizationState(io: std.Io, allocator: Allocator, env: *const CaseEnv, timer: *harness.Timer) ?TestResult { const cache_root = env.env_map.get("ROC_CACHE_DIR") orelse return customInfraFailure(allocator, timer, "case has no isolated cache", .{}); @@ -9772,26 +9811,12 @@ fn forgetOptimizationState(io: std.Io, allocator: Allocator, env: *const CaseEnv } fn deleteOptimizationState(io: std.Io, allocator: Allocator, cache_root: []const u8) CliRunnerError!void { - var root = std.Io.Dir.cwd().openDir(io, cache_root, .{ .iterate = true }) catch |err| switch (err) { - error.FileNotFound => return, - else => return err, - }; - defer root.close(io); - var walker = try root.walk(allocator); - defer walker.deinit(); - var stores = std.ArrayList([]const u8).empty; - defer { - for (stores.items) |path| allocator.free(path); - stores.deinit(allocator); + const entries = try indexedCacheEntries(io, allocator, cache_root); + defer allocator.free(entries); + for (entries) |entry| { + if (!entry.family.isPack() or entry.state != .published) continue; + if (std.mem.eql(u8, &entry.variant, &artifact_store.hashBytes("speed")) or std.mem.eql(u8, &entry.variant, &artifact_store.hashBytes("size"))) try retireTestEntry(io, allocator, cache_root, entry); } - while (try walker.next(io)) |entry| { - if (entry.kind != .directory) continue; - const parent = std.fs.path.basename(std.fs.path.dirname(entry.path) orelse continue); - if (!std.mem.startsWith(u8, parent, "objects-v")) continue; - if (!std.mem.endsWith(u8, entry.basename, "-speed") and !std.mem.endsWith(u8, entry.basename, "-size")) continue; - try stores.append(allocator, try allocator.dupe(u8, entry.path)); - } - for (stores.items) |path| try root.deleteTree(io, path); } /// Before a build that must serve the program from the object cache's packs: @@ -9805,26 +9830,21 @@ fn forgetRecordedProgramOutputs(io: std.Io, allocator: Allocator, env: *const Ca return null; } -/// Delete every recorded program output (`objects-v*//programs/`) under +/// Delete every recorded program output under /// `cache_root`. +fn retireTestEntry(io: std.Io, allocator: Allocator, cache_root: []const u8, entry: artifact_store.Metadata) CliRunnerError!void { + var store = try artifact_store.Store.init(allocator, io, cache_root); + defer store.deinit(); + var counters = artifact_store.catalog.Counters{}; + var shard = try artifact_store.Shard.open(&store, artifact_store.Store.shardFor(entry.key), false, &counters); + defer shard.close(); + _ = try shard.retire(entry.key, entry.generation); +} + fn deleteRecordedProgramOutputs(io: std.Io, allocator: Allocator, cache_root: []const u8) CliRunnerError!void { - var root = std.Io.Dir.cwd().openDir(io, cache_root, .{ .iterate = true }) catch |err| switch (err) { - error.FileNotFound => return, - else => return err, - }; - defer root.close(io); - var walker = try root.walk(allocator); - defer walker.deinit(); - var programs = std.ArrayList([]const u8).empty; - defer { - for (programs.items) |path| allocator.free(path); - programs.deinit(allocator); - } - while (try walker.next(io)) |entry| { - if (entry.kind != .directory or !std.mem.eql(u8, entry.basename, "programs")) continue; - try programs.append(allocator, try allocator.dupe(u8, entry.path)); - } - for (programs.items) |path| try root.deleteTree(io, path); + const entries = try indexedCacheEntries(io, allocator, cache_root); + defer allocator.free(entries); + for (entries) |entry| if (entry.family == .program_output and entry.state == .published) try retireTestEntry(io, allocator, cache_root, entry); } /// Pin the platform's dependent export, not only an independent imported loop. @@ -12440,52 +12460,18 @@ fn writeGeneratedTypeModule( try out.flush(); } -/// Count the entry files one cache section holds. The compiler keeps its two -/// module caches in sibling directories under the version directory (checked -/// artifacts in `mod`, canonicalization output in `can`), so a count must name -/// the section it means rather than every file under the cache root. +/// Count published entries of the explicitly selected module-cache family. fn countModuleCacheFiles(io: std.Io, allocator: Allocator, cache_path: []const u8, section: []const u8) CliRunnerError!usize { - var cache_dir = std.Io.Dir.cwd().openDir(io, cache_path, .{ .iterate = true }) catch |err| switch (err) { - error.AccessDenied, - error.BadPathName, - error.Canceled, - error.NameTooLong, - error.NetworkNotFound, - error.NoDevice, - error.NotDir, - error.PermissionDenied, - error.ProcessFdQuotaExceeded, - error.SymLinkLoop, - error.SystemFdQuotaExceeded, - error.SystemResources, - error.Unexpected, - => return err, - error.FileNotFound => return 0, - }; - defer cache_dir.close(io); - - var walker = try cache_dir.walk(allocator); - defer walker.deinit(); - + const family: artifact_store.Family = if (std.mem.eql(u8, section, "mod")) .checked else .canonicalized; + const entries = try indexedCacheEntries(io, allocator, cache_path); + defer allocator.free(entries); var count: usize = 0; - while (try walker.next(io)) |entry| { - if (entry.kind != .file) continue; - if (std.mem.endsWith(u8, entry.basename, ".meta")) continue; - if (std.mem.endsWith(u8, entry.basename, ".tmp")) continue; - if (!cachePathIsInSection(entry.path, section)) continue; + for (entries) |entry| if (entry.state == .published and entry.family == family) { count += 1; - } + }; return count; } -/// Whether a cache-root-relative entry path lies under `/
/`. -fn cachePathIsInSection(entry_path: []const u8, section: []const u8) bool { - var components = std.mem.tokenizeAny(u8, entry_path, "/\\"); - _ = components.next() orelse return false; // compiler version directory - const entry_section = components.next() orelse return false; - return std.mem.eql(u8, entry_section, section); -} - /// The `--timings` row that covers LLVM's optimization pipeline and object /// emission for the app. const llvm_emit_phase_name = "LLVM Optimize + Emit"; @@ -12827,7 +12813,7 @@ fn customBuildDefaultAppArgs( /// Where the object and bitcode files under a compiler cache root lie. const CacheIntermediates = struct { - /// Files inside the per-invocation scratch section, `/tmp/`. + /// Files inside registered scratch directories not yet retired. in_scratch: usize = 0, /// The first file found in a section that builds share, if any. first_shared: ?[]const u8 = null, @@ -12843,6 +12829,10 @@ fn findCacheIntermediates(io: std.Io, allocator: Allocator, cache_path: []const var walker = try cache_dir.walk(allocator); defer walker.deinit(); + const entries = try indexedCacheEntries(io, allocator, cache_path); + defer allocator.free(entries); + var store = try artifact_store.Store.init(allocator, io, cache_path); + defer store.deinit(); var found: CacheIntermediates = .{}; while (try walker.next(io)) |entry| { if (entry.kind != .file) continue; @@ -12851,11 +12841,22 @@ fn findCacheIntermediates(io: std.Io, allocator: Allocator, cache_path: []const std.mem.eql(u8, extension, ".obj") or std.mem.eql(u8, extension, ".bc"); if (!is_intermediate) continue; - if (cachePathIsInSection(entry.path, "tmp")) { - found.in_scratch += 1; - } else if (found.first_shared == null) { - found.first_shared = try allocator.dupe(u8, entry.path); + const absolute = try std.fs.path.resolve(allocator, &.{ cache_path, entry.path }); + defer allocator.free(absolute); + var registered = false; + for (entries) |metadata| { + if (metadata.family != .scratch) continue; + const physical = try store.payloadPath(artifact_store.Store.shardFor(metadata.key), metadata.slot, ".pending"); + defer allocator.free(physical); + const scratch = try std.fs.path.resolve(allocator, &.{physical}); + defer allocator.free(scratch); + if (absolute.len > scratch.len and std.mem.eql(u8, absolute[0..scratch.len], scratch) and std.fs.path.isSep(absolute[scratch.len])) { + registered = true; + if (metadata.state != .retiring) found.in_scratch += 1; + break; + } } + if (!registered and found.first_shared == null) found.first_shared = try allocator.dupe(u8, entry.path); } return found; } @@ -12863,7 +12864,8 @@ fn findCacheIntermediates(io: std.Io, allocator: Allocator, cache_path: []const /// Concurrent interpreter builds share one compiler cache, so every file a /// build writes while it works must lie in a directory that build owns. A /// build leaves no intermediate in a shared cache section and removes its -/// scratch directory when it finishes; `--keep-temp` keeps one scratch +/// scratch ownership when it finishes; bounded maintenance removes its files. +/// `--keep-temp` keeps one scratch /// directory per build, so two builds never write the same intermediate path. fn customBuildInterpreterOwnsIntermediates( io: std.Io, @@ -12888,9 +12890,8 @@ fn customBuildInterpreterOwnsIntermediates( if (after_build.first_shared) |path| { return customFailure(allocator, timer, "interpreter build wrote the intermediate {s} into a cache section that concurrent builds share", .{path}); } - if (after_build.in_scratch != 0) { - return customFailure(allocator, timer, "interpreter build left {d} intermediate files in its scratch directory", .{after_build.in_scratch}); - } + // Reclamation is bounded and may finish in later invocations. Every + // intermediate must already belong to a registered scratch generation. var kept_after_first: usize = 0; for (0..2) |build_index| { @@ -13256,38 +13257,7 @@ fn customPipelineParityDiagnostics(io: std.Io, allocator: Allocator, env: *const /// Count only checked-module cache entries (`.../mod/...`), excluding the shim /// and linked-executable caches that the run verb also writes under the root. fn countCheckedModuleCacheFiles(io: std.Io, allocator: Allocator, cache_path: []const u8) CliRunnerError!usize { - var cache_dir = std.Io.Dir.cwd().openDir(io, cache_path, .{ .iterate = true }) catch |err| switch (err) { - error.AccessDenied, - error.BadPathName, - error.Canceled, - error.NameTooLong, - error.NetworkNotFound, - error.NoDevice, - error.NotDir, - error.PermissionDenied, - error.ProcessFdQuotaExceeded, - error.SymLinkLoop, - error.SystemFdQuotaExceeded, - error.SystemResources, - error.Unexpected, - => return err, - error.FileNotFound => return 0, - }; - defer cache_dir.close(io); - - var walker = try cache_dir.walk(allocator); - defer walker.deinit(); - - const mod_segment = std.fs.path.sep_str ++ "mod" ++ std.fs.path.sep_str; - var count: usize = 0; - while (try walker.next(io)) |entry| { - if (entry.kind != .file) continue; - if (std.mem.find(u8, entry.path, mod_segment) == null) continue; - if (std.mem.endsWith(u8, entry.basename, ".meta")) continue; - if (std.mem.endsWith(u8, entry.basename, ".tmp")) continue; - count += 1; - } - return count; + return countModuleCacheFiles(io, allocator, cache_path, "mod"); } fn customIssue11344Diagnostics(io: std.Io, allocator: Allocator, env: *const CaseEnv, timer: *harness.Timer, timeout_ms: u64) ?TestResult { @@ -16430,45 +16400,16 @@ fn hostObjectExtension() []const u8 { } fn countGluePluginCacheFiles(io: std.Io, allocator: Allocator, cache_path: []const u8, opt_name: []const u8) CliRunnerError!GluePluginCacheFileCounts { - var cache_dir = std.Io.Dir.cwd().openDir(io, cache_path, .{ .iterate = true }) catch |err| switch (err) { - error.AccessDenied, - error.BadPathName, - error.Canceled, - error.NameTooLong, - error.NetworkNotFound, - error.NoDevice, - error.NotDir, - error.PermissionDenied, - error.ProcessFdQuotaExceeded, - error.SymLinkLoop, - error.SystemFdQuotaExceeded, - error.SystemResources, - error.Unexpected, - => return err, - error.FileNotFound => return .{}, - }; - defer cache_dir.close(io); - - var walker = try cache_dir.walk(allocator); - defer walker.deinit(); - - const opt_segment = try std.fmt.allocPrint(allocator, "{s}{s}{s}", .{ std.fs.path.sep_str, opt_name, std.fs.path.sep_str }); - defer allocator.free(opt_segment); - - const extension = hostObjectExtension(); + const entries = try indexedCacheEntries(io, allocator, cache_path); + defer allocator.free(entries); + const variant = artifact_store.hashBytes(opt_name); var counts = GluePluginCacheFileCounts{}; - while (try walker.next(io)) |entry| { - if (entry.kind != .file) continue; - if (std.mem.find(u8, entry.path, "glue-plugin") == null) continue; - - if (std.mem.endsWith(u8, entry.basename, ".tmp")) { - counts.temps += 1; - continue; - } - - if (std.mem.find(u8, entry.path, opt_segment) == null) continue; - if (std.mem.endsWith(u8, entry.basename, extension)) { - counts.plugins += 1; + for (entries) |entry| { + if (entry.family != .glue_plugin or !std.mem.eql(u8, &entry.variant, &variant)) continue; + switch (entry.state) { + .published => counts.plugins += 1, + .staging => counts.temps += 1, + .retiring => {}, } } return counts; diff --git a/src/compile/artifact_store.zig b/src/compile/artifact_store.zig new file mode 100644 index 00000000000..f705fbd5437 --- /dev/null +++ b/src/compile/artifact_store.zig @@ -0,0 +1,924 @@ +//! Native, indexed artifact storage. All payloads, including unfinished ones, +//! have a catalog slot before creation. Shard locks protect only metadata and +//! acquisition; payload production never holds a catalog lock. + +const std = @import("std"); +pub const catalog = @import("cache_index.zig"); +const Io = std.Io; +const Dir = Io.Dir; +const Allocator = std.mem.Allocator; +const put = catalog.put; +const get = catalog.get; + +pub const format_directory = "artifacts-v1"; +pub const shard_count = 16; +pub const minute_ns: i64 = 60 * std.time.ns_per_s; +pub const day_ns: i64 = 24 * 60 * minute_ns; + +/// Exhaustive retention policy for compiler-owned artifacts. Sources materialized +/// for tooling, downloaded packages, and installed applications are not artifacts. +pub const Family = enum(u8) { + checked, + canonicalized, + placement, + literals, + pairing, + executable, + test_result, + wasm_host, + glue_plugin, + local_pack, + package_pack, + optimized_pack, + local_discovery, + package_discovery, + program_output, + scratch, + + pub fn age(self: Family) i64 { + return switch (self) { + .scratch => 5 * minute_ns, + .local_pack, .local_discovery, .program_output => day_ns, + .checked, + .canonicalized, + .placement, + .literals, + .pairing, + .executable, + .test_result, + .wasm_host, + .glue_plugin, + .package_pack, + .optimized_pack, + .package_discovery, + => 30 * day_ns, + }; + } + + pub fn isPack(self: Family) bool { + return switch (self) { + .local_pack, .package_pack, .optimized_pack, .local_discovery, .package_discovery, .program_output => true, + else => false, + }; + } + + fn expiresByUse(self: Family) bool { + return self.isPack(); + } +}; + +pub const Identity = struct { + namespace: [32]u8, + family: Family, + group: [32]u8 = @splat(0), + key: [32]u8, + pack_format: u32 = 0, + variant: [32]u8 = @splat(0), + + pub fn indexedKey(self: Identity) catalog.Key { + var hash = std.crypto.hash.sha2.Sha256.init(.{}); + hash.update("roc-artifact-group-v1"); + hash.update(&self.namespace); + hash.update(&self.variant); + hash.update(&.{@backingInt(self.family)}); + hash.update(&self.group); + var result: catalog.Key = undefined; + hash.final(result[0..32]); + @memcpy(result[32..], &self.key); + return result; + } +}; + +pub const Error = catalog.Error || error{ Busy, Missing, Changed, InvalidArtifact, PathTooLong, OutOfMemory }; + +pub fn hashBytes(bytes: []const u8) [32]u8 { + var result: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &result, .{}); + return result; +} + +pub const State = enum(u8) { staging, published, retiring }; + +pub const Metadata = struct { + state: State, + family: Family, + pack_format: u32, + namespace: [32]u8, + generation: u64, + size: u64, + created: i64, + used: i64, + key: catalog.Key, + slot: u64, + variant: [32]u8 = @splat(0), + + pub fn encode(self: Metadata) catalog.Value { + var bytes: catalog.Value = @splat(0); + @memcpy(bytes[0..8], "ROCART01"); + bytes[8] = @backingInt(self.state); + bytes[9] = @backingInt(self.family); + put(u32, &bytes, 12, self.pack_format); + @memcpy(bytes[16..48], &self.namespace); + put(u64, &bytes, 48, self.generation); + put(u64, &bytes, 56, self.size); + put(i64, &bytes, 64, self.created); + put(i64, &bytes, 72, self.used); + @memcpy(bytes[80..144], &self.key); + put(u64, &bytes, 144, self.slot); + @memcpy(bytes[152..184], &self.variant); + return bytes; + } + + pub fn decode(bytes: catalog.Value) Error!Metadata { + if (!std.mem.eql(u8, bytes[0..8], "ROCART01")) return error.CorruptCatalog; + return .{ + .state = std.enums.fromInt(State, bytes[8]) orelse return error.CorruptCatalog, + .family = std.enums.fromInt(Family, bytes[9]) orelse return error.CorruptCatalog, + .pack_format = get(u32, &bytes, 12), + .namespace = bytes[16..48].*, + .generation = get(u64, &bytes, 48), + .size = get(u64, &bytes, 56), + .created = get(i64, &bytes, 64), + .used = get(i64, &bytes, 72), + .key = bytes[80..144].*, + .slot = get(u64, &bytes, 144), + .variant = bytes[152..184].*, + }; + } + + pub fn expired(self: Metadata, now: i64, pack_format: u32) bool { + if (self.family.isPack() and self.pack_format != pack_format) return true; + const stamp = if (self.state == .staging or !self.family.expiresByUse()) self.created else self.used; + const age: i128 = @as(i128, now) - stamp; + return age > if (self.state == .staging) 5 * minute_ns else self.family.age(); + } + + pub fn recency(self: Metadata, slot: u64) catalog.Key { + var key: catalog.Key = @splat(0); + std.mem.writeInt(u64, key[0..8], @as(u64, @bitCast(self.used)) ^ (@as(u64, 1) << 63), .big); + std.mem.writeInt(u64, key[8..16], slot, .big); + return key; + } +}; + +pub const Store = struct { + allocator: Allocator, + io: Io, + root: []u8, + + pub fn init(allocator: Allocator, io: Io, cache_root: []const u8) Allocator.Error!Store { + return .{ .allocator = allocator, .io = io, .root = try std.fs.path.join(allocator, &.{ cache_root, format_directory }) }; + } + + pub fn deinit(self: *Store) void { + self.allocator.free(self.root); + } + + pub fn now(self: *const Store) i64 { + return @intCast(std.math.clamp(Io.Timestamp.now(self.io, .real).nanoseconds, std.math.minInt(i64), std.math.maxInt(i64))); + } + + pub fn openRoot(self: *const Store, create: bool) Error!Dir { + if (create) Dir.cwd().createDirPath(self.io, self.root) catch return error.IoFailure; + return Dir.cwd().openDir(self.io, self.root, .{}) catch |err| switch (err) { + error.FileNotFound => error.Missing, + else => error.IoFailure, + }; + } + + pub fn shardFor(key: catalog.Key) u4 { + return @truncate(key[32]); + } + + pub fn reserve(self: *const Store, identity: Identity, size: u64, timestamp: i64) Error!Staging { + return self.reserveWith(identity, size, timestamp, false); + } + + fn reserveWith(self: *const Store, identity: Identity, size: u64, timestamp: i64, required: bool) Error!Staging { + const key = identity.indexedKey(); + var counters = catalog.Counters{}; + var shard = try Shard.openWith(self, shardFor(key), true, &counters, required); + defer shard.close(); + var tx = shard.index.transaction(); + if (tx.header.user[0] == std.math.maxInt(u64)) return error.CapacityExceeded; + tx.header.user[0] += 1; + const generation = tx.header.user[0]; + var metadata: Metadata = .{ + .state = .staging, + .family = identity.family, + .pack_format = identity.pack_format, + .namespace = identity.namespace, + .variant = identity.variant, + .generation = generation, + .size = size, + .created = timestamp, + .used = timestamp, + .key = key, + .slot = 0, + }; + const slot = try tx.putValue(.owners, storageKey(generation), metadata.encode()); + metadata.slot = slot; + _ = try tx.putValue(.owners, storageKey(generation), metadata.encode()); + var path_buffer: [128]u8 = undefined; + const stem = try slotStem(&path_buffer, slot); + // Directory fanout is fixed by ID bytes, independent of artifact names. + shard.dir.createDirPath(self.io, std.fs.path.dirname(stem).?) catch return error.IoFailure; + var lease_buffer: [140]u8 = undefined; + const lease_path = std.fmt.bufPrint(&lease_buffer, "{s}.lease", .{stem}) catch return error.PathTooLong; + const lease = try leaseFile(shard.dir, self.io, lease_path, false, true); + errdefer lease.close(self.io); + tx.header.user[2] = std.math.add(u64, tx.header.user[2], size) catch return error.CapacityExceeded; + try tx.commit(); + const path = try self.payloadPath(shard.number, slot, ".pending"); + return .{ .store = self, .key = key, .slot = slot, .generation = generation, .lease = lease, .path = path }; + } + + pub fn write(self: *const Store, identity: Identity, bytes: []const u8, timestamp: i64) Error!void { + var staging = try self.reserve(identity, bytes.len, timestamp); + defer staging.deinit(); + const file = Dir.cwd().createFile(self.io, staging.path, .{ .exclusive = true }) catch return error.IoFailure; + { + defer file.close(self.io); + file.writeStreamingAll(self.io, bytes) catch return error.IoFailure; + } + try staging.publish(); + } + + /// Copy into our registered staging name directly. Generic atomic-copy + /// helpers may create unregistered sibling temporary files on interruption. + pub fn importFile(self: *const Store, identity: Identity, source_path: []const u8) Error!void { + const source = Dir.cwd().openFile(self.io, source_path, .{}) catch return error.IoFailure; + defer source.close(self.io); + const info = source.stat(self.io) catch return error.IoFailure; + if (info.kind != .file) return error.InvalidArtifact; + var staging = try self.reserve(identity, info.size, self.now()); + defer staging.deinit(); + { + const destination = Dir.cwd().createFile(self.io, staging.path, .{ .exclusive = true, .permissions = info.permissions }) catch return error.IoFailure; + defer destination.close(self.io); + var buffer: [64 * 1024]u8 = undefined; + var offset: u64 = 0; + while (offset < info.size) { + const chunk = buffer[0..@min(buffer.len, info.size - offset)]; + const count = source.readPositionalAll(self.io, chunk, offset) catch return error.IoFailure; + if (count != chunk.len) return error.InvalidArtifact; + destination.writePositionalAll(self.io, chunk, offset) catch return error.IoFailure; + offset += count; + } + } + try staging.publish(); + } + + pub fn acquire(self: *const Store, identity: Identity, timestamp: i64) Error!?Artifact { + return self.acquireKey(identity.indexedKey(), timestamp); + } + + pub fn acquireKey(self: *const Store, key: catalog.Key, timestamp: i64) Error!?Artifact { + var counters = catalog.Counters{}; + var shard = Shard.open(self, shardFor(key), false, &counters) catch |err| switch (err) { + error.Missing, error.Busy => return null, + else => return err, + }; + defer shard.close(); + var tx = shard.index.transaction(); + const found = (try tx.find(.artifacts, key)) orelse return null; + var metadata = try Metadata.decode(found.value); + if (metadata.state != .published or metadata.family == .scratch) return null; + const path = try self.payloadPath(shard.number, metadata.slot, ".data"); + errdefer self.allocator.free(path); + var stem_buffer: [128]u8 = undefined; + const stem = try slotStem(&stem_buffer, metadata.slot); + var lease_buffer: [140]u8 = undefined; + const relative_lease = std.fmt.bufPrint(&lease_buffer, "{s}.lease", .{stem}) catch return error.PathTooLong; + const lease = leaseFile(shard.dir, self.io, relative_lease, true, false) catch |err| { + if (err == error.Missing or err == error.Busy) { + self.allocator.free(path); + return null; + } + return err; + }; + errdefer lease.close(self.io); + const file = Dir.cwd().openFile(self.io, path, .{}) catch |err| { + return switch (err) { + error.FileNotFound => blk: { + self.allocator.free(path); + lease.close(self.io); + break :blk null; + }, + else => error.IoFailure, + }; + }; + errdefer file.close(self.io); + // Acquire storage before releasing metadata protection. An accepted + // reader owns this open file even if maintenance unlinks its name. + if (metadata.used < timestamp) { + if (!try tx.remove(.recency, metadata.recency(metadata.slot))) return error.CorruptCatalog; + metadata.used = timestamp; + _ = try tx.putValue(.artifacts, key, metadata.encode()); + _ = try tx.putValue(.owners, storageKey(metadata.generation), metadata.encode()); + var value: catalog.Value = @splat(0); + @memcpy(value[0..64], &key); + _ = try tx.putValue(.recency, metadata.recency(metadata.slot), value); + try tx.commit(); + } + const actual_size = file.length(self.io) catch return error.IoFailure; + return .{ .store = self, .file = file, .lease = lease, .path = path, .size = actual_size }; + } + + /// Invalidate a rejected cache offer under the same generation validation + /// used by maintenance. A reader holding its lease keeps accepted storage. + pub fn invalidate(self: *const Store, identity: Identity) Error!void { + const key = identity.indexedKey(); + var counters = catalog.Counters{}; + var shard = try Shard.open(self, shardFor(key), false, &counters); + defer shard.close(); + var tx = shard.index.transaction(); + const entry = (try tx.find(.artifacts, key)) orelse return; + const metadata = try Metadata.decode(entry.value); + _ = try shard.retire(key, metadata.generation); + } + + pub fn read(self: *const Store, allocator: Allocator, identity: Identity, timestamp: i64) Error!?[]u8 { + var artifact = (try self.acquire(identity, timestamp)) orelse return null; + defer artifact.deinit(); + return try artifact.read(allocator); + } + + /// Enumerating offers is foreground work. The key index supplies stable + /// ordering within a shard; the caller sorts the combined keys as required + /// by the existing deterministic pack-selection contract. + pub fn groupKeys(self: *const Store, allocator: Allocator, identity: Identity) Error![]catalog.Key { + var keys: std.ArrayList(catalog.Key) = .empty; + errdefer keys.deinit(allocator); + const group = identity.indexedKey()[0..32].*; + for (0..shard_count) |number| { + var cursor: catalog.Key = @splat(0); + @memcpy(cursor[0..32], &group); + while (true) { + var counters = catalog.Counters{}; + var shard = Shard.open(self, @intCast(number), false, &counters) catch |err| switch (err) { + error.Missing, error.Busy => break, + else => return err, + }; + defer shard.close(); + var tx = shard.index.transaction(); + const entry = (try tx.lowerBound(.artifacts, cursor)) orelse break; + if (!std.mem.eql(u8, entry.key[0..32], &group)) break; + const metadata = try Metadata.decode(entry.value); + if (metadata.state == .published) try keys.append(allocator, entry.key); + cursor = entry.key; + if (!advanceKey(&cursor)) break; + } + } + std.mem.sort(catalog.Key, keys.items, {}, struct { + fn less(_: void, a: catalog.Key, b: catalog.Key) bool { + return std.mem.order(u8, &a, &b) == .lt; + } + }.less); + return keys.toOwnedSlice(allocator); + } + + pub fn payloadPath(self: *const Store, shard: u4, slot: u64, suffix: []const u8) Error![]u8 { + var stem_buffer: [128]u8 = undefined; + const stem = try slotStem(&stem_buffer, slot); + return std.fmt.allocPrint(self.allocator, "{s}/{x:0>2}/{s}{s}", .{ self.root, shard, stem, suffix }); + } +}; + +/// POSIX flock belongs to the open file description and survives inheritance. +/// Windows byte-range locks belong to the locking process; use sharing denial +/// for leases instead, which lasts until the last inherited handle is closed. +fn leaseFile(dir: Dir, io: Io, path: []const u8, shared: bool, create: bool) Error!Io.File { + if (comptime @import("builtin").os.tag == .windows) { + const w = std.os.windows; + var wide: [140]u16 = undefined; + if (path.len > wide.len) return error.PathTooLong; + for (path, 0..) |byte, i| wide[i] = if (byte == '/') '\\' else byte; + var status: w.IO_STATUS_BLOCK = undefined; + var handle: w.HANDLE = undefined; + const result = w.ntdll.NtCreateFile(&handle, .{ .STANDARD = .{ .SYNCHRONIZE = true }, .GENERIC = .{ .READ = true, .WRITE = !shared } }, &.{ .RootDirectory = dir.handle, .ObjectName = @constCast(&w.UNICODE_STRING.init(wide[0..path.len])) }, &status, null, .{ .NORMAL = true }, if (shared) .{ .READ = true } else .{}, if (create) .OPEN_IF else .OPEN, .{ .NON_DIRECTORY_FILE = true, .IO = .SYNCHRONOUS_NONALERT }, null, 0); + return switch (result) { + .SUCCESS => .{ .handle = handle, .flags = .{ .nonblocking = false } }, + .SHARING_VIOLATION => error.Busy, + .OBJECT_NAME_NOT_FOUND, .OBJECT_PATH_NOT_FOUND => error.Missing, + else => error.IoFailure, + }; + } else { + return (if (create) + dir.createFile(io, path, .{ .read = true, .truncate = false, .lock = .exclusive, .lock_nonblocking = true }) + else + dir.openFile(io, path, .{ .mode = if (shared) .read_only else .read_write, .lock = if (shared) .shared else .exclusive, .lock_nonblocking = true })) catch |err| switch (err) { + error.WouldBlock => error.Busy, + error.FileNotFound => error.Missing, + else => error.IoFailure, + }; + } +} + +pub const Shard = struct { + io: Io, + dir: Dir, + lock: Io.File, + index: catalog.Index, + number: u4, + + pub fn open(store: *const Store, number: u4, create: bool, counters: *catalog.Counters) Error!Shard { + return openWith(store, number, create, counters, false); + } + + fn openWith(store: *const Store, number: u4, create: bool, counters: *catalog.Counters, required: bool) Error!Shard { + var root = try store.openRoot(create); + defer root.close(store.io); + var name_buffer: [2]u8 = undefined; + const name = std.fmt.bufPrint(&name_buffer, "{x:0>2}", .{number}) catch return error.PathTooLong; + if (create) root.createDirPath(store.io, name) catch return error.IoFailure; + const dir = root.openDir(store.io, name, .{}) catch |err| switch (err) { + error.FileNotFound => return error.Missing, + else => return error.IoFailure, + }; + errdefer dir.close(store.io); + const lock = dir.createFile(store.io, "coordination", .{ .read = true, .truncate = false, .lock = .exclusive, .lock_nonblocking = !required }) catch |err| switch (err) { + error.WouldBlock => return error.Busy, + else => return error.IoFailure, + }; + errdefer lock.close(store.io); + return .{ .io = store.io, .dir = dir, .lock = lock, .index = try catalog.Index.open(dir, store.io, counters), .number = number }; + } + + pub fn close(self: *Shard) void { + self.index.close(); + self.lock.close(self.io); + self.dir.close(self.io); + } + + /// A retiring slot is only freed after all names have been removed. The + /// lease identity is kept until retirement, while the shard lock excludes + /// anyone from opening that slot's lease for a new generation. + pub fn retire(self: *Shard, key: catalog.Key, generation: u64) Error!bool { + var tx = self.index.transaction(); + const found = (try tx.find(.owners, storageKey(generation))) orelse return false; + var metadata = try Metadata.decode(found.value); + if (metadata.generation != generation or !std.mem.eql(u8, &metadata.key, &key) or metadata.slot != found.id) return error.CorruptCatalog; + var stem_buffer: [128]u8 = undefined; + const stem = try slotStem(&stem_buffer, found.id); + var lease_buffer: [140]u8 = undefined; + const lease_path = std.fmt.bufPrint(&lease_buffer, "{s}.lease", .{stem}) catch return error.PathTooLong; + const lease = try leaseFile(self.dir, self.io, lease_path, false, false); + defer lease.close(self.io); + if (metadata.state != .retiring) { + const counter: usize = if (metadata.state == .staging) 2 else 1; + if (tx.header.user[counter] < metadata.size) return error.CorruptCatalog; + tx.header.user[counter] -= metadata.size; + tx.header.user[3] = std.math.add(u64, tx.header.user[3], metadata.size) catch return error.CapacityExceeded; + if (metadata.state == .published and !try tx.remove(.recency, metadata.recency(found.id))) return error.CorruptCatalog; + if (try tx.find(.artifacts, key)) |current| { + if ((try Metadata.decode(current.value)).generation == generation) _ = try tx.remove(.artifacts, key); + } + metadata.state = .retiring; + _ = try tx.putValue(.owners, storageKey(generation), metadata.encode()); + try tx.commit(); + } + if (metadata.family == .scratch and !try drainScratch(self, stem, metadata.generation)) return false; + for ([_][]const u8{ ".pending", ".data", ".pending.txt", ".cursor" }) |suffix| { + var buffer: [140]u8 = undefined; + const path = std.fmt.bufPrint(&buffer, "{s}{s}", .{ stem, suffix }) catch return error.PathTooLong; + self.dir.deleteFile(self.io, path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return error.IoFailure, + }; + } + var finish = self.index.transaction(); + if (finish.header.user[3] < metadata.size) return error.CorruptCatalog; + finish.header.user[3] -= metadata.size; + if (!try finish.remove(.owners, storageKey(generation))) return error.CorruptCatalog; + try finish.commit(); + // Keep the lease filename stable across slot reuse. Empty directories + // and this small control record never participate in size eviction. + return true; + } +}; + +pub const Staging = struct { + store: *const Store, + key: catalog.Key, + slot: u64, + generation: u64, + lease: Io.File, + path: []u8, + + /// Closing a failed staging operation leaves a registered, abandoned slot. + /// Maintenance can reclaim it after the staging window; no finalizer must + /// run for its ownership to become recoverable. + pub fn deinit(self: *Staging) void { + self.lease.close(self.store.io); + self.store.allocator.free(self.path); + } + + pub fn publish(self: *Staging) Error!void { + var counters = catalog.Counters{}; + var shard = try Shard.open(self.store, Store.shardFor(self.key), false, &counters); + defer shard.close(); + var tx = shard.index.transaction(); + const found = (try tx.find(.owners, storageKey(self.generation))) orelse return error.Changed; + var metadata = try Metadata.decode(found.value); + if (found.id != self.slot or metadata.generation != self.generation or metadata.state != .staging) return error.Changed; + const stat = Dir.cwd().statFile(self.store.io, self.path, .{}) catch return error.IoFailure; + if (stat.kind != .file or stat.size != metadata.size) return error.InvalidArtifact; + const final_path = try self.store.payloadPath(shard.number, self.slot, ".data"); + defer self.store.allocator.free(final_path); + Dir.cwd().rename(self.path, Dir.cwd(), final_path, self.store.io) catch return error.IoFailure; + if (try tx.find(.artifacts, self.key)) |previous| { + var old = try Metadata.decode(previous.value); + if (old.state != .published or tx.header.user[1] < old.size) return error.CorruptCatalog; + if (!try tx.remove(.recency, old.recency(old.slot))) return error.CorruptCatalog; + tx.header.user[1] -= old.size; + tx.header.user[3] = std.math.add(u64, tx.header.user[3], old.size) catch return error.CapacityExceeded; + old.state = .retiring; + _ = try tx.putValue(.owners, storageKey(old.generation), old.encode()); + } + metadata.state = .published; + if (tx.header.user[2] < metadata.size) return error.CorruptCatalog; + tx.header.user[2] -= metadata.size; + tx.header.user[1] = std.math.add(u64, tx.header.user[1], metadata.size) catch return error.CapacityExceeded; + _ = try tx.putValue(.artifacts, self.key, metadata.encode()); + _ = try tx.putValue(.owners, storageKey(self.generation), metadata.encode()); + var value: catalog.Value = @splat(0); + @memcpy(value[0..64], &self.key); + _ = try tx.putValue(.recency, metadata.recency(self.slot), value); + try tx.commit(); + } +}; + +pub const Artifact = struct { + store: *const Store, + file: Io.File, + lease: Io.File, + path: []u8, + size: u64, + + pub fn deinit(self: *Artifact) void { + self.file.close(self.store.io); + self.lease.close(self.store.io); + self.store.allocator.free(self.path); + } + + pub fn read(self: *const Artifact, allocator: Allocator) Error![]u8 { + const size = std.math.cast(usize, self.size) orelse return error.OutOfMemory; + const bytes = try allocator.alloc(u8, size); + errdefer allocator.free(bytes); + const len = self.file.readPositionalAll(self.store.io, bytes, 0) catch return error.IoFailure; + if (len != bytes.len) return error.InvalidArtifact; + return bytes; + } +}; + +/// One CLI-owned scratch directory. The lease, not the timestamp, establishes +/// whether its contents may still be used. All files in an abandoned directory +/// are eligible, so bounded destructive traversal can always consume its first +/// entry without persisting an opaque iterator or rescanning a retained prefix. +pub const Scratch = struct { + store: *Store, + staging: Staging, + keep_until_expired: bool = false, + + pub fn create(allocator: Allocator, io: Io, cache_root: []const u8, namespace: [32]u8) Error!Scratch { + const store = try allocator.create(Store); + errdefer allocator.destroy(store); + store.* = try Store.init(allocator, io, cache_root); + errdefer store.deinit(); + var nonce: [32]u8 = undefined; + io.random(&nonce); + var staging = try store.reserveWith(.{ .namespace = namespace, .family = .scratch, .key = nonce }, 0, store.now(), true); + errdefer staging.deinit(); + // Native application children use this same lease. Merely closing the + // parent's copy never unlocks a file description still held by a child. + if (comptime @import("builtin").os.tag == .windows) { + const w = std.os.windows; + if (w.ntdll.NtSetInformationObject(staging.lease.handle, .HandleFlag, &w.OBJECT.HANDLE_FLAG{ .INHERIT = true }, @sizeOf(w.OBJECT.HANDLE_FLAG)) != .SUCCESS) return error.IoFailure; + } else { + if (std.posix.errno(std.posix.system.fcntl(staging.lease.handle, std.posix.F.SETFD, @as(u32, 0))) != .SUCCESS) return error.IoFailure; + } + Dir.cwd().createDir(io, staging.path, .default_dir) catch return error.IoFailure; + return .{ .store = store, .staging = staging }; + } + + pub fn path(self: *const Scratch) []const u8 { + return self.staging.path; + } + + pub fn deinit(self: *Scratch) void { + const allocator = self.store.allocator; + const key = self.staging.key; + const generation = self.staging.generation; + self.staging.deinit(); + defer { + self.store.deinit(); + allocator.destroy(self.store); + } + if (self.keep_until_expired) return; + // Normal completion makes the entry immediately eligible. A crash + // leaves it eligible after the abandoned-work window instead. + var counters = catalog.Counters{}; + if (Shard.open(self.store, Store.shardFor(key), false, &counters)) |opened| { + var shard = opened; + defer shard.close(); + var tx = shard.index.transaction(); + if (tx.find(.owners, storageKey(generation)) catch null) |entry| { + if (Metadata.decode(entry.value)) |value| { + var metadata = value; + metadata.state = .retiring; + _ = tx.putValue(.owners, storageKey(generation), metadata.encode()) catch 0; + tx.commit() catch {}; + } else |_| {} + } + } else |_| {} + } +}; + +/// One filesystem leaf or empty directory per call. The cursor has a fixed +/// byte limit, checksum, and generation. Invalid cursors restart at the owned +/// root; they never provide deletion authority. +fn drainScratch(shard: *Shard, stem: []const u8, generation: u64) Error!bool { + var path_buffer: [140]u8 = undefined; + const scratch_path = std.fmt.bufPrint(&path_buffer, "{s}.pending", .{stem}) catch return error.PathTooLong; + var root = shard.dir.openDir(shard.io, scratch_path, .{ .follow_symlinks = false }) catch |err| switch (err) { + error.FileNotFound => return true, + else => return error.IoFailure, + }; + var root_closed = false; + defer if (!root_closed) root.close(shard.io); + var cursor_name_buffer: [140]u8 = undefined; + const cursor_name = std.fmt.bufPrint(&cursor_name_buffer, "{s}.cursor", .{stem}) catch return error.PathTooLong; + var cursor: [std.fs.max_path_bytes]u8 = @splat(0); + const capacity = cursor.len - 32; + var relative: []const u8 = ""; + if (shard.dir.openFile(shard.io, cursor_name, .{})) |file| { + defer file.close(shard.io); + const len = file.readPositionalAll(shard.io, &cursor, 0) catch return error.IoFailure; + if (len == cursor.len and get(u64, &cursor, 0) == std.hash.Wyhash.hash(0, cursor[8..]) and get(u64, &cursor, 8) == generation) { + const path_len = get(u64, &cursor, 16); + if (path_len <= capacity) relative = cursor[32..][0..@intCast(path_len)]; + } + } else |err| switch (err) { + error.FileNotFound => {}, + else => return error.IoFailure, + } + // Validate every component and prohibit following any symlink on the + // route from the registered scratch root to the current directory. + var directory = root; + var owns_directory = false; + defer if (owns_directory) directory.close(shard.io); + var parts = std.mem.splitScalar(u8, relative, '/'); + var valid_relative = true; + while (parts.next()) |part| { + if (part.len == 0) { + if (relative.len != 0) valid_relative = false; + break; + } + if (std.mem.eql(u8, part, ".") or std.mem.eql(u8, part, "..") or std.mem.findScalar(u8, part, '\\') != null or std.mem.findScalar(u8, part, 0) != null or std.mem.findScalar(u8, part, ':') != null) { + valid_relative = false; + break; + } + const child = directory.openDir(shard.io, part, .{ .follow_symlinks = false }) catch { + valid_relative = false; + break; + }; + if (owns_directory) directory.close(shard.io); + directory = child; + owns_directory = true; + } + if (!valid_relative) { + relative = ""; + } else { + var iterable = directory.openDir(shard.io, ".", .{ .iterate = true, .follow_symlinks = false }) catch return error.IoFailure; + var iterable_closed = false; + defer if (!iterable_closed) iterable.close(shard.io); + var iterator = iterable.iterate(); + if (iterator.next(shard.io) catch return error.IoFailure) |entry| { + if (entry.kind == .directory) { + var next: [std.fs.max_path_bytes]u8 = undefined; + const name = std.fmt.bufPrint(&next, "{s}{s}{s}", .{ relative, if (relative.len == 0) "" else "/", entry.name }) catch return error.PathTooLong; + if (name.len > capacity) return error.PathTooLong; + @memcpy(cursor[32..][0..name.len], name); + relative = cursor[32..][0..name.len]; + } else { + directory.deleteFile(shard.io, entry.name) catch |err| switch (err) { + error.FileNotFound => {}, + else => return error.IoFailure, + }; + } + } else if (relative.len == 0) { + iterable.close(shard.io); + iterable_closed = true; + root.close(shard.io); + root_closed = true; + shard.dir.deleteDir(shard.io, scratch_path) catch |err| switch (err) { + error.FileNotFound => {}, + else => return error.IoFailure, + }; + shard.dir.deleteFile(shard.io, cursor_name) catch {}; + return true; + } else { + iterable.close(shard.io); + iterable_closed = true; + if (owns_directory) { + directory.close(shard.io); + owns_directory = false; + } + root.deleteDir(shard.io, relative) catch |err| switch (err) { + error.FileNotFound => {}, + else => return error.IoFailure, + }; + relative = if (std.mem.lastIndexOfScalar(u8, relative, '/')) |index| relative[0..index] else ""; + } + } + put(u64, &cursor, 8, generation); + put(u64, &cursor, 16, relative.len); + if (relative.len != 0) std.mem.copyForwards(u8, cursor[32..][0..relative.len], relative); + @memset(cursor[32 + relative.len ..], 0); + put(u64, &cursor, 0, std.hash.Wyhash.hash(0, cursor[8..])); + // The ownership lease serializes this disposable cursor. A torn write + // simply restarts at the root; the already removed leaves stay removed. + shard.dir.writeFile(shard.io, .{ .sub_path = cursor_name, .data = &cursor }) catch return error.IoFailure; + return false; +} + +pub fn slotStem(buffer: *[128]u8, slot: u64) Error![]const u8 { + var bytes: [8]u8 = undefined; + std.mem.writeInt(u64, &bytes, slot, .big); + return std.fmt.bufPrint(buffer, "data/{x:0>2}/{x:0>2}/{x:0>2}/{x:0>2}/{x:0>2}/{x:0>2}/{x:0>2}/{x:0>2}", .{ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + }) catch return error.PathTooLong; +} + +pub fn storageKey(generation: u64) catalog.Key { + var key: catalog.Key = @splat(0); + std.mem.writeInt(u64, key[0..8], generation, .big); + return key; +} + +pub fn advanceKey(key: *catalog.Key) bool { + var index: usize = key.len; + while (index != 0) { + index -= 1; + key[index] +%= 1; + if (key[index] != 0) return true; + } + return false; +} + +test "publication owns staging and an accepted reader survives eviction" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var store = try Store.init(allocator, io, root); + defer store.deinit(); + const identity: Identity = .{ .namespace = @splat(1), .family = .checked, .key = @splat(2) }; + var stage = try store.reserve(identity, 5, 1); + try Dir.cwd().writeFile(io, .{ .sub_path = stage.path, .data = "hello" }); + try std.testing.expect((try store.acquire(identity, 2)) == null); + { + var counters = catalog.Counters{}; + var shard = try Shard.open(&store, Store.shardFor(stage.key), false, &counters); + defer shard.close(); + try std.testing.expectError(error.Busy, shard.retire(stage.key, stage.generation)); + } + try stage.publish(); + const generation = stage.generation; + stage.deinit(); + var reader = (try store.acquire(identity, 3)).?; + { + var counters = catalog.Counters{}; + var shard = try Shard.open(&store, Store.shardFor(identity.indexedKey()), false, &counters); + defer shard.close(); + try std.testing.expectError(error.Busy, shard.retire(identity.indexedKey(), generation)); + } + const bytes = try reader.read(allocator); + defer allocator.free(bytes); + try std.testing.expectEqualStrings("hello", bytes); + reader.deinit(); + var counters = catalog.Counters{}; + var shard = try Shard.open(&store, Store.shardFor(identity.indexedKey()), false, &counters); + try std.testing.expect(try shard.retire(identity.indexedKey(), generation)); + shard.close(); + try std.testing.expect((try store.acquire(identity, 4)) == null); +} + +test "shard ownership is nonblocking and lock-file existence does not imply ownership" { + const io = std.testing.io; + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var store = try Store.init(allocator, io, root); + defer store.deinit(); + var counters = catalog.Counters{}; + var first = try Shard.open(&store, 0, true, &counters); + try std.testing.expectError(error.Busy, Shard.open(&store, 0, false, &counters)); + first.close(); + var second = try Shard.open(&store, 0, false, &counters); + second.close(); +} + +test "interleaved publication and retirement preserve generations and acquired storage" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var store = try Store.init(allocator, io, root); + defer store.deinit(); + const id: Identity = .{ .namespace = @splat(1), .family = .checked, .key = @splat(2) }; + var first = try store.reserve(id, 3, 1); + defer first.deinit(); + var second = try store.reserve(id, 3, 2); + var second_open = true; + defer if (second_open) second.deinit(); + try std.testing.expect(!std.mem.eql(u8, first.path, second.path)); + try Dir.cwd().writeFile(io, .{ .sub_path = first.path, .data = "o" }); + try std.testing.expectError(error.InvalidArtifact, first.publish()); + try Dir.cwd().writeFile(io, .{ .sub_path = second.path, .data = "new" }); + try second.publish(); + // Publication ownership is exclusive until the producer closes it. + second.deinit(); + second_open = false; + var accepted = (try store.acquire(id, 2)).?; + defer accepted.deinit(); + try Dir.cwd().writeFile(io, .{ .sub_path = first.path, .data = "old" }); + try first.publish(); + const bytes = try accepted.read(allocator); + defer allocator.free(bytes); + try std.testing.expectEqualStrings("new", bytes); + var counters = catalog.Counters{}; + var shard = try Shard.open(&store, Store.shardFor(first.key), false, &counters); + defer shard.close(); + try std.testing.expectError(error.Busy, shard.retire(second.key, second.generation)); + var tx = shard.index.transaction(); + try std.testing.expectEqual(@as(u64, 3), tx.header.user[1]); + try std.testing.expectEqual(@as(u64, 3), tx.header.user[3]); + const current = try Metadata.decode((try tx.find(.artifacts, first.key)).?.value); + try std.testing.expectEqual(first.generation, current.generation); +} + +test "file import registers exact bytes and permissions before publication" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + try tmp.dir.writeFile(io, .{ .sub_path = "executable", .data = "executable bytes" }); + const source = try tmp.dir.realPathFileAlloc(io, "executable", allocator); + defer allocator.free(source); + var store = try Store.init(allocator, io, root); + defer store.deinit(); + const id: Identity = .{ .namespace = @splat(1), .family = .executable, .key = @splat(2) }; + try store.importFile(id, source); + const bytes = (try store.read(allocator, id, store.now())).?; + defer allocator.free(bytes); + try std.testing.expectEqualStrings("executable bytes", bytes); +} + +test "scratch ownership survives the parent closing its inherited lease" { + if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var store = try Store.init(allocator, io, root); + defer store.deinit(); + var scratch = try Scratch.create(allocator, io, root, @splat(1)); + var scratch_open = true; + defer if (scratch_open) scratch.deinit(); + const key = scratch.staging.key; + const generation = scratch.staging.generation; + var child = try std.process.spawn(io, .{ + .argv = &.{ "/bin/sh", "-c", "printf r; read line" }, + .inherit_files = &.{scratch.staging.lease}, + .stdin = .pipe, + .stdout = .pipe, + }); + defer child.kill(io); + var ready: [1]u8 = undefined; + try std.testing.expectEqual(@as(usize, 1), try child.stdout.?.readStreaming(io, &.{&ready})); + scratch.deinit(); + scratch_open = false; + var counters = catalog.Counters{}; + { + var shard = try Shard.open(&store, Store.shardFor(key), false, &counters); + defer shard.close(); + try std.testing.expectError(error.Busy, shard.retire(key, generation)); + } + try child.stdin.?.writeStreamingAll(io, "done\n"); + try std.testing.expectEqual(std.process.Child.Term{ .exited = 0 }, try child.wait(io)); + var shard = try Shard.open(&store, Store.shardFor(key), false, &counters); + defer shard.close(); + try std.testing.expect(try shard.retire(key, generation)); +} diff --git a/src/compile/cache_cleanup.zig b/src/compile/cache_cleanup.zig index 512c62bc112..59d05b532cd 100644 --- a/src/compile/cache_cleanup.zig +++ b/src/compile/cache_cleanup.zig @@ -1,947 +1,108 @@ -//! Cache cleanup utilities for managing temporary and persistent cache files. -//! -//! This module provides background cleanup functionality that: -//! - Removes per-invocation scratch directories older than 5 minutes -//! - Removes persistent cache files (mod/, exe/, test/, and wasm-host/) older than 30 days -//! - Removes object-cache packs by last use, and every pack namespace of a -//! format other than the current one -//! - Evicts the least recently used persistent cache files while the cache -//! exceeds its configured size -//! -//! The cleanup runs on a single background thread that is fire-and-forget: -//! it exits automatically when done, and if the main process exits first, -//! the OS will terminate the cleanup thread along with the process. -//! -//! This is strictly a native `roc` CLI operation—it is never reached on -//! wasm (`compile/mod.zig` substitutes a stub there). It therefore talks to -//! the real OS directly through `std.Io.Dir` rather than going through the -//! `CoreCtx` filesystem abstraction, whose only purpose is injecting a -//! non-OS implementation (the playground's virtual FS or test mocks)—none -//! of which can apply here. The cache root path is resolved once by the -//! caller (see `startBackgroundCleanup`) and copied in by value, so this code -//! depends on neither `CoreCtx` nor a caller-provided allocator. The walk uses -//! open directory handles and operates on entry basenames, so it allocates -//! nothing and is unaffected by long total path lengths. +//! Admission of bounded cache-maintenance tasks at foreground safe points. +//! No detached thread or helper process owns progress. Startup completes one +//! overdue allowance synchronously; coordinator waits can admit optional units. +//! Serial coordination and linking never grant optional capacity. A filesystem +//! operation can block: operation bounds are not wall-clock deadlines. const std = @import("std"); -const threading = @import("threading.zig"); -const cache_config = @import("cache_config.zig"); -const scratch_dir_name = cache_config.scratch_dir_name; - -const Dir = std.Io.Dir; -const Io = std.Io; - -const is_freestanding = threading.is_freestanding; - -/// Cleanup configuration constants -pub const Config = struct { - /// Maximum age for scratch directories (5 minutes in nanoseconds) - pub const TEMP_MAX_AGE_NS: i96 = 5 * 60 * std.time.ns_per_s; - - /// Maximum age for persistent cache files (30 days in nanoseconds) - pub const PERSISTENT_MAX_AGE_NS: i96 = 30 * 24 * 60 * 60 * std.time.ns_per_s; - - /// Maximum age for object-cache packs of modules reached by path - /// (`objects-v//local/`), which every edit rewrites: one day. - pub const LOCAL_PACK_MAX_AGE_NS: i96 = 24 * 60 * 60 * std.time.ns_per_s; -}; - -/// What the sweep needs to know about the compiler that started it. -pub const Limits = struct { - /// Format version of the object-cache packs this compiler reads and - /// writes. Every other pack namespace holds packs no compiler sharing this - /// cache version can use. - object_pack_format_version: u32, - /// Upper bound on the total size of the persistent cache files, in bytes - /// (`CacheConfig.max_size_mb`). - max_size_bytes: u64, -}; - -/// Statistics from a cleanup operation -pub const CleanupStats = struct { - temp_dirs_deleted: u32 = 0, - temp_files_deleted: u32 = 0, - cache_files_deleted: u32 = 0, - stale_namespaces_deleted: u32 = 0, - evicted_files_deleted: u32 = 0, - empty_dirs_deleted: u32 = 0, - errors: u32 = 0, -}; - -/// Background cleanup thread handle. -/// -/// This is a fire-and-forget thread - you don't need to join it. -/// If the main process exits before cleanup completes, the OS will -/// automatically terminate this thread along with the process. -pub const CleanupThread = if (!is_freestanding) struct { - thread: std.Thread, - - /// Wait for the cleanup thread to complete. - /// Note: This is optional - if you don't call join(), the thread will - /// be automatically terminated when the main process exits. - pub fn join(self: *CleanupThread) void { - self.thread.join(); - } -} else struct {}; - -/// The cache root path, owned by value so the detached thread depends on -/// neither the caller's allocator nor the caller's `CoreCtx`. Filled by -/// copying the resolved path slice in `startBackgroundCleanup`. -const CacheBase = struct { - buf: [std.fs.max_path_bytes]u8 = undefined, - len: usize = 0, - - fn path(self: *const CacheBase) []const u8 { - return self.buf[0..self.len]; - } -}; - -/// Start background cleanup on a separate thread. -/// -/// `cache_base` is the cache root (e.g. `~/.cache/roc`); resolve it with -/// `CacheConfig.getEffectiveCacheDir` so this matches where the cache writer -/// stores artifacts and where scratch directories are created. The slice is -/// copied by value into the spawned thread, so it only needs to be valid until -/// this function returns. -/// -/// The thread is fire-and-forget: if the main process exits before cleanup -/// completes, the OS terminates it. You do not need to join the returned handle. -pub fn startBackgroundCleanup(cache_base: []const u8, std_io: Io, limits: Limits) std.Thread.SpawnError!?CleanupThread { - if (comptime is_freestanding) return null; - - var base = CacheBase{}; - // A path longer than the platform path limit can't be opened anyway. - if (cache_base.len > base.buf.len) return null; - @memcpy(base.buf[0..cache_base.len], cache_base); - base.len = cache_base.len; - - const thread = try std.Thread.spawn(.{}, runCleanup, .{ base, std_io, limits }); - return CleanupThread{ .thread = thread }; -} - -/// Run the full cleanup process (called on background thread). -fn runCleanup(base: CacheBase, std_io: Io, limits: Limits) void { - sweep(std_io, base.path(), limits, nowNs(std_io), null); -} - -/// Every cleanup pass over the cache rooted at `cache_base`, as of `now_ns`. -pub fn sweep(std_io: Io, cache_base: []const u8, limits: Limits, now_ns: i128, maybe_stats: ?*CleanupStats) void { - // Remove old flat files without treating current compiler namespaces as legacy. - cleanupLegacyPersistentCache(std_io, cache_base, maybe_stats); - - // Clean up scratch directories (5 minute threshold) - cleanupScratchDirs(std_io, cache_base, now_ns, maybe_stats); - - // Clean up persistent cache (30 day threshold) - cleanupPersistentCache(std_io, cache_base, now_ns, maybe_stats); - - // Clean up object-cache packs (one-day and 30-day thresholds by origin) - cleanupObjectPacks(std_io, cache_base, limits.object_pack_format_version, now_ns, maybe_stats); - - // Evict least recently used files past the size bound - enforceSizeLimit(std_io, cache_base, limits, maybe_stats); -} - -/// Current wall-clock time in nanoseconds since the Unix epoch. -pub fn nowNs(std_io: Io) i128 { - return std.Io.Timestamp.now(std_io, .real).nanoseconds; -} - -/// Modification time of `name` (relative to `dir`) in nanoseconds, or null. -fn mtimeNs(dir: Dir, std_io: Io, name: []const u8) ?i128 { - const info = dir.statFile(std_io, name, .{}) catch return null; - const ns: i128 = @intCast(info.mtime.nanoseconds); - return ns; -} - -/// Clean up per-invocation scratch directories older than 5 minutes. -/// -/// Layout: `//tmp/` plus sibling -/// `.txt` coordination files. We act on the entries of each version's -/// scratch directory; we never recurse into the random dirs themselves. -fn cleanupScratchDirs(std_io: Io, cache_base: []const u8, now_ns: i128, maybe_stats: ?*CleanupStats) void { - var base_dir = Dir.cwd().openDir(std_io, cache_base, .{ .iterate = true }) catch return; - defer base_dir.close(std_io); - - var version_it = base_dir.iterate(); - while (true) { - const version_entry = (version_it.next(std_io) catch break) orelse break; - if (version_entry.kind != .directory) continue; - - var version_dir = base_dir.openDir(std_io, version_entry.name, .{}) catch continue; - defer version_dir.close(std_io); - var scratch_dir = version_dir.openDir(std_io, scratch_dir_name, .{ .iterate = true }) catch continue; - defer scratch_dir.close(std_io); - - var entry_it = scratch_dir.iterate(); - while (true) { - const entry = (entry_it.next(std_io) catch break) orelse break; - - if (entry.kind == .directory) { - const mtime = mtimeNs(scratch_dir, std_io, entry.name) orelse continue; - if (now_ns - mtime <= Config.TEMP_MAX_AGE_NS) continue; - - scratch_dir.deleteTree(std_io, entry.name) catch { - if (maybe_stats) |stats| stats.errors += 1; - continue; - }; - if (maybe_stats) |stats| stats.temp_dirs_deleted += 1; - - // Also try to delete the coordination file (`.txt`). - var txt_buf: [std.fs.max_path_bytes]u8 = undefined; - const txt_path = std.fmt.bufPrint(&txt_buf, "{s}.txt", .{entry.name}) catch continue; - scratch_dir.deleteFile(std_io, txt_path) catch {}; - if (maybe_stats) |stats| stats.temp_files_deleted += 1; - } else if (entry.kind == .file) { - // Stale `.txt` coordination file with no surviving directory. - if (!std.mem.endsWith(u8, entry.name, ".txt")) continue; - const mtime = mtimeNs(scratch_dir, std_io, entry.name) orelse continue; - if (now_ns - mtime <= Config.TEMP_MAX_AGE_NS) continue; - - scratch_dir.deleteFile(std_io, entry.name) catch { - if (maybe_stats) |stats| stats.errors += 1; - continue; - }; - if (maybe_stats) |stats| stats.temp_files_deleted += 1; - } - } - } - - // NOTE: We intentionally do NOT delete empty scratch directories. It's - // harmless and avoids race conditions with concurrent processes. -} - -/// Clean up persistent cache files older than 30 days. -/// -/// Layout: `//{mod,exe,test,wasm-host}/...`. -fn cleanupPersistentCache(std_io: Io, cache_base: []const u8, now_ns: i128, maybe_stats: ?*CleanupStats) void { - var base_dir = Dir.cwd().openDir(std_io, cache_base, .{ .iterate = true }) catch return; - defer base_dir.close(std_io); - - var version_it = base_dir.iterate(); - while (true) { - const version_entry = (version_it.next(std_io) catch break) orelse break; - if (version_entry.kind != .directory) continue; - - var version_dir = base_dir.openDir(std_io, version_entry.name, .{ .iterate = true }) catch continue; - defer version_dir.close(std_io); - - for (persistent_families) |subdir_info| { - var subdir = version_dir.openDir(std_io, subdir_info.name, .{ .iterate = true }) catch continue; - defer subdir.close(std_io); - cleanupCacheSubdir(std_io, subdir, now_ns, maybe_stats, subdir_info.nested_directory_depth); - } - } - - // NOTE: We intentionally do NOT delete empty version directories. Empty - // directories are harmless and deleting them can cause race conditions. -} - -/// The persistent cache families under each version directory, with the -/// number of directory levels between a family's root and its files. -const persistent_families = [_]struct { name: []const u8, nested_directory_depth: u8 }{ - .{ .name = "mod", .nested_directory_depth = 1 }, - .{ .name = "mod-latest", .nested_directory_depth = 1 }, - .{ .name = "literals", .nested_directory_depth = 1 }, - .{ .name = "pair", .nested_directory_depth = 1 }, - .{ .name = "exe", .nested_directory_depth = 1 }, - .{ .name = "test", .nested_directory_depth = 1 }, - .{ .name = "wasm-host", .nested_directory_depth = 1 }, - .{ .name = "glue-plugin", .nested_directory_depth = 2 }, -}; - -/// Directory levels between an object-pack namespace and its packs: -/// `///`. -const object_pack_nested_directory_depth = 3; - -/// Writes the namespace of pack format `format_version` into `buf`. -fn objectPackNamespaceName(buf: *[cache_config.object_pack_namespace_prefix.len + 10]u8, format_version: u32) []const u8 { - return std.fmt.bufPrint(buf, cache_config.object_pack_namespace_prefix ++ "{d}", .{format_version}) catch - unreachable; // buf holds the prefix plus 10 digits, the most a u32 prints -} - -/// Clean up object-cache packs. -/// -/// Layout: `//objects-v/// -/// /.rpk`, with the packs optimized builds -/// file for their programs in `/optimized/.rpk` -/// and recorded program outputs in `/programs/.rpo`. -/// A namespace of any format other than `format_version` is deleted -/// outright: its packs can never be read again. In the current namespace, -/// packs under `pkg/` come from URL packages, are written once per package -/// version, and keep the 30-day window, as do optimized builds' packs under -/// `optimized/`; packs under `local/` are rewritten by every edit of the -/// module and keep a one-day window, so superseded packs do not pile up. A pack ages from -/// its last use, not its write: an in-use pack whose bytes never change must -/// not be deleted and rebuilt once per window. -fn cleanupObjectPacks(std_io: Io, cache_base: []const u8, format_version: u32, now_ns: i128, maybe_stats: ?*CleanupStats) void { - var base_dir = Dir.cwd().openDir(std_io, cache_base, .{ .iterate = true }) catch return; - defer base_dir.close(std_io); - var namespace_buf: [cache_config.object_pack_namespace_prefix.len + 10]u8 = undefined; - const current_namespace = objectPackNamespaceName(&namespace_buf, format_version); - - var version_it = base_dir.iterate(); - while (true) { - const version_entry = (version_it.next(std_io) catch break) orelse break; - if (version_entry.kind != .directory) continue; - var version_dir = base_dir.openDir(std_io, version_entry.name, .{ .iterate = true }) catch continue; - defer version_dir.close(std_io); - - var namespace_it = version_dir.iterate(); - while (true) { - const namespace_entry = (namespace_it.next(std_io) catch break) orelse break; - if (namespace_entry.kind != .directory) continue; - if (!std.mem.startsWith(u8, namespace_entry.name, cache_config.object_pack_namespace_prefix)) continue; - if (!std.mem.eql(u8, namespace_entry.name, current_namespace)) { - version_dir.deleteTree(std_io, namespace_entry.name) catch { - if (maybe_stats) |stats| stats.errors += 1; - continue; - }; - if (maybe_stats) |stats| stats.stale_namespaces_deleted += 1; - continue; - } - var namespace_dir = version_dir.openDir(std_io, namespace_entry.name, .{ .iterate = true }) catch continue; - defer namespace_dir.close(std_io); - cleanupPackNamespace(std_io, namespace_dir, now_ns, maybe_stats); - } - } -} - -fn cleanupPackNamespace(std_io: Io, namespace_dir: Dir, now_ns: i128, maybe_stats: ?*CleanupStats) void { - var mode_it = namespace_dir.iterate(); - while (true) { - const mode_entry = (mode_it.next(std_io) catch break) orelse break; - if (mode_entry.kind != .directory) continue; - var mode_dir = namespace_dir.openDir(std_io, mode_entry.name, .{ .iterate = true }) catch continue; - defer mode_dir.close(std_io); - const origins = [_]struct { name: []const u8, max_age_ns: i96 }{ - .{ .name = "local", .max_age_ns = Config.LOCAL_PACK_MAX_AGE_NS }, - .{ .name = "pkg", .max_age_ns = Config.PERSISTENT_MAX_AGE_NS }, - }; - for (origins) |origin| { - var origin_dir = mode_dir.openDir(std_io, origin.name, .{ .iterate = true }) catch continue; - defer origin_dir.close(std_io); - var identity_it = origin_dir.iterate(); - while (true) { - const identity_entry = (identity_it.next(std_io) catch break) orelse break; - if (identity_entry.kind != .directory) continue; - var identity_dir = origin_dir.openDir(std_io, identity_entry.name, .{ .iterate = true }) catch continue; - defer identity_dir.close(std_io); - ageFilesIn(std_io, identity_dir, origin.max_age_ns, now_ns, maybe_stats); - } - } - // The pack each optimized build of a program replaces keeps the - // window of a package's pack. - if (mode_dir.openDir(std_io, "optimized", .{ .iterate = true })) |optimized_dir| { - var dir = optimized_dir; - defer dir.close(std_io); - ageFilesIn(std_io, dir, Config.PERSISTENT_MAX_AGE_NS, now_ns, maybe_stats); - } else |_| {} - // A recorded program output names one exact program, which an edit - // replaces, so it keeps the window of a local pack. - var programs_dir = mode_dir.openDir(std_io, "programs", .{ .iterate = true }) catch continue; - defer programs_dir.close(std_io); - ageFilesIn(std_io, programs_dir, Config.LOCAL_PACK_MAX_AGE_NS, now_ns, maybe_stats); - } -} - -/// Delete the files of `dir` last used more than `max_age_ns` before `now_ns`. -fn ageFilesIn(std_io: Io, dir: Dir, max_age_ns: i96, now_ns: i128, maybe_stats: ?*CleanupStats) void { - var file_it = dir.iterate(); - while (true) { - const file_entry = (file_it.next(std_io) catch break) orelse break; - if (file_entry.kind != .file) continue; - const last_use = lastUseNs(dir, std_io, file_entry.name) orelse continue; - if (now_ns - last_use <= max_age_ns) continue; - dir.deleteFile(std_io, file_entry.name) catch { - if (maybe_stats) |stats| stats.errors += 1; - continue; +const artifacts = @import("artifact_store.zig"); +const maintenance = @import("cache_maintenance.zig"); + +pub const Limits = maintenance.Limits; +pub const CleanupStats = maintenance.Stats; + +/// Explicit demand, including the coordinator's own CPU slot. `tasks` counts +/// queued plus executing tasks, never idle workers or a sampled queue length. +pub const Demand = union(enum) { + serial, + linking, + waiting: struct { budget: usize, tasks: usize }, + + pub fn hasCapacity(self: Demand) bool { + return switch (self) { + .serial, .linking => false, + .waiting => |d| d.budget > 1 and d.tasks < d.budget, }; - if (maybe_stats) |stats| stats.cache_files_deleted += 1; - } -} - -/// One persistent cache file the size bound may evict. -const SizedFile = struct { - /// Path relative to the cache root. - path: []const u8, - size: u64, - last_use: i128, - - fn lessThan(_: void, lhs: SizedFile, rhs: SizedFile) bool { - if (lhs.last_use != rhs.last_use) return lhs.last_use < rhs.last_use; - return std.mem.order(u8, lhs.path, rhs.path) == .lt; } }; -/// Delete the least recently used persistent cache files, by `max(atime, -/// mtime)`, until the files left total at most `limits.max_size_bytes`. The -/// bound covers every family the age passes cover (checked modules, pairings, -/// executables, test results, wasm hosts, glue plugins, and object packs) in -/// every version directory, so one oversized family evicts the oldest files -/// of all of them. Lock files are never evicted: they name a stable identity -/// another process may hold. -fn enforceSizeLimit(std_io: Io, cache_base: []const u8, limits: Limits, maybe_stats: ?*CleanupStats) void { - var base_dir = Dir.cwd().openDir(std_io, cache_base, .{ .iterate = true }) catch return; - defer base_dir.close(std_io); - - var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); - defer arena.deinit(); - var files: std.ArrayList(SizedFile) = .empty; - var total: u64 = 0; - collectSizedFiles(std_io, base_dir, arena.allocator(), limits.object_pack_format_version, &files, &total) catch { - if (maybe_stats) |stats| stats.errors += 1; - return; - }; - if (total <= limits.max_size_bytes) return; - - std.mem.sort(SizedFile, files.items, {}, SizedFile.lessThan); - for (files.items) |file| { - if (total <= limits.max_size_bytes) break; - base_dir.deleteFile(std_io, file.path) catch { - if (maybe_stats) |stats| stats.errors += 1; - continue; - }; - total -= file.size; - if (maybe_stats) |stats| stats.evicted_files_deleted += 1; - } -} - -fn collectSizedFiles(std_io: Io, base_dir: Dir, arena: std.mem.Allocator, format_version: u32, files: *std.ArrayList(SizedFile), total: *u64) std.mem.Allocator.Error!void { - var namespace_buf: [cache_config.object_pack_namespace_prefix.len + 10]u8 = undefined; - const current_namespace = objectPackNamespaceName(&namespace_buf, format_version); - - var version_it = base_dir.iterate(); - while (true) { - const version_entry = (version_it.next(std_io) catch break) orelse break; - if (version_entry.kind != .directory) continue; - var version_dir = base_dir.openDir(std_io, version_entry.name, .{}) catch continue; - defer version_dir.close(std_io); - for (persistent_families) |family| { - var family_dir = version_dir.openDir(std_io, family.name, .{ .iterate = true }) catch continue; - defer family_dir.close(std_io); - const prefix = try std.fs.path.join(arena, &.{ version_entry.name, family.name }); - try collectSizedFilesIn(std_io, family_dir, prefix, family.nested_directory_depth, arena, files, total); - } - var namespace_dir = version_dir.openDir(std_io, current_namespace, .{ .iterate = true }) catch continue; - defer namespace_dir.close(std_io); - const prefix = try std.fs.path.join(arena, &.{ version_entry.name, current_namespace }); - try collectSizedFilesIn(std_io, namespace_dir, prefix, object_pack_nested_directory_depth, arena, files, total); - } -} - -fn collectSizedFilesIn(std_io: Io, dir: Dir, prefix: []const u8, nested_directory_depth: u8, arena: std.mem.Allocator, files: *std.ArrayList(SizedFile), total: *u64) std.mem.Allocator.Error!void { - var it = dir.iterate(); - while (true) { - const entry = (it.next(std_io) catch break) orelse break; - if (entry.kind == .file) { - if (std.mem.endsWith(u8, entry.name, ".lock")) continue; - const info = dir.statFile(std_io, entry.name, .{}) catch continue; - const mtime: i128 = @intCast(info.mtime.nanoseconds); - const atime: i128 = if (info.atime) |atime| @intCast(atime.nanoseconds) else mtime; - try files.append(arena, .{ - .path = try std.fs.path.join(arena, &.{ prefix, entry.name }), - .size = info.size, - .last_use = @max(mtime, atime), - }); - total.* += info.size; - } else if (entry.kind == .directory and nested_directory_depth > 0) { - var child = dir.openDir(std_io, entry.name, .{ .iterate = true }) catch continue; - defer child.close(std_io); - const child_prefix = try std.fs.path.join(arena, &.{ prefix, entry.name }); - try collectSizedFilesIn(std_io, child, child_prefix, nested_directory_depth - 1, arena, files, total); - } - } -} - -/// The later of a file's access and modification times, so a read keeps a -/// file alive where the filesystem maintains access times, and a file ages -/// from its write where it does not. -fn lastUseNs(dir: Dir, std_io: Io, name: []const u8) ?i128 { - const info = dir.statFile(std_io, name, .{}) catch return null; - const mtime: i128 = @intCast(info.mtime.nanoseconds); - const atime: i128 = if (info.atime) |atime| @intCast(atime.nanoseconds) else mtime; - return @max(mtime, atime); -} - -/// Clean up files in a cache subdirectory older than 30 days. The caller passes -/// the exact nested directory depth for the cache family: one bucket level for -/// mod/exe/test/wasm-host and target/opt levels for glue dylibs. -fn cleanupCacheSubdir(std_io: Io, subdir: Dir, now_ns: i128, maybe_stats: ?*CleanupStats, nested_directory_depth: u8) void { - var it = subdir.iterate(); - while (true) { - const entry = (it.next(std_io) catch break) orelse break; - - if (entry.kind == .file) { - // A lock file names a stable cache identity. Removing it while a - // process holds the lock would let another process create a new - // inode and enter the same critical section concurrently. - if (std.mem.endsWith(u8, entry.name, ".lock")) continue; - deleteCacheFileIfOld(std_io, subdir, entry.name, now_ns, maybe_stats); - } else if (entry.kind == .directory and nested_directory_depth > 0) { - var child = subdir.openDir(std_io, entry.name, .{ .iterate = true }) catch continue; - defer child.close(std_io); - cleanupCacheSubdir(std_io, child, now_ns, maybe_stats, nested_directory_depth - 1); - } - } - - // NOTE: We intentionally do NOT delete empty bucket directories or subdirs. - // Empty directories are harmless and deleting them can cause race conditions. -} - -/// Delete `name` (relative to `dir`) if it is older than the persistent cache -/// threshold. -fn deleteCacheFileIfOld(std_io: Io, dir: Dir, name: []const u8, now_ns: i128, maybe_stats: ?*CleanupStats) void { - const mtime = mtimeNs(dir, std_io, name) orelse return; - if (now_ns - mtime <= Config.PERSISTENT_MAX_AGE_NS) return; - - dir.deleteFile(std_io, name) catch { - if (maybe_stats) |stats| stats.errors += 1; - return; - }; - if (maybe_stats) |stats| stats.cache_files_deleted += 1; -} - -/// Delete a specific scratch directory and its coordination file. -/// Used for immediate cleanup after spawning a child process. -pub fn deleteTempDir(std_io: Io, temp_dir_path: []const u8) void { - // `temp_dir_path` is absolute; cwd()-relative operations resolve it directly. - Dir.cwd().deleteTree(std_io, temp_dir_path) catch {}; - - var txt_buf: [std.fs.max_path_bytes + 4]u8 = undefined; - const txt_path = std.fmt.bufPrint(&txt_buf, "{s}.txt", .{temp_dir_path}) catch return; - Dir.cwd().deleteFile(std_io, txt_path) catch {}; -} +var active: std.atomic.Value(?*Session) = .init(null); -/// Remove legacy `.rcache` files directly inside the cache root. -/// -/// Directory names cannot identify legacy caches: current source-derived -/// compiler compatibility IDs are hexadecimal, just like old flat hash -/// directories. Never delete a directory here. The scoped expiry passes -/// handle scratch directories and artifacts within compiler namespaces. -fn cleanupLegacyPersistentCache(std_io: Io, cache_base: []const u8, maybe_stats: ?*CleanupStats) void { - var base = Dir.cwd().openDir(std_io, cache_base, .{ .iterate = true }) catch return; - defer base.close(std_io); +pub const Session = struct { + store: artifacts.Store, + limits: Limits, + next_compulsory: i64 = 0, + // Only maintenance admission uses this mutex. Compiler scheduling never + // takes it, and concurrent callbacks skip it instead of waiting. + mutex: std.atomic.Mutex = .unlocked, - var it = base.iterate(); - while (true) { - const entry = (it.next(std_io) catch break) orelse break; - if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".rcache")) continue; - base.deleteFile(std_io, entry.name) catch { - if (maybe_stats) |stats| stats.errors += 1; - continue; - }; - if (maybe_stats) |stats| stats.temp_files_deleted += 1; + pub fn init(allocator: std.mem.Allocator, io: std.Io, root: []const u8, limits: Limits) std.mem.Allocator.Error!Session { + return .{ .store = try artifacts.Store.init(allocator, io, root), .limits = limits }; } -} - -test "background cleanup preserves compatibility namespaces and removes only flat legacy files" { - const allocator = std.testing.allocator; - var tmp_dir = std.testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - const cache_base = try std.fs.path.join(allocator, &.{ ".zig-cache", "tmp", &tmp_dir.sub_path, "roc" }); - defer allocator.free(cache_base); - const compatibility_id = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - const namespaces = [_][]const u8{ compatibility_id, "compat-" ++ compatibility_id, "debug-abcd1234", "embedder-compiler-v1" }; - for (namespaces) |namespace| { - const scratch = try std.fs.path.join(allocator, &.{ cache_base, namespace, scratch_dir_name, "active" }); - defer allocator.free(scratch); - const artifact_dir = try std.fs.path.join(allocator, &.{ cache_base, namespace, "mod", "aa" }); - defer allocator.free(artifact_dir); - try Dir.cwd().createDirPath(std.testing.io, scratch); - try Dir.cwd().createDirPath(std.testing.io, artifact_dir); - const source = try std.fs.path.join(allocator, &.{ scratch, "main.roc" }); - defer allocator.free(source); - const artifact = try std.fs.path.join(allocator, &.{ artifact_dir, "checked.rcache" }); - defer allocator.free(artifact); - try Dir.cwd().writeFile(std.testing.io, .{ .sub_path = source, .data = "live staged source" }); - try Dir.cwd().writeFile(std.testing.io, .{ .sub_path = artifact, .data = "live checked artifact" }); + pub fn start(self: *Session) void { + active.store(self, .release); + self.tick(.serial); } - const legacy_file = try std.fs.path.join(allocator, &.{ cache_base, "old.rcache" }); - defer allocator.free(legacy_file); - try Dir.cwd().writeFile(std.testing.io, .{ .sub_path = legacy_file, .data = "flat legacy cache" }); - - var cleanup = (try startBackgroundCleanup(cache_base, std.testing.io, test_limits)).?; - cleanup.join(); - for (namespaces) |namespace| { - const source = try std.fs.path.join(allocator, &.{ cache_base, namespace, scratch_dir_name, "active", "main.roc" }); - defer allocator.free(source); - const artifact = try std.fs.path.join(allocator, &.{ cache_base, namespace, "mod", "aa", "checked.rcache" }); - defer allocator.free(artifact); - const source_bytes = try Dir.cwd().readFileAlloc(std.testing.io, source, allocator, .unlimited); - defer allocator.free(source_bytes); - const artifact_bytes = try Dir.cwd().readFileAlloc(std.testing.io, artifact, allocator, .unlimited); - defer allocator.free(artifact_bytes); - try std.testing.expectEqualStrings("live staged source", source_bytes); - try std.testing.expectEqualStrings("live checked artifact", artifact_bytes); + pub fn deinit(self: *Session) void { + active.store(null, .release); + self.tick(.serial); + self.store.deinit(); } - try std.testing.expectError(error.FileNotFound, Dir.cwd().access(std.testing.io, legacy_file, .{})); -} - -const test_limits: Limits = .{ .object_pack_format_version = 1, .max_size_bytes = std.math.maxInt(u64) }; - -test "CleanupStats initializes to zero" { - const stats = CleanupStats{}; - try std.testing.expectEqual(@as(u32, 0), stats.temp_dirs_deleted); - try std.testing.expectEqual(@as(u32, 0), stats.temp_files_deleted); - try std.testing.expectEqual(@as(u32, 0), stats.cache_files_deleted); - try std.testing.expectEqual(@as(u32, 0), stats.empty_dirs_deleted); - try std.testing.expectEqual(@as(u32, 0), stats.errors); -} - -test "background cleanup preserves directories next to the cache root" { - const allocator = std.testing.allocator; - - var tmp_dir = std.testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - - const root_path = try std.fs.path.join(allocator, &.{ ".zig-cache", "tmp", &tmp_dir.sub_path }); - defer allocator.free(root_path); - const cache_base = try std.fs.path.join(allocator, &.{ root_path, "roc" }); - defer allocator.free(cache_base); - const unrelated_dir = try std.fs.path.join(allocator, &.{ root_path, "roc-active-cache" }); - defer allocator.free(unrelated_dir); - const sentinel_path = try std.fs.path.join(allocator, &.{ unrelated_dir, "sentinel" }); - defer allocator.free(sentinel_path); - - try Dir.cwd().createDirPath(std.testing.io, cache_base); - try Dir.cwd().createDirPath(std.testing.io, unrelated_dir); - (try Dir.cwd().createFile(std.testing.io, sentinel_path, .{})).close(std.testing.io); - - var base = CacheBase{}; - @memcpy(base.buf[0..cache_base.len], cache_base); - base.len = cache_base.len; - runCleanup(base, std.testing.io, test_limits); - - try Dir.cwd().access(std.testing.io, sentinel_path, .{}); -} - -test "cleanupScratchDirs deletes only stale entries of each version's scratch directory" { - const allocator = std.testing.allocator; - - var tmp_dir = std.testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - - const cache_base = try std.fs.path.join(allocator, &.{ ".zig-cache", "tmp", &tmp_dir.sub_path, "roc" }); - defer allocator.free(cache_base); - const run_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", scratch_dir_name, "run" }); - defer allocator.free(run_dir); - const run_exe = try std.fs.path.join(allocator, &.{ run_dir, "app" }); - defer allocator.free(run_exe); - const run_txt = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", scratch_dir_name, "run.txt" }); - defer allocator.free(run_txt); - const exe_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "exe", "old" }); - defer allocator.free(exe_dir); - const exe_file = try std.fs.path.join(allocator, &.{ exe_dir, "app" }); - defer allocator.free(exe_file); - - try Dir.cwd().createDirPath(std.testing.io, run_dir); - try Dir.cwd().createDirPath(std.testing.io, exe_dir); - (try Dir.cwd().createFile(std.testing.io, run_exe, .{})).close(std.testing.io); - (try Dir.cwd().createFile(std.testing.io, run_txt, .{})).close(std.testing.io); - (try Dir.cwd().createFile(std.testing.io, exe_file, .{})).close(std.testing.io); - - const now_ns = nowNs(std.testing.io); - - var fresh_stats = CleanupStats{}; - cleanupScratchDirs(std.testing.io, cache_base, now_ns, &fresh_stats); - try std.testing.expectEqual(@as(u32, 0), fresh_stats.temp_dirs_deleted); - try Dir.cwd().access(std.testing.io, run_exe, .{}); - try Dir.cwd().access(std.testing.io, run_txt, .{}); - - const stale_ns: i128 = now_ns + Config.TEMP_MAX_AGE_NS + std.time.ns_per_s; - var stale_stats = CleanupStats{}; - cleanupScratchDirs(std.testing.io, cache_base, stale_ns, &stale_stats); - try std.testing.expectEqual(@as(u32, 1), stale_stats.temp_dirs_deleted); - try std.testing.expectError(error.FileNotFound, Dir.cwd().access(std.testing.io, run_dir, .{})); - try std.testing.expectError(error.FileNotFound, Dir.cwd().access(std.testing.io, run_txt, .{})); - try Dir.cwd().access(std.testing.io, exe_file, .{}); -} - -test "deleteTempDir deletes directory and coordination file" { - const allocator = std.testing.allocator; - - // Create a temporary test directory - var tmp_dir = std.testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - - // Create a subdirectory simulating a temp runtime dir - try tmp_dir.dir.createDir(std.testing.io, "test_temp_dir", .default_dir); - - // Create a file inside the directory - const inner_file = try tmp_dir.dir.createFile(std.testing.io, "test_temp_dir/executable", .{}); - inner_file.close(std.testing.io); - - // Create the coordination file (.txt) - const coord_file = try tmp_dir.dir.createFile(std.testing.io, "test_temp_dir.txt", .{}); - coord_file.close(std.testing.io); - - // Get the full path to the temp dir - const temp_dir_path = try std.fs.path.join(allocator, &.{ ".zig-cache", "tmp", &tmp_dir.sub_path, "test_temp_dir" }); - defer allocator.free(temp_dir_path); - - // Verify both exist - tmp_dir.dir.access(std.testing.io, "test_temp_dir", .{}) catch { - return error.TestSetupFailed; - }; - tmp_dir.dir.access(std.testing.io, "test_temp_dir.txt", .{}) catch { - return error.TestSetupFailed; - }; - - // Delete the temp dir - deleteTempDir(std.testing.io, temp_dir_path); - - // Verify directory is deleted - tmp_dir.dir.access(std.testing.io, "test_temp_dir", .{}) catch |err| { - try std.testing.expectEqual(error.FileNotFound, err); - return; // Success - directory was deleted - }; - // If we get here, the directory still exists - return error.DirectoryNotDeleted; -} - -test "cleanupCacheSubdir deletes old files and keeps new files" { - const allocator = std.testing.allocator; - - var tmp_dir = std.testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - - // Create a cache subdir structure - try tmp_dir.dir.createDir(std.testing.io, "cache_subdir", .default_dir); - try tmp_dir.dir.createDir(std.testing.io, "cache_subdir/bucket1", .default_dir); - try tmp_dir.dir.createDir(std.testing.io, "cache_subdir/bucket2", .default_dir); - - // Create files in buckets - const file1 = try tmp_dir.dir.createFile(std.testing.io, "cache_subdir/bucket1/old_file.rcache", .{}); - file1.close(std.testing.io); - - const file2 = try tmp_dir.dir.createFile(std.testing.io, "cache_subdir/bucket2/new_file.rcache", .{}); - file2.close(std.testing.io); - - // Get the full path - const subdir_path = try std.fs.path.join(allocator, &.{ ".zig-cache", "tmp", &tmp_dir.sub_path, "cache_subdir" }); - defer allocator.free(subdir_path); - - // Get current time - files will be very recent (age ~0) - const now_ns = nowNs(std.testing.io); - - // Run cleanup with current time - nothing should be deleted (files are too new) - var stats = CleanupStats{}; - { - var subdir = try Dir.cwd().openDir(std.testing.io, subdir_path, .{ .iterate = true }); - defer subdir.close(std.testing.io); - cleanupCacheSubdir(std.testing.io, subdir, now_ns, &stats, 1); + pub fn tick(self: *Session, demand: Demand) void { + var stats = maintenance.Stats{}; + self.tickAt(self.store.now(), demand, &stats); } - // Both files should still exist since they're brand new - try std.testing.expectEqual(@as(u32, 0), stats.cache_files_deleted); - - tmp_dir.dir.access(std.testing.io, "cache_subdir/bucket1/old_file.rcache", .{}) catch { - return error.FileShouldExist; - }; - tmp_dir.dir.access(std.testing.io, "cache_subdir/bucket2/new_file.rcache", .{}) catch { - return error.FileShouldExist; - }; - - // Now test with a fake "future" time that makes all files appear old - const far_future_ns: i128 = now_ns + Config.PERSISTENT_MAX_AGE_NS + std.time.ns_per_s; - var stats2 = CleanupStats{}; - { - var subdir = try Dir.cwd().openDir(std.testing.io, subdir_path, .{ .iterate = true }); - defer subdir.close(std.testing.io); - cleanupCacheSubdir(std.testing.io, subdir, far_future_ns, &stats2, 1); + fn tickAt(self: *Session, now: i64, demand: Demand, stats: *maintenance.Stats) void { + if (!self.mutex.tryLock()) return; + defer self.mutex.unlock(); + if (now >= self.next_compulsory) { + self.next_compulsory = now +| maintenance.Policy.grant_interval; + maintenance.run(&self.store, self.limits, now, .compulsory, stats); + } + if (demand.hasCapacity()) maintenance.run(&self.store, self.limits, now, .optional, stats); } +}; - // Both files should be deleted now - try std.testing.expectEqual(@as(u32, 2), stats2.cache_files_deleted); - - // Verify files are gone - tmp_dir.dir.access(std.testing.io, "cache_subdir/bucket1/old_file.rcache", .{}) catch |err| { - try std.testing.expectEqual(error.FileNotFound, err); - }; +/// Called only at completed-task/await-result boundaries, without holding the +/// scheduler's channel locks. The caller resumes foreground work after at most +/// one optional unit; demand is reconsidered at the next boundary. +pub fn safePoint(demand: Demand) void { + if (active.load(.acquire)) |session| session.tick(demand); } -test "cleanupPersistentCache deletes old cache files at each family depth" { +test "foreground events grant completed checkpoints and revoke optional admission between units" { const allocator = std.testing.allocator; - - var tmp_dir = std.testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - - const cache_base = try std.fs.path.join(allocator, &.{ ".zig-cache", "tmp", &tmp_dir.sub_path, "roc-cache" }); - defer allocator.free(cache_base); - - const glue_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "glue-plugin", "x64mac", "dev" }); - defer allocator.free(glue_dir); - - const mod_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "mod", "aa" }); - defer allocator.free(mod_dir); - const pair_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "pair", "cc" }); - defer allocator.free(pair_dir); - const wasm_host_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "wasm-host", "bb" }); - defer allocator.free(wasm_host_dir); - - try Dir.cwd().createDirPath(std.testing.io, glue_dir); - try Dir.cwd().createDirPath(std.testing.io, mod_dir); - try Dir.cwd().createDirPath(std.testing.io, pair_dir); - try Dir.cwd().createDirPath(std.testing.io, wasm_host_dir); - - const glue_file = try std.fs.path.join(allocator, &.{ glue_dir, "old.o" }); - defer allocator.free(glue_file); - const glue_tmp = try std.fs.path.join(allocator, &.{ glue_dir, "old.o.1.0.tmp" }); - defer allocator.free(glue_tmp); - const mod_file = try std.fs.path.join(allocator, &.{ mod_dir, "old.rcache" }); - defer allocator.free(mod_file); - const pair_file = try std.fs.path.join(allocator, &.{ pair_dir, "old-pairing" }); - defer allocator.free(pair_file); - const wasm_host_file = try std.fs.path.join(allocator, &.{ wasm_host_dir, "old-host" }); - defer allocator.free(wasm_host_file); - const wasm_host_lock = try std.fs.path.join(allocator, &.{ wasm_host_dir, "old-host.lock" }); - defer allocator.free(wasm_host_lock); - - (try Dir.cwd().createFile(std.testing.io, glue_file, .{})).close(std.testing.io); - (try Dir.cwd().createFile(std.testing.io, glue_tmp, .{})).close(std.testing.io); - (try Dir.cwd().createFile(std.testing.io, mod_file, .{})).close(std.testing.io); - (try Dir.cwd().createFile(std.testing.io, pair_file, .{})).close(std.testing.io); - (try Dir.cwd().createFile(std.testing.io, wasm_host_file, .{})).close(std.testing.io); - (try Dir.cwd().createFile(std.testing.io, wasm_host_lock, .{})).close(std.testing.io); - - const now_ns = nowNs(std.testing.io); - const far_future_ns: i128 = now_ns + Config.PERSISTENT_MAX_AGE_NS + std.time.ns_per_s; - var stats = CleanupStats{}; - cleanupPersistentCache(std.testing.io, cache_base, far_future_ns, &stats); - - try std.testing.expectEqual(@as(u32, 5), stats.cache_files_deleted); - - Dir.cwd().access(std.testing.io, glue_file, .{}) catch |err| { - try std.testing.expectEqual(error.FileNotFound, err); - }; - Dir.cwd().access(std.testing.io, glue_tmp, .{}) catch |err| { - try std.testing.expectEqual(error.FileNotFound, err); - }; - Dir.cwd().access(std.testing.io, mod_file, .{}) catch |err| { - try std.testing.expectEqual(error.FileNotFound, err); + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var session = try Session.init(allocator, io, root, .{ .object_pack_format_version = 1, .max_size_bytes = 1000 }); + defer session.store.deinit(); + try session.store.write(.{ .namespace = @splat(1), .family = .checked, .key = @splat(0) }, "entry", 1); + const now = 31 * artifacts.day_ns; + var stats = maintenance.Stats{}; + session.tickAt(now, .serial, &stats); + try std.testing.expectEqual(@as(usize, 1), stats.units); // compulsory startup + const events = [_]struct { demand: Demand, units: usize }{ + .{ .demand = .{ .waiting = .{ .budget = 2, .tasks = 1 } }, .units = 1 }, + .{ .demand = .serial, .units = 0 }, + .{ .demand = .linking, .units = 0 }, + .{ .demand = .{ .waiting = .{ .budget = 4, .tasks = 4 } }, .units = 0 }, + .{ .demand = .{ .waiting = .{ .budget = 1, .tasks = 0 } }, .units = 0 }, + .{ .demand = .{ .waiting = .{ .budget = 4, .tasks = 2 } }, .units = 1 }, }; - Dir.cwd().access(std.testing.io, wasm_host_file, .{}) catch |err| { - try std.testing.expectEqual(error.FileNotFound, err); - }; - try std.testing.expectError(error.FileNotFound, Dir.cwd().access(std.testing.io, pair_file, .{})); - try Dir.cwd().access(std.testing.io, wasm_host_lock, .{}); -} - -/// Write a cache file whose last use is `last_use_ns`. The timestamps are -/// set through the open file, which every host supports. -fn writeTestFile(dir_path: []const u8, name: []const u8, data: []const u8, last_use_ns: i128) (Dir.CreateDirPathError || Dir.OpenError || Dir.WriteFileError || std.Io.File.OpenError || std.Io.File.SetTimestampsError)!void { - try Dir.cwd().createDirPath(std.testing.io, dir_path); - var dir = try Dir.cwd().openDir(std.testing.io, dir_path, .{}); - defer dir.close(std.testing.io); - try dir.writeFile(std.testing.io, .{ .sub_path = name, .data = data }); - const file = try dir.openFile(std.testing.io, name, .{ .mode = .read_write }); - defer file.close(std.testing.io); - const stamp: std.Io.File.SetTimestamp = .{ .new = .{ .nanoseconds = @intCast(last_use_ns) } }; - try file.setTimestamps(std.testing.io, .{ .access_timestamp = stamp, .modify_timestamp = stamp }); -} - -test "object pack sweep deletes stale format namespaces and ages current packs by origin" { - const allocator = std.testing.allocator; - var tmp_dir = std.testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - const cache_base = try std.fs.path.join(allocator, &.{ ".zig-cache", "tmp", &tmp_dir.sub_path, "roc" }); - defer allocator.free(cache_base); - - const now_ns = nowNs(std.testing.io); - const local_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "objects-v7", "x64glibc-dev", "local", "aa" }); - defer allocator.free(local_dir); - const pkg_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "objects-v7", "x64glibc-dev", "pkg", "bb" }); - defer allocator.free(pkg_dir); - const stale_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "objects-v6", "x64glibc-dev", "pkg", "bb" }); - defer allocator.free(stale_dir); - const stale_namespace = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "objects-v6" }); - defer allocator.free(stale_namespace); - const programs_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "objects-v7", "x64glibc-dev", "programs" }); - defer allocator.free(programs_dir); - const optimized_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "objects-v7", "x64glibc-speed", "optimized" }); - defer allocator.free(optimized_dir); - try writeTestFile(local_dir, "fresh.rpk", "local", now_ns); - try writeTestFile(pkg_dir, "fresh.rpk", "pkg", now_ns); - try writeTestFile(optimized_dir, "cc.rpk", "optimized", now_ns); - try writeTestFile(stale_dir, "fresh.rpk", "stale", now_ns); - try writeTestFile(programs_dir, "fresh.rpo", "program", now_ns); - - const limits: Limits = .{ .object_pack_format_version = 7, .max_size_bytes = std.math.maxInt(u64) }; - var stats = CleanupStats{}; - sweep(std.testing.io, cache_base, limits, now_ns, &stats); - try std.testing.expectEqual(@as(u32, 1), stats.stale_namespaces_deleted); - try std.testing.expectEqual(@as(u32, 0), stats.cache_files_deleted); - try std.testing.expectError(error.FileNotFound, Dir.cwd().access(std.testing.io, stale_namespace, .{})); - - var local_dir_handle = try Dir.cwd().openDir(std.testing.io, local_dir, .{}); - defer local_dir_handle.close(std.testing.io); - var pkg_dir_handle = try Dir.cwd().openDir(std.testing.io, pkg_dir, .{}); - defer pkg_dir_handle.close(std.testing.io); - var programs_dir_handle = try Dir.cwd().openDir(std.testing.io, programs_dir, .{}); - defer programs_dir_handle.close(std.testing.io); - var optimized_dir_handle = try Dir.cwd().openDir(std.testing.io, optimized_dir, .{}); - defer optimized_dir_handle.close(std.testing.io); - try local_dir_handle.access(std.testing.io, "fresh.rpk", .{}); - try pkg_dir_handle.access(std.testing.io, "fresh.rpk", .{}); - try programs_dir_handle.access(std.testing.io, "fresh.rpo", .{}); - - const two_days_later = now_ns + 2 * Config.LOCAL_PACK_MAX_AGE_NS; - var aged_stats = CleanupStats{}; - sweep(std.testing.io, cache_base, limits, two_days_later, &aged_stats); - try std.testing.expectEqual(@as(u32, 2), aged_stats.cache_files_deleted); - try std.testing.expectError(error.FileNotFound, local_dir_handle.access(std.testing.io, "fresh.rpk", .{})); - try std.testing.expectError(error.FileNotFound, programs_dir_handle.access(std.testing.io, "fresh.rpo", .{})); - try pkg_dir_handle.access(std.testing.io, "fresh.rpk", .{}); - try optimized_dir_handle.access(std.testing.io, "cc.rpk", .{}); - - const past_window = now_ns + Config.PERSISTENT_MAX_AGE_NS + std.time.ns_per_s; - var pkg_stats = CleanupStats{}; - sweep(std.testing.io, cache_base, limits, past_window, &pkg_stats); - try std.testing.expectEqual(@as(u32, 2), pkg_stats.cache_files_deleted); - try std.testing.expectError(error.FileNotFound, pkg_dir_handle.access(std.testing.io, "fresh.rpk", .{})); - try std.testing.expectError(error.FileNotFound, optimized_dir_handle.access(std.testing.io, "cc.rpk", .{})); -} - -test "size bound evicts least recently used files across families until under the limit" { - const allocator = std.testing.allocator; - var tmp_dir = std.testing.tmpDir(.{}); - defer tmp_dir.cleanup(); - const cache_base = try std.fs.path.join(allocator, &.{ ".zig-cache", "tmp", &tmp_dir.sub_path, "roc" }); - defer allocator.free(cache_base); - - const now_ns = nowNs(std.testing.io); - const hour: i128 = 60 * 60 * std.time.ns_per_s; - const mod_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "mod", "aa" }); - defer allocator.free(mod_dir); - const exe_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "exe", "bb" }); - defer allocator.free(exe_dir); - const pack_dir = try std.fs.path.join(allocator, &.{ cache_base, "0.0.0-test", "objects-v7", "x64glibc-dev", "pkg", "cc" }); - defer allocator.free(pack_dir); - const block: [100]u8 = @splat('x'); - const lock_block: [300]u8 = @splat('x'); - try writeTestFile(mod_dir, "oldest.rcache", &block, now_ns - 3 * hour); - try writeTestFile(pack_dir, "middle.rpk", &block, now_ns - 2 * hour); - try writeTestFile(exe_dir, "newest", &block, now_ns - hour); - try writeTestFile(exe_dir, "newest.lock", &lock_block, now_ns - 4 * hour); - - var stats = CleanupStats{}; - sweep(std.testing.io, cache_base, .{ .object_pack_format_version = 7, .max_size_bytes = 450 }, now_ns, &stats); - try std.testing.expectEqual(@as(u32, 0), stats.evicted_files_deleted); - - sweep(std.testing.io, cache_base, .{ .object_pack_format_version = 7, .max_size_bytes = 150 }, now_ns, &stats); - try std.testing.expectEqual(@as(u32, 2), stats.evicted_files_deleted); - var mod = try Dir.cwd().openDir(std.testing.io, mod_dir, .{}); - defer mod.close(std.testing.io); - var exe = try Dir.cwd().openDir(std.testing.io, exe_dir, .{}); - defer exe.close(std.testing.io); - var pack = try Dir.cwd().openDir(std.testing.io, pack_dir, .{}); - defer pack.close(std.testing.io); - try std.testing.expectError(error.FileNotFound, mod.access(std.testing.io, "oldest.rcache", .{})); - try std.testing.expectError(error.FileNotFound, pack.access(std.testing.io, "middle.rpk", .{})); - try exe.access(std.testing.io, "newest", .{}); - try exe.access(std.testing.io, "newest.lock", .{}); + for (events) |event| { + stats = .{}; + session.tickAt(now, event.demand, &stats); + try std.testing.expectEqual(event.units, stats.units); + try std.testing.expectEqual(@as(usize, 0), stats.errors); + } + stats = .{}; + session.tickAt(now + maintenance.Policy.grant_interval, .{ .waiting = .{ .budget = 1, .tasks = 1 } }, &stats); + try std.testing.expectEqual(@as(usize, 1), stats.units); // saturated one-worker progress } diff --git a/src/compile/cache_config.zig b/src/compile/cache_config.zig index dce54fe8ef4..ad84a2cf7bf 100644 --- a/src/compile/cache_config.zig +++ b/src/compile/cache_config.zig @@ -1,7 +1,7 @@ //! Cache configuration and directory management for the Roc compiler cache system. //! -//! This module provides platform-specific cache directory logic that matches -//! the Rust implementation, ensuring consistency across the codebase. +//! Resolves the shared native store root and explicit logical cache scopes. +//! Artifact scopes are identity data; only tooling/package paths are directories. const std = @import("std"); const builtin = @import("builtin"); @@ -10,20 +10,6 @@ const CoreCtx = @import("ctx").CoreCtx; const Allocator = std.mem.Allocator; -/// Name of the per-version subdirectory that holds per-invocation scratch -/// directories (see `CacheConfig.getScratchDir`). -pub const scratch_dir_name = "tmp"; - -/// Prefix of the per-version subdirectories that hold object-cache packs. One -/// namespace exists per pack format, named by appending the format version, so -/// packs of different formats never share a directory. -pub const object_pack_namespace_prefix = "objects-v"; - -/// The object-cache namespace of pack format `format_version`. -pub fn objectPackNamespace(comptime format_version: u32) []const u8 { - return std.fmt.comptimePrint(object_pack_namespace_prefix ++ "{d}", .{format_version}); -} - const CacheOs = enum { windows, macos, other }; fn cacheOs(os: std.Target.Os.Tag) CacheOs { @@ -444,7 +430,7 @@ pub const CacheConfig = struct { return std.fs.path.join(allocator, &[_][]const u8{ base_dir, version_dir }); } - /// Get the checked-artifact cache directory. + /// Logical checked-artifact scope within this compiler compatibility identity. pub fn getCheckedArtifactCacheDir(self: Self, allocator: Allocator) (Allocator.Error || error{NoHomeDirectory})![]u8 { const version_dir = try self.getVersionCacheDir(allocator); defer allocator.free(version_dir); @@ -452,25 +438,6 @@ pub const CacheConfig = struct { return std.fs.path.join(allocator, &[_][]const u8{ version_dir, "mod" }); } - /// Pointers from a module's placement to the key of its most recently - /// stored checked entry (design.md "Compile-Time Value Reuse Across - /// Edits"). - pub fn getCheckedPlacementDir(self: Self, allocator: Allocator) (Allocator.Error || error{NoHomeDirectory})![]u8 { - const version_dir = try self.getVersionCacheDir(allocator); - defer allocator.free(version_dir); - - return std.fs.path.join(allocator, &[_][]const u8{ version_dir, "mod-latest" }); - } - - /// The literal values of each program root module, by the module's - /// placement (design.md "Compile-Time Value Reuse Across Edits"). - pub fn getLiteralValuesDir(self: Self, allocator: Allocator) (Allocator.Error || error{NoHomeDirectory})![]u8 { - const version_dir = try self.getVersionCacheDir(allocator); - defer allocator.free(version_dir); - - return std.fs.path.join(allocator, &[_][]const u8{ version_dir, "literals" }); - } - /// Exact platform/app composition results, separate from reusable modules. pub fn getPlatformPairingCacheDir(self: Self, allocator: Allocator) (Allocator.Error || error{NoHomeDirectory})![]u8 { const version_dir = try self.getVersionCacheDir(allocator); @@ -478,7 +445,7 @@ pub const CacheConfig = struct { return std.fs.path.join(allocator, &.{ version_dir, "pair" }); } - /// Get the canonicalized-module cache directory. + /// Logical canonicalized-module scope. /// /// Separate from the checked-artifact directory because the two caches key /// their entries on different inputs: a canonicalized entry names one @@ -507,21 +474,7 @@ pub const CacheConfig = struct { return std.fs.path.join(allocator, &[_][]const u8{ version_dir, "exe" }); } - /// Get the scratch directory for per-invocation build outputs and runtime - /// executables. Each invocation creates its own unique subdirectory here. - /// - /// This lives under the user's own cache root rather than the system temp - /// directory, so no other user can create or rename entries along the path, - /// and executables built here are on the same filesystem as the exe cache - /// they get hardlinked into. - pub fn getScratchDir(self: Self, allocator: Allocator) (Allocator.Error || error{NoHomeDirectory})![]u8 { - const version_dir = try self.getVersionCacheDir(allocator); - defer allocator.free(version_dir); - - return std.fs.path.join(allocator, &[_][]const u8{ version_dir, scratch_dir_name }); - } - - /// Get the test cache directory (for cached test results). + /// Logical scope for cached test results. pub fn getTestCacheDir(self: Self, allocator: Allocator) (Allocator.Error || error{NoHomeDirectory})![]u8 { const version_dir = try self.getVersionCacheDir(allocator); defer allocator.free(version_dir); diff --git a/src/compile/cache_index.zig b/src/compile/cache_index.zig new file mode 100644 index 00000000000..d42dfd43dbf --- /dev/null +++ b/src/compile/cache_index.zig @@ -0,0 +1,522 @@ +//! Bounded, positional-I/O catalog for the native artifact store. +//! +//! Four independent crit-bit indexes share fixed-size records. A key has a +//! fixed width, so a lookup visits at most key_bits branch records. Mutations +//! replace a bounded set of records through one redo transaction, never a +//! growing WAL. The caller holds the shard's kernel lock through recovery and +//! the transaction. No stored pointer is trusted before checking its bounds. + +const std = @import("std"); +const Io = std.Io; +const Dir = Io.Dir; + +pub const record_size = 512; +pub const Key = [64]u8; +pub const Value = [384]u8; +pub const key_bits = @bitSizeOf(Key); +pub const max_changes = 24; +pub const RecordId = u64; +pub const Tree = enum(u2) { artifacts, recency, owners, spare }; +const Record = [record_size]u8; +const magic = "ROCIDX01"; +const transaction_magic = "ROCTXN01"; +const redo_size = 24 + record_size + max_changes * (8 + record_size); + +pub const Error = error{ IoFailure, CorruptCatalog, CapacityExceeded }; + +/// Observable work, including recovery. Tests assert bounds on actual I/O. +pub const Counters = struct { + reads: usize = 0, + writes: usize = 0, + read_bytes: usize = 0, + written_bytes: usize = 0, +}; + +pub const Header = struct { + roots: [4]RecordId = @splat(0), + next: RecordId = 1, + free: RecordId = 0, + sequence: u64 = 0, + /// Store-owned accounting, allocation generation, and reconciliation data. + user: [16]u64 = @splat(0), + + fn encode(self: Header) Record { + var bytes: Record = @splat(0); + @memcpy(bytes[8..16], magic); + for (self.roots, 0..) |root, i| put(u64, &bytes, 16 + i * 8, root); + put(u64, &bytes, 48, self.next); + put(u64, &bytes, 56, self.free); + put(u64, &bytes, 64, self.sequence); + for (self.user, 0..) |value, i| put(u64, &bytes, 72 + i * 8, value); + seal(&bytes); + return bytes; + } + + fn decode(bytes: *const Record) Error!Header { + if (!valid(bytes) or !std.mem.eql(u8, bytes[8..16], magic)) return error.CorruptCatalog; + var result = Header{}; + for (&result.roots, 0..) |*root, i| root.* = get(u64, bytes, 16 + i * 8); + result.next = get(u64, bytes, 48); + result.free = get(u64, bytes, 56); + result.sequence = get(u64, bytes, 64); + for (&result.user, 0..) |*value, i| value.* = get(u64, bytes, 72 + i * 8); + if (result.next == 0 or result.next > std.math.maxInt(u64) / record_size) return error.CorruptCatalog; + if (result.free >= result.next) return error.CorruptCatalog; + for (result.roots) |root| if (root >= result.next) return error.CorruptCatalog; + return result; + } +}; + +pub const Node = struct { + pub const Kind = enum(u8) { free, leaf, branch }; + kind: Kind = .free, + bit: u16 = 0, + children: [2]RecordId = @splat(0), + key: Key = @splat(0), + value: Value = @splat(0), + + fn encode(self: Node) Record { + var bytes: Record = @splat(0); + bytes[8] = @backingInt(self.kind); + put(u16, &bytes, 10, self.bit); + for (self.children, 0..) |child, i| put(u64, &bytes, 16 + i * 8, child); + @memcpy(bytes[32..96], &self.key); + @memcpy(bytes[96..480], &self.value); + seal(&bytes); + return bytes; + } + + fn decode(bytes: *const Record, next: RecordId) Error!Node { + if (!valid(bytes)) return error.CorruptCatalog; + const kind = std.enums.fromInt(Kind, bytes[8]) orelse return error.CorruptCatalog; + const result: Node = .{ + .kind = kind, + .bit = get(u16, bytes, 10), + .children = .{ get(u64, bytes, 16), get(u64, bytes, 24) }, + .key = bytes[32..96].*, + .value = bytes[96..480].*, + }; + for (result.children) |child| if (child >= next) return error.CorruptCatalog; + if (kind == .branch and (result.bit >= key_bits or result.children[0] == 0 or + result.children[1] == 0 or result.children[0] == result.children[1])) return error.CorruptCatalog; + return result; + } +}; + +pub const Index = struct { + dir: Dir, + io: Io, + file: Io.File, + header: Header, + counters: *Counters, + + /// `dir` and the shard lock are borrowed. The lock file is separate from + /// both files that this protocol replaces or truncates. + pub fn open(dir: Dir, io: Io, counters: *Counters) Error!Index { + const file = dir.createFile(io, "catalog", .{ .read = true, .truncate = false }) catch return error.IoFailure; + errdefer file.close(io); + var result: Index = .{ .dir = dir, .io = io, .file = file, .header = .{}, .counters = counters }; + try result.recover(); + const len = file.length(io) catch return error.IoFailure; + if (len == 0) { + try result.writeRecord(0, (Header{}).encode()); + } + result.header = try Header.decode(&try result.readRecord(0)); + if ((file.length(io) catch return error.IoFailure) < result.header.next * record_size) return error.CorruptCatalog; + return result; + } + + pub fn close(self: *Index) void { + self.file.close(self.io); + } + + fn readRecord(self: *Index, id: RecordId) Error!Record { + if (id > std.math.maxInt(u64) / record_size) return error.CorruptCatalog; + var bytes: Record = undefined; + self.counters.reads += 1; + self.counters.read_bytes += record_size; + const len = self.file.readPositionalAll(self.io, &bytes, id * record_size) catch return error.IoFailure; + if (len != bytes.len) return error.CorruptCatalog; + return bytes; + } + + fn writeRecord(self: *Index, id: RecordId, bytes: Record) Error!void { + if (id > std.math.maxInt(u64) / record_size) return error.CorruptCatalog; + self.counters.writes += 1; + self.counters.written_bytes += record_size; + self.file.writePositionalAll(self.io, &bytes, id * record_size) catch return error.IoFailure; + } + + pub fn node(self: *Index, id: RecordId) Error!Node { + if (id == 0 or id >= self.header.next) return error.CorruptCatalog; + return Node.decode(&try self.readRecord(id), self.header.next); + } + + pub fn transaction(self: *Index) Transaction { + return .{ .index = self, .header = self.header }; + } + + /// A redo record is validated in full before applying any of its writes. + /// Recovery work is bounded even for a hostile length or record count. + fn recover(self: *Index) Error!void { + const redo = self.dir.openFile(self.io, "transaction", .{}) catch |err| switch (err) { + error.FileNotFound => return, + else => return error.IoFailure, + }; + defer redo.close(self.io); + var bytes: [redo_size]u8 = undefined; + self.counters.reads += 1; + const len = redo.readPositionalAll(self.io, &bytes, 0) catch return error.IoFailure; + self.counters.read_bytes += len; + if (len < 24 + record_size or !std.mem.eql(u8, bytes[8..16], transaction_magic)) return error.CorruptCatalog; + const count = get(u64, &bytes, 16); + if (count > max_changes or len != 24 + record_size + count * (8 + record_size)) return error.CorruptCatalog; + if (get(u64, &bytes, 0) != std.hash.Wyhash.hash(0, bytes[8..len])) return error.CorruptCatalog; + const header = try Header.decode(bytes[24..][0..record_size]); + var ids: [max_changes]u64 = undefined; + for (0..@intCast(count)) |i| { + const offset = 24 + record_size + i * (8 + record_size); + const id = get(u64, &bytes, offset); + if (id == 0 or id >= header.next) return error.CorruptCatalog; + for (ids[0..i]) |previous| if (previous == id) return error.CorruptCatalog; + ids[i] = id; + _ = try Node.decode(bytes[offset + 8 ..][0..record_size], header.next); + } + for (ids[0..@intCast(count)], 0..) |id, i| { + const offset = 24 + record_size + i * (8 + record_size) + 8; + try self.writeRecord(id, bytes[offset..][0..record_size].*); + } + // Header last: an interrupted replay still has its complete redo. + try self.writeRecord(0, header.encode()); + self.dir.deleteFile(self.io, "transaction") catch return error.IoFailure; + } +}; + +pub const Transaction = struct { + pub const Entry = struct { id: RecordId, key: Key, value: Value }; + index: *Index, + header: Header, + changes: [max_changes]struct { id: RecordId, node: Node } = undefined, + count: usize = 0, + + pub fn node(self: *Transaction, id: RecordId) Error!Node { + if (id == 0 or id >= self.header.next) return error.CorruptCatalog; + for (self.changes[0..self.count]) |change| if (change.id == id) return change.node; + return self.index.node(id); + } + + fn set(self: *Transaction, id: RecordId, value: Node) void { + for (self.changes[0..self.count]) |*change| if (change.id == id) { + change.node = value; + return; + }; + // Store operations change a fixed number of records, independent of + // tree depth. Exceeding that declared transaction bound is a bug. + std.debug.assert(self.count < max_changes); + self.changes[self.count] = .{ .id = id, .node = value }; + self.count += 1; + } + + fn allocate(self: *Transaction, value: Node) Error!RecordId { + const id = if (self.header.free != 0) blk: { + const id = self.header.free; + const old = try self.node(id); + if (old.kind != .free or old.children[0] == id) return error.CorruptCatalog; + self.header.free = old.children[0]; + break :blk id; + } else blk: { + if (self.header.next == std.math.maxInt(u64) / record_size) return error.CapacityExceeded; + const id = self.header.next; + self.header.next += 1; + break :blk id; + }; + self.set(id, value); + return id; + } + + fn release(self: *Transaction, id: RecordId) Error!void { + self.set(id, .{ .children = .{ self.header.free, 0 } }); + self.header.free = id; + } + + pub fn find(self: *Transaction, tree: Tree, key: Key) Error!?struct { id: RecordId, value: Value } { + var id = self.header.roots[@backingInt(tree)]; + var lower_bit: usize = 0; + while (id != 0) { + const entry = try self.node(id); + switch (entry.kind) { + .leaf => return if (std.mem.eql(u8, &entry.key, &key)) .{ .id = id, .value = entry.value } else null, + .free => return error.CorruptCatalog, + .branch => { + if (entry.bit < lower_bit) return error.CorruptCatalog; + lower_bit = @as(usize, entry.bit) + 1; + id = entry.children[direction(key, entry.bit)]; + }, + } + } + return null; + } + + /// The first leaf at or after a key. A persisted cursor stores that key, + /// never the branch positions, which insertion and deletion can change. + pub fn lowerBound(self: *Transaction, tree: Tree, key: Key) Error!?Entry { + var id = self.header.roots[@backingInt(tree)]; + var alternatives: [key_bits]RecordId = undefined; + var count: usize = 0; + var lower_bit: usize = 0; + while (id != 0) { + const entry = try self.node(id); + switch (entry.kind) { + .free => return error.CorruptCatalog, + .leaf => { + if (std.mem.order(u8, &entry.key, &key) != .lt) return .{ .id = id, .key = entry.key, .value = entry.value }; + if (count == 0) return null; + count -= 1; + id = alternatives[count]; + return self.minimum(id); + }, + .branch => { + if (entry.bit < lower_bit) return error.CorruptCatalog; + // Crit-bit nodes omit common key bits. Compare that + // prefix before selecting a child for an absent key. + if (!std.mem.eql(u8, &entry.key, &key) and firstDifference(entry.key, key) < entry.bit) { + if (std.mem.order(u8, &entry.key, &key) == .gt) return self.minimum(id); + if (count == 0) return null; + count -= 1; + return self.minimum(alternatives[count]); + } + lower_bit = @as(usize, entry.bit) + 1; + const side = direction(key, entry.bit); + if (side == 0) { + alternatives[count] = entry.children[1]; + count += 1; + } + id = entry.children[side]; + }, + } + } + return null; + } + + fn minimum(self: *Transaction, start: RecordId) Error!?Entry { + var id = start; + var lower_bit: usize = 0; + while (id != 0) { + const entry = try self.node(id); + switch (entry.kind) { + .free => return error.CorruptCatalog, + .leaf => return .{ .id = id, .key = entry.key, .value = entry.value }, + .branch => { + if (entry.bit < lower_bit) return error.CorruptCatalog; + lower_bit = @as(usize, entry.bit) + 1; + id = entry.children[0]; + }, + } + } + return null; + } + + pub fn putValue(self: *Transaction, tree: Tree, key: Key, value: Value) Error!RecordId { + const root = &self.header.roots[@backingInt(tree)]; + if (root.* == 0) { + root.* = try self.allocate(.{ .kind = .leaf, .key = key, .value = value }); + return root.*; + } + var id = root.*; + var lower_bit: usize = 0; + var old: Node = undefined; + while (true) { + old = try self.node(id); + if (old.kind == .leaf) break; + if (old.kind != .branch or old.bit < lower_bit) return error.CorruptCatalog; + lower_bit = @as(usize, old.bit) + 1; + id = old.children[direction(key, old.bit)]; + } + if (std.mem.eql(u8, &old.key, &key)) { + old.value = value; + self.set(id, old); + return id; + } + const bit = firstDifference(old.key, key); + var parent: RecordId = 0; + var side: u1 = 0; + id = root.*; + lower_bit = 0; + while (true) { + const entry = try self.node(id); + if (entry.kind == .leaf or (entry.kind == .branch and entry.bit >= bit)) break; + if (entry.kind != .branch or entry.bit < lower_bit) return error.CorruptCatalog; + lower_bit = @as(usize, entry.bit) + 1; + parent = id; + side = direction(key, entry.bit); + id = entry.children[side]; + } + const leaf = try self.allocate(.{ .kind = .leaf, .key = key, .value = value }); + var branch: Node = .{ .kind = .branch, .bit = bit, .key = key }; + const new_side = direction(key, bit); + branch.children[new_side] = leaf; + branch.children[new_side ^ 1] = id; + const branch_id = try self.allocate(branch); + if (parent == 0) root.* = branch_id else { + var entry = try self.node(parent); + entry.children[side] = branch_id; + self.set(parent, entry); + } + return leaf; + } + + pub fn remove(self: *Transaction, tree: Tree, key: Key) Error!bool { + const root = &self.header.roots[@backingInt(tree)]; + var id = root.*; + var parent: RecordId = 0; + var grandparent: RecordId = 0; + var parent_side: u1 = 0; + var side: u1 = 0; + var lower_bit: usize = 0; + while (id != 0) { + const entry = try self.node(id); + if (entry.kind == .leaf) { + if (!std.mem.eql(u8, &entry.key, &key)) return false; + if (parent == 0) root.* = 0 else { + const branch = try self.node(parent); + const sibling = branch.children[side ^ 1]; + if (grandparent == 0) root.* = sibling else { + var grand = try self.node(grandparent); + grand.children[parent_side] = sibling; + self.set(grandparent, grand); + } + try self.release(parent); + } + try self.release(id); + return true; + } + if (entry.kind != .branch or entry.bit < lower_bit) return error.CorruptCatalog; + lower_bit = @as(usize, entry.bit) + 1; + grandparent = parent; + parent_side = side; + parent = id; + side = direction(key, entry.bit); + id = entry.children[side]; + } + return false; + } + + /// Publish redo separately so fault-injection tests can terminate between + /// publication, individual record writes, and checkpoint completion. + pub fn prepare(self: *Transaction) Error!void { + if (self.header.sequence == std.math.maxInt(u64)) return error.CapacityExceeded; + self.header.sequence += 1; + var bytes: [redo_size]u8 = @splat(0); + @memcpy(bytes[8..16], transaction_magic); + put(u64, &bytes, 16, self.count); + @memcpy(bytes[24..][0..record_size], &self.header.encode()); + for (self.changes[0..self.count], 0..) |change, i| { + const offset = 24 + record_size + i * (8 + record_size); + put(u64, &bytes, offset, change.id); + @memcpy(bytes[offset + 8 ..][0..record_size], &change.node.encode()); + } + const len = 24 + record_size + self.count * (8 + record_size); + put(u64, &bytes, 0, std.hash.Wyhash.hash(0, bytes[8..len])); + self.index.counters.writes += 1; + self.index.counters.written_bytes += len; + self.index.dir.writeFile(self.index.io, .{ .sub_path = "transaction.tmp", .data = bytes[0..len] }) catch return error.IoFailure; + self.index.dir.rename("transaction.tmp", self.index.dir, "transaction", self.index.io) catch return error.IoFailure; + } + + pub fn commit(self: *Transaction) Error!void { + try self.prepare(); + try self.index.recover(); + self.index.header = self.header; + } +}; + +fn direction(key: Key, bit: u16) u1 { + return @truncate(key[bit / 8] >> @as(u3, @intCast(7 - bit % 8))); +} + +fn firstDifference(a: Key, b: Key) u16 { + for (a, b, 0..) |x, y, i| if (x != y) return @intCast(i * 8 + @clz(x ^ y)); + unreachable; // putValue handles equal keys before finding their first differing bit +} + +pub fn put(comptime T: type, bytes: []u8, offset: usize, value: T) void { + std.mem.writeInt(T, bytes[offset..][0..@sizeOf(T)], value, .little); +} + +pub fn get(comptime T: type, bytes: []const u8, offset: usize) T { + return std.mem.readInt(T, bytes[offset..][0..@sizeOf(T)], .little); +} + +fn seal(bytes: *Record) void { + put(u64, bytes, 0, std.hash.Wyhash.hash(0, bytes[8..])); +} + +fn valid(bytes: *const Record) bool { + return get(u64, bytes, 0) == std.hash.Wyhash.hash(0, bytes[8..]); +} + +test "catalog transactions recover every partial application and reuse freed slots" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var counters = Counters{}; + var index = try Index.open(tmp.dir, io, &counters); + defer index.close(); + var keys: [40]Key = undefined; + for (&keys, 0..) |*key, i| { + key.* = @splat(0); + std.mem.writeInt(u64, key[0..8], i * 37, .big); + var tx = index.transaction(); + _ = try tx.putValue(.artifacts, key.*, @splat(@intCast(i))); + // Leave a durable intent and an arbitrary prefix of applied pages. + try tx.prepare(); + for (tx.changes[0 .. i % (tx.count + 1)]) |change| try index.writeRecord(change.id, change.node.encode()); + try index.recover(); + index.header = try Header.decode(&try index.readRecord(0)); + } + const high_water = index.header.next; + for (keys, 0..) |key, i| { + var tx = index.transaction(); + const found = (try tx.find(.artifacts, key)).?; + try std.testing.expectEqual(@as(Value, @splat(@intCast(i))), found.value); + try std.testing.expect(try tx.remove(.artifacts, key)); + try tx.commit(); + } + for (keys) |key| { + var tx = index.transaction(); + _ = try tx.putValue(.artifacts, key, @splat(1)); + try tx.commit(); + } + try std.testing.expectEqual(high_water, index.header.next); + var tx = index.transaction(); + for (keys) |key| { + const before = counters.reads; + const found = (try tx.lowerBound(.artifacts, key)).?; + try std.testing.expectEqual(key, found.key); + try std.testing.expect(counters.reads - before <= key_bits * 2 + 2); + } + for (0..40 * 37) |number| { + var key: Key = @splat(0); + std.mem.writeInt(u64, key[0..8], number, .big); + const found = try tx.lowerBound(.artifacts, key); + const expected = (number + 36) / 37; + if (expected >= keys.len) try std.testing.expect(found == null) else try std.testing.expectEqual(keys[expected], found.?.key); + } +} + +test "corrupt redo is rejected before writing catalog pages" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var counters = Counters{}; + var index = try Index.open(tmp.dir, std.testing.io, &counters); + defer index.close(); + var tx = index.transaction(); + _ = try tx.putValue(.artifacts, @splat(1), @splat(2)); + try tx.prepare(); + const redo = try tmp.dir.openFile(std.testing.io, "transaction", .{ .mode = .read_write }); + defer redo.close(std.testing.io); + try redo.writePositionalAll(std.testing.io, "bad", 0); + const writes = counters.writes; + try std.testing.expectError(error.CorruptCatalog, index.recover()); + try std.testing.expectEqual(writes, counters.writes); +} diff --git a/src/compile/cache_maintenance.zig b/src/compile/cache_maintenance.zig new file mode 100644 index 00000000000..18b71eb589d --- /dev/null +++ b/src/compile/cache_maintenance.zig @@ -0,0 +1,578 @@ +//! Resumable cache maintenance. One owner and one fixed-size checkpoint cover +//! all shards and compiler compatibility identities in a shared store. + +const std = @import("std"); +const artifacts = @import("artifact_store.zig"); +const catalog = artifacts.catalog; +const Io = std.Io; +const Dir = Io.Dir; +const put = catalog.put; +const get = catalog.get; + +pub const Limits = struct { object_pack_format_version: u32, max_size_bytes: u64 }; +pub const Stats = struct { + units: usize = 0, + records: usize = 0, + metadata: usize = 0, + deletions: usize = 0, + errors: usize = 0, + io: catalog.Counters = .{}, +}; + +pub const Policy = struct { + pub const interval: i64 = 5 * artifacts.minute_ns; + pub const overdue: i64 = artifacts.minute_ns; + pub const grant_interval: i64 = std.time.ns_per_s; + pub const ordinary_units: usize = 1; + pub const pressure_units: usize = 8; +}; + +pub const Admission = enum { optional, compulsory }; +const Phase = enum(u8) { boundaries, age, candidates, evict }; +const state_size = 2048; + +pub const Checkpoint = struct { + due: i64 = 0, + next_grant: i64 = 0, + phase: Phase = .boundaries, + shard: u8 = 0, + generation: u64 = 0, + boundaries: [artifacts.shard_count]u64 = @splat(0), + bytes: [artifacts.shard_count]u64 = @splat(0), + candidates: [artifacts.shard_count]?catalog.Key = @splat(null), + + fn oversized(self: *const Checkpoint, limit: u64) bool { + var total: u64 = 0; + for (self.bytes) |size| total +|= size; + return total > limit; + } + + fn encode(self: *const Checkpoint) [state_size]u8 { + var bytes: [state_size]u8 = @splat(0); + @memcpy(bytes[8..16], "ROCMAI01"); + put(i64, &bytes, 16, self.due); + put(i64, &bytes, 24, self.next_grant); + bytes[32] = @backingInt(self.phase); + bytes[33] = self.shard; + put(u64, &bytes, 40, self.generation); + for (0..artifacts.shard_count) |i| { + const offset = 48 + i * 88; + put(u64, &bytes, offset, self.boundaries[i]); + put(u64, &bytes, offset + 8, self.bytes[i]); + if (self.candidates[i]) |key| { + bytes[offset + 16] = 1; + @memcpy(bytes[offset + 24 ..][0..64], &key); + } + } + put(u64, &bytes, 0, std.hash.Wyhash.hash(0, bytes[8..])); + return bytes; + } + + fn decode(bytes: *const [state_size]u8) ?Checkpoint { + if (!std.mem.eql(u8, bytes[8..16], "ROCMAI01") or get(u64, bytes, 0) != std.hash.Wyhash.hash(0, bytes[8..])) return null; + var state: Checkpoint = .{ + .due = get(i64, bytes, 16), + .next_grant = get(i64, bytes, 24), + .phase = std.enums.fromInt(Phase, bytes[32]) orelse return null, + .shard = bytes[33], + .generation = get(u64, bytes, 40), + }; + if (state.shard >= artifacts.shard_count) return null; + for (0..artifacts.shard_count) |i| { + const offset = 48 + i * 88; + state.boundaries[i] = get(u64, bytes, offset); + state.bytes[i] = get(u64, bytes, offset + 8); + if (bytes[offset + 16] > 1) return null; + if (bytes[offset + 16] == 1) state.candidates[i] = bytes[offset + 24 ..][0..64].*; + } + return state; + } + + fn load(root: Dir, io: Io) artifacts.Error!Checkpoint { + const file = root.openFile(io, "maintenance", .{}) catch |err| switch (err) { + error.FileNotFound => return .{}, + else => return error.IoFailure, + }; + defer file.close(io); + var bytes: [state_size]u8 = undefined; + const len = file.readPositionalAll(io, &bytes, 0) catch return error.IoFailure; + // The cursor is disposable. Artifact metadata and live ownership are + // revalidated independently before every deletion. + return if (len == state_size) decode(&bytes) orelse .{} else .{}; + } + + fn save(self: *const Checkpoint, root: Dir, io: Io) artifacts.Error!void { + const bytes = self.encode(); + root.writeFile(io, .{ .sub_path = "maintenance.tmp", .data = &bytes }) catch return error.IoFailure; + root.rename("maintenance.tmp", root, "maintenance", io) catch return error.IoFailure; + } + + fn nextShard(self: *Checkpoint, next: Phase) void { + self.generation = 0; + if (self.shard == artifacts.shard_count - 1) { + self.shard = 0; + self.phase = next; + } else self.shard += 1; + } +}; + +/// The admission caller provides explicit capacity. This function never polls +/// a compiler queue or guesses whether the machine is idle. A compulsory grant +/// is global to the root, not independently renewed by every parallel build. +pub fn run(store: *const artifacts.Store, limits: Limits, now: i64, admission: Admission, stats: *Stats) void { + var root = store.openRoot(false) catch return; + defer root.close(store.io); + const owner = root.createFile(store.io, "maintenance.lock", .{ + .read = true, + .truncate = false, + .lock = .exclusive, + .lock_nonblocking = true, + }) catch return; + defer owner.close(store.io); + var checkpoint = Checkpoint.load(root, store.io) catch { + stats.errors += 1; + return; + }; + // Detect a checkpoint from a clock that has moved backwards. The only + // valid future deadlines are one interval/grant ahead of the last writer. + // Reset traversal hints; artifact metadata still decides eligibility. + if (checkpoint.due > now +| Policy.interval or checkpoint.next_grant > now +| Policy.grant_interval) checkpoint = .{}; + const pressure = checkpoint.oversized(limits.max_size_bytes); + if (!pressure and now < checkpoint.due) return; + if (admission == .compulsory) { + if (now < checkpoint.next_grant) return; + if (!pressure and checkpoint.due != 0 and @as(i128, now) - checkpoint.due < Policy.overdue) return; + checkpoint.next_grant = now +| Policy.grant_interval; + } + const allowance = if (admission == .optional) 1 else if (pressure) Policy.pressure_units else Policy.ordinary_units; + for (0..allowance) |_| { + step(store, limits, now, &checkpoint, stats) catch { + stats.errors += 1; + // Do not retry a failing filesystem operation in this invocation. + return; + }; + checkpoint.save(root, store.io) catch { + stats.errors += 1; + return; + }; + stats.units += 1; + if (!checkpoint.oversized(limits.max_size_bytes) and now < checkpoint.due) break; + } +} + +fn shardBytes(shard: *const artifacts.Shard) u64 { + return shard.index.header.user[1] +| shard.index.header.user[2] +| shard.index.header.user[3]; +} + +fn step(store: *const artifacts.Store, limits: Limits, now: i64, checkpoint: *Checkpoint, stats: *Stats) artifacts.Error!void { + if (checkpoint.phase == .evict) return evict(store, limits, now, checkpoint, stats); + const number: u4 = @intCast(checkpoint.shard); + var shard = artifacts.Shard.open(store, number, false, &stats.io) catch |err| { + if (err != error.Missing and err != error.Busy) stats.errors += 1; + if (err == error.Missing) { + checkpoint.bytes[number] = 0; + checkpoint.boundaries[number] = 0; + } + checkpoint.candidates[number] = null; + checkpoint.nextShard(switch (checkpoint.phase) { + .boundaries => .age, + .age => .candidates, + .candidates => .evict, + .evict => unreachable, // evict returns before opening a traversal shard + }); + return; + }; + defer shard.close(); + checkpoint.bytes[number] = shardBytes(&shard); + switch (checkpoint.phase) { + .boundaries => { + checkpoint.boundaries[number] = shard.index.header.user[0]; + checkpoint.nextShard(.age); + }, + .age => { + var tx = shard.index.transaction(); + const entry = (try tx.lowerBound(.owners, artifacts.storageKey(checkpoint.generation))) orelse { + checkpoint.nextShard(.candidates); + return; + }; + const metadata = try artifacts.Metadata.decode(entry.value); + if (metadata.generation > checkpoint.boundaries[number]) { + checkpoint.nextShard(.candidates); + return; + } + if (metadata.generation == std.math.maxInt(u64)) return error.CapacityExceeded; + checkpoint.generation = metadata.generation + 1; + stats.records += 1; + var missing = false; + if (metadata.state == .published and metadata.family != .scratch) { + var stem_buffer: [128]u8 = undefined; + const stem = try artifacts.slotStem(&stem_buffer, metadata.slot); + var buffer: [140]u8 = undefined; + const path = std.fmt.bufPrint(&buffer, "{s}.data", .{stem}) catch return error.PathTooLong; + stats.metadata += 1; + _ = shard.dir.statFile(store.io, path, .{}) catch |err| switch (err) { + error.FileNotFound => blk: { + missing = true; + break :blk null; + }, + else => return error.IoFailure, + }; + } + if (metadata.state == .retiring or missing or metadata.expired(now, limits.object_pack_format_version)) { + if (shard.retire(metadata.key, metadata.generation)) |removed| { + if (removed) stats.deletions += 1; + if (!removed and metadata.family == .scratch) checkpoint.generation = metadata.generation; + } else |err| switch (err) { + error.Busy => {}, + else => stats.errors += 1, + } + checkpoint.bytes[number] = shardBytes(&shard); + } + }, + .candidates => { + var tx = shard.index.transaction(); + const candidate = try tx.lowerBound(.recency, @splat(0)); + checkpoint.candidates[number] = if (candidate) |entry| entry.key else null; + checkpoint.nextShard(.evict); + }, + .evict => unreachable, // evict returns before opening a traversal shard + } +} + +fn evict(store: *const artifacts.Store, limits: Limits, now: i64, checkpoint: *Checkpoint, stats: *Stats) artifacts.Error!void { + var selected: ?u4 = null; + if (checkpoint.oversized(limits.max_size_bytes)) { + for (checkpoint.candidates, 0..) |candidate, number| { + const key = candidate orelse continue; + if (selected == null or std.mem.order(u8, &key, &checkpoint.candidates[selected.?].?) == .lt) selected = @intCast(number); + } + } + const number = selected orelse { + checkpoint.phase = .boundaries; + checkpoint.shard = 0; + checkpoint.generation = 0; + checkpoint.due = now +| Policy.interval; + return; + }; + var shard = artifacts.Shard.open(store, number, false, &stats.io) catch |err| { + checkpoint.candidates[number] = null; + if (err == error.Missing) checkpoint.bytes[number] = 0 else if (err != error.Busy) stats.errors += 1; + return; + }; + defer shard.close(); + checkpoint.bytes[number] = shardBytes(&shard); + var tx = shard.index.transaction(); + const oldest = (try tx.lowerBound(.recency, checkpoint.candidates[number].?)) orelse { + checkpoint.candidates[number] = null; + return; + }; + // Acquisitions can move a candidate forward since the candidate pass. + // Refresh that shard's lower bound before deciding to evict it. + if (!std.mem.eql(u8, &oldest.key, &checkpoint.candidates[number].?)) { + checkpoint.candidates[number] = oldest.key; + return; + } + const key = oldest.value[0..64].*; + const entry = (try tx.find(.artifacts, key)) orelse return error.CorruptCatalog; + const metadata = try artifacts.Metadata.decode(entry.value); + if (shard.retire(key, metadata.generation)) |removed| { + if (removed) stats.deletions += 1; + checkpoint.bytes[number] = shardBytes(&shard); + var refreshed = shard.index.transaction(); + const next = try refreshed.lowerBound(.recency, @splat(0)); + checkpoint.candidates[number] = if (next) |item| item.key else null; + } else |err| switch (err) { + error.Busy => { + var after = oldest.key; + checkpoint.candidates[number] = if (artifacts.advanceKey(&after)) after else null; + }, + else => { + stats.errors += 1; + checkpoint.candidates[number] = null; + }, + } +} + +const Fixture = struct { + tmp: std.testing.TmpDir, + store: artifacts.Store, + + fn init() !Fixture { + var tmp = std.testing.tmpDir(.{}); + errdefer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(std.testing.io, ".", std.testing.allocator); + defer std.testing.allocator.free(root); + return .{ .tmp = tmp, .store = try artifacts.Store.init(std.testing.allocator, std.testing.io, root) }; + } + + fn deinit(self: *Fixture) void { + self.store.deinit(); + self.tmp.cleanup(); + } + + fn identity(number: u32) artifacts.Identity { + var key: [32]u8 = @splat(0); + std.mem.writeInt(u32, key[0..4], number, .little); + return .{ .namespace = @splat(1), .family = .checked, .key = key }; + } +}; + +test "short saturated invocations checkpoint bounded units and reach the end of a large shard" { + var fixture = try Fixture.init(); + defer fixture.deinit(); + for (0..150) |i| try fixture.store.write(Fixture.identity(@intCast(i * artifacts.shard_count)), "old", 1); + var now: i64 = 31 * artifacts.day_ns; + var removed: usize = 0; + for (0..350) |_| { + var stats = Stats{}; + run(&fixture.store, .{ .max_size_bytes = std.math.maxInt(u64), .object_pack_format_version = 1 }, now, .compulsory, &stats); + try std.testing.expect(stats.units <= 1); + try std.testing.expect(stats.records <= 1); + try std.testing.expect(stats.metadata <= 1); + try std.testing.expect(stats.deletions <= 1); + try std.testing.expectEqual(@as(usize, 0), stats.errors); + removed += stats.deletions; + now += Policy.grant_interval; + if (removed == 150) break; + } + try std.testing.expectEqual(@as(usize, 150), removed); +} + +test "not due does no catalog I/O and pressure increases the root allowance" { + var fixture = try Fixture.init(); + defer fixture.deinit(); + for (0..20) |i| try fixture.store.write(Fixture.identity(@intCast(i * artifacts.shard_count)), "data", 1); + var root = try fixture.store.openRoot(false); + defer root.close(std.testing.io); + var checkpoint: Checkpoint = .{ .due = Policy.interval }; + try checkpoint.save(root, std.testing.io); + var stats = Stats{}; + run(&fixture.store, .{ .max_size_bytes = 1, .object_pack_format_version = 1 }, 10, .compulsory, &stats); + try std.testing.expectEqual(@as(usize, 0), stats.units); + try std.testing.expectEqual(@as(usize, 0), stats.io.reads); + checkpoint.due = 0; + checkpoint.bytes[0] = 80; + try checkpoint.save(root, std.testing.io); + var removed: usize = 0; + for (0..60) |i| { + stats = .{}; + run(&fixture.store, .{ .max_size_bytes = 4, .object_pack_format_version = 1 }, @intCast(i * Policy.grant_interval), .compulsory, &stats); + if (i == 0) try std.testing.expectEqual(Policy.pressure_units, stats.units); + try std.testing.expectEqual(@as(usize, 0), stats.errors); + removed += stats.deletions; + if (removed == 19) break; + } + try std.testing.expectEqual(@as(usize, 19), removed); +} + +test "corrupt checkpoint cannot authorize deleting live staging" { + var fixture = try Fixture.init(); + defer fixture.deinit(); + var staging = try fixture.store.reserve(Fixture.identity(0), 4, 1); + defer staging.deinit(); + try Dir.cwd().writeFile(std.testing.io, .{ .sub_path = staging.path, .data = "live" }); + var root = try fixture.store.openRoot(false); + defer root.close(std.testing.io); + try root.writeFile(std.testing.io, .{ .sub_path = "maintenance", .data = "truncated cursor" }); + for (0..100) |i| { + var stats = Stats{}; + run(&fixture.store, .{ .max_size_bytes = 1, .object_pack_format_version = 1 }, 31 * artifacts.day_ns + @as(i64, @intCast(i)) * Policy.grant_interval, .compulsory, &stats); + try std.testing.expectEqual(@as(usize, 0), stats.deletions); + } + try Dir.cwd().access(std.testing.io, staging.path, .{}); +} + +test "large abandoned scratch traversal resumes after lost and corrupt checkpoints" { + var fixture = try Fixture.init(); + defer fixture.deinit(); + const allocator = std.testing.allocator; + const io = std.testing.io; + const root_path = try fixture.tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root_path); + var scratch = try artifacts.Scratch.create(allocator, io, root_path, @splat(9)); + const path = try allocator.dupe(u8, scratch.path()); + defer allocator.free(path); + var dir = try Dir.cwd().openDir(io, path, .{}); + try dir.createDirPath(io, "nested/deeper"); + for (0..80) |i| { + var buffer: [64]u8 = undefined; + const name = try std.fmt.bufPrint(&buffer, "nested/deeper/{d}", .{i}); + try dir.writeFile(io, .{ .sub_path = name, .data = "scratch" }); + } + dir.close(io); + const key = scratch.staging.key; + const generation = scratch.staging.generation; + const slot = scratch.staging.slot; + scratch.deinit(); + var counters = artifacts.catalog.Counters{}; + // Simulate termination after a deletion but before the root checkpoint: + // repeat the same logical generation; the per-scratch cursor resumes it. + var done = false; + for (0..150) |i| { + var shard = try artifacts.Shard.open(&fixture.store, artifacts.Store.shardFor(key), false, &counters); + defer shard.close(); + if (i == 30) { + var stem_buffer: [128]u8 = undefined; + const stem = try artifacts.slotStem(&stem_buffer, slot); + var name_buffer: [140]u8 = undefined; + const name = try std.fmt.bufPrint(&name_buffer, "{s}.cursor", .{stem}); + try shard.dir.writeFile(io, .{ .sub_path = name, .data = "corrupt" }); + } + if (try shard.retire(key, generation)) { + done = true; + break; + } + } + try std.testing.expect(done); + try std.testing.expectError(error.FileNotFound, Dir.cwd().access(io, path, .{})); +} + +test "missing payload reconciliation and obsolete namespaces use the same bounded traversal" { + var fixture = try Fixture.init(); + defer fixture.deinit(); + const io = std.testing.io; + var old = Fixture.identity(0); + old.family = .local_pack; + old.pack_format = 1; + var fresh = Fixture.identity(16); + fresh.family = .package_pack; + fresh.pack_format = 2; + fresh.namespace = @splat(99); + try fixture.store.write(old, "obsolete", 1); + try fixture.store.write(fresh, "fresh", 1); + { + var accepted = (try fixture.store.acquire(fresh, 100 * artifacts.day_ns)).?; + defer accepted.deinit(); + // External eviction is reconciled by checking one owned record. + try Dir.cwd().deleteFile(io, accepted.path); + } + var removed: usize = 0; + for (0..100) |i| { + var stats = Stats{}; + run(&fixture.store, .{ .max_size_bytes = std.math.maxInt(u64), .object_pack_format_version = 2 }, 101 * artifacts.day_ns + @as(i64, @intCast(i)) * Policy.grant_interval, .compulsory, &stats); + try std.testing.expect(stats.records <= 1); + try std.testing.expect(stats.metadata <= 1); + try std.testing.expect(stats.deletions <= 1); + try std.testing.expect(stats.io.reads < 20 * artifacts.catalog.key_bits + 100); + try std.testing.expect(stats.io.writes <= 2 * (artifacts.catalog.max_changes + 1)); + removed += stats.deletions; + } + try std.testing.expectEqual(@as(usize, 2), removed); + var counters = artifacts.catalog.Counters{}; + var shard = try artifacts.Shard.open(&fixture.store, 0, false, &counters); + defer shard.close(); + try std.testing.expectEqual(@as(u64, 0), shard.index.header.user[1]); +} + +test "maintenance ownership is shared across processes and released after abrupt death" { + if (comptime @import("builtin").os.tag == .windows) return error.SkipZigTest; + const C = struct { + extern "c" fn fork() std.c.pid_t; + extern "c" fn pipe(*[2]c_int) c_int; + extern "c" fn open([*:0]const u8, c_int, ...) c_int; + extern "c" fn flock(c_int, c_int) c_int; + extern "c" fn read(c_int, [*]u8, usize) isize; + extern "c" fn write(c_int, [*]const u8, usize) isize; + extern "c" fn close(c_int) c_int; + extern "c" fn pause() c_int; + extern "c" fn kill(std.c.pid_t, c_int) c_int; + extern "c" fn waitpid(std.c.pid_t, *c_int, c_int) std.c.pid_t; + extern "c" fn _exit(c_int) noreturn; + }; + var fixture = try Fixture.init(); + defer fixture.deinit(); + var root = try fixture.store.openRoot(true); + defer root.close(std.testing.io); + const file = try root.createFile(std.testing.io, "maintenance.lock", .{}); + file.close(std.testing.io); + const path = try std.fmt.allocPrintSentinel(std.testing.allocator, "{s}/maintenance.lock", .{fixture.store.root}, 0); + defer std.testing.allocator.free(path); + var pipe: [2]c_int = undefined; + try std.testing.expectEqual(@as(c_int, 0), C.pipe(&pipe)); + defer _ = C.close(pipe[0]); + var writer_closed = false; + defer if (!writer_closed) { + _ = C.close(pipe[1]); + }; + const pid = C.fork(); + if (pid == 0) { + // Only async-signal-safe libc/syscalls in the fork child; no Zig Io, + // allocator, or test harness state is used after fork. + const fd = C.open(path.ptr, 2); // O_RDWR on supported POSIX hosts + if (fd < 0 or C.flock(fd, std.posix.LOCK.EX | std.posix.LOCK.NB) != 0) C._exit(1); + _ = C.write(pipe[1], "1", 1); + _ = C.close(pipe[1]); + while (true) _ = C.pause(); + } + try std.testing.expect(pid > 0); + _ = C.close(pipe[1]); + writer_closed = true; + var waited = false; + defer if (!waited) { + _ = C.kill(pid, @intCast(@backingInt(std.posix.SIG.KILL))); + var status: c_int = 0; + _ = C.waitpid(pid, &status, 0); + }; + var ready: [1]u8 = undefined; + try std.testing.expectEqual(@as(isize, 1), C.read(pipe[0], &ready, 1)); + var stats = Stats{}; + const limits: Limits = .{ .max_size_bytes = 1000, .object_pack_format_version = 1 }; + run(&fixture.store, limits, 1, .compulsory, &stats); + try std.testing.expectEqual(@as(usize, 0), stats.units); + _ = C.kill(pid, @intCast(@backingInt(std.posix.SIG.KILL))); + var status: c_int = 0; + try std.testing.expectEqual(pid, C.waitpid(pid, &status, 0)); + waited = true; + run(&fixture.store, limits, 1, .compulsory, &stats); + try std.testing.expectEqual(@as(usize, 1), stats.units); +} + +test "a deletion whose checkpoint fails is safe to repeat" { + var fixture = try Fixture.init(); + defer fixture.deinit(); + try fixture.store.write(Fixture.identity(0), "expired", 1); + var root = try fixture.store.openRoot(false); + defer root.close(std.testing.io); + var checkpoint: Checkpoint = .{ .phase = .age }; + checkpoint.boundaries[0] = 1; + try checkpoint.save(root, std.testing.io); + try root.createDir(std.testing.io, "maintenance.tmp", .default_dir); + const limits: Limits = .{ .max_size_bytes = 100, .object_pack_format_version = 1 }; + var stats = Stats{}; + run(&fixture.store, limits, 31 * artifacts.day_ns, .compulsory, &stats); + try std.testing.expectEqual(@as(usize, 1), stats.deletions); + try std.testing.expectEqual(@as(usize, 1), stats.errors); + try std.testing.expectEqual(@as(usize, 0), stats.units); + try root.deleteDir(std.testing.io, "maintenance.tmp"); + stats = .{}; + run(&fixture.store, limits, 31 * artifacts.day_ns, .compulsory, &stats); + try std.testing.expectEqual(@as(usize, 0), stats.deletions); + try std.testing.expectEqual(@as(usize, 0), stats.errors); + try std.testing.expectEqual(@as(usize, 1), stats.units); +} + +test "allocation boundaries terminate passes and later passes cover concurrent insertion" { + var fixture = try Fixture.init(); + defer fixture.deinit(); + const now = 31 * artifacts.day_ns; + try fixture.store.write(Fixture.identity(0), "old generation", 1); + const limits: Limits = .{ .max_size_bytes = 1000, .object_pack_format_version = 1 }; + for (0..artifacts.shard_count) |_| { + var stats = Stats{}; + run(&fixture.store, limits, now, .optional, &stats); + try std.testing.expectEqual(@as(usize, 1), stats.units); + } + try fixture.store.write(Fixture.identity(0), "fresh replacement", now); + try fixture.store.write(Fixture.identity(16), "late expired insertion", 1); + var removed: usize = 0; + for (0..200) |i| { + var stats = Stats{}; + run(&fixture.store, limits, now + @as(i64, @intCast(i)) * artifacts.minute_ns, .compulsory, &stats); + removed += stats.deletions; + } + try std.testing.expectEqual(@as(usize, 2), removed); + try std.testing.expect((try fixture.store.acquire(Fixture.identity(16), now)) == null); + const fresh = (try fixture.store.read(std.testing.allocator, Fixture.identity(0), now)).?; + defer std.testing.allocator.free(fresh); + try std.testing.expectEqualStrings("fresh replacement", fresh); +} diff --git a/src/compile/cache_manager.zig b/src/compile/cache_manager.zig index acddd6d0337..8483b43a4bd 100644 --- a/src/compile/cache_manager.zig +++ b/src/compile/cache_manager.zig @@ -14,6 +14,7 @@ const Allocator = std.mem.Allocator; const CoreCtx = ctx_mod.CoreCtx; const CacheStats = @import("cache_config.zig").CacheStats; const CacheConfig = @import("cache_config.zig").CacheConfig; +const artifacts = @import("artifact_store.zig"); /// Public `CacheManager` declaration. pub const CacheManager = struct { @@ -105,390 +106,242 @@ pub const CacheManager = struct { self.stats.recordStoreFor(kind, bytes_written); } - pub fn computeCacheFilePath(self: *Self, cache_key: [32]u8, entries_dir: []const u8) Allocator.Error![]u8 { - return computeCacheFilePathIn(self.allocator, cache_key, entries_dir); + /// The caller's cache scope is explicit identity data. It is no longer a + /// directory that maintenance must discover by walking the filesystem. + pub fn identity(family: artifacts.Family, key: [32]u8, scope: []const u8) artifacts.Identity { + return .{ + .namespace = artifacts.hashBytes(@import("build_options").compiler_compatibility_id), + .family = family, + .group = artifacts.hashBytes(scope), + .key = key, + }; } - /// Build a cache entry's path using a caller-supplied allocator, so a worker - /// thread never allocates from the manager's own allocator. - pub fn computeCacheFilePathIn(allocator: Allocator, cache_key: [32]u8, entries_dir: []const u8) Allocator.Error![]u8 { - const subdir_buf = std.fmt.bytesToHex(cache_key[0..1], .lower); - - const filename_buf: [cache_file_name_len]u8 = std.fmt.bytesToHex(cache_key[1..32], .lower); - - return std.fs.path.join(allocator, &.{ entries_dir, &subdir_buf, &filename_buf }); + pub fn openStore(self: *const Self, allocator: Allocator) (Allocator.Error || error{NoHomeDirectory})!artifacts.Store { + const root = try self.config.getEffectiveCacheDir(allocator); + defer allocator.free(root); + return .{ .allocator = allocator, .io = self.roc_ctx.std_io, .root = try std.fs.path.join(allocator, &.{ root, artifacts.format_directory }) }; } - /// Hex spelling of the key bytes after the subdirectory byte. - const cache_file_name_len = 62; + fn familyFor(kind: Kind) artifacts.Family { + return switch (kind) { + .checked => .checked, + .canonicalized => .canonicalized, + }; + } - /// The subdirectory holding the entry at `cache_path`, a path built by - /// `computeCacheFilePathIn`. The file name has no separators, so the join - /// put exactly one separator before it. - fn cacheSubdirOf(cache_path: []const u8) []const u8 { - return cache_path[0 .. cache_path.len - cache_file_name_len - 1]; + pub fn storeRawBytes(self: *Self, key: [32]u8, bytes: []const u8, scope: []const u8, name: []const u8) void { + self.storeFamilyBytes(.checked, key, bytes, scope, name); } - pub fn storeRawBytes(self: *Self, cache_key: [32]u8, data: []const u8, entries_dir: []const u8, source_name: []const u8) void { - self.storeRawBytesIn(self.allocator, .checked, cache_key, data, entries_dir, source_name); + pub fn storeFamilyBytes(self: *Self, family: artifacts.Family, key: [32]u8, bytes: []const u8, scope: []const u8, name: []const u8) void { + self.storeEntry(self.allocator, .checked, family, key, .{ .bytes = bytes }, bytes.len, scope, name); } - /// Store one cache entry, allocating path scratch from `allocator` and - /// counting the operation against `kind`'s counters. The source name is - /// diagnostic context only; it does not contribute to the cache key. - pub fn storeRawBytesIn( - self: *Self, - allocator: Allocator, - kind: Kind, - cache_key: [32]u8, - data: []const u8, - entries_dir: []const u8, - source_name: []const u8, - ) void { - self.storeEntry(allocator, kind, cache_key, .{ .bytes = data }, data.len, entries_dir, source_name); + pub fn storeRawBytesIn(self: *Self, allocator: Allocator, kind: Kind, key: [32]u8, bytes: []const u8, scope: []const u8, name: []const u8) void { + self.storeEntry(allocator, kind, familyFor(kind), key, .{ .bytes = bytes }, bytes.len, scope, name); } - /// Atomically persist a known-size entry emitted incrementally by its producer. - /// The source name is diagnostic context only, as in `storeRawBytesIn`. - pub fn storeStream( - self: *Self, - cache_key: [32]u8, - stream: CoreCtx.FileStream, - size: usize, - entries_dir: []const u8, - source_name: []const u8, - ) void { - self.storeStreamIn(self.allocator, .checked, cache_key, stream, size, entries_dir, source_name); + pub fn storeStream(self: *Self, key: [32]u8, stream: CoreCtx.FileStream, size: usize, scope: []const u8, name: []const u8) void { + self.storeStreamIn(self.allocator, .checked, key, stream, size, scope, name); } - /// `storeStream`, allocating path scratch from `allocator` and counting the - /// operation against `kind`'s counters. - pub fn storeStreamIn( - self: *Self, - allocator: Allocator, - kind: Kind, - cache_key: [32]u8, - stream: CoreCtx.FileStream, - size: usize, - entries_dir: []const u8, - source_name: []const u8, - ) void { - self.storeEntry(allocator, kind, cache_key, .{ .stream = stream }, size, entries_dir, source_name); + pub fn storeStreamIn(self: *Self, allocator: Allocator, kind: Kind, key: [32]u8, stream: CoreCtx.FileStream, size: usize, scope: []const u8, name: []const u8) void { + self.storeEntry(allocator, kind, familyFor(kind), key, .{ .stream = stream }, size, scope, name); } const EntrySource = union(enum) { bytes: []const u8, stream: CoreCtx.FileStream }; - fn storeEntry( - self: *Self, - allocator: Allocator, - kind: Kind, - cache_key: [32]u8, - source: EntrySource, - size: usize, - entries_dir: []const u8, - source_name: []const u8, - ) void { + fn storeEntry(self: *Self, allocator: Allocator, kind: Kind, family: artifacts.Family, key: [32]u8, source: EntrySource, size: usize, scope: []const u8, name: []const u8) void { + if (comptime threading.is_freestanding) return; if (!self.config.enabled) return; - - const cache_path = computeCacheFilePathIn(allocator, cache_key, entries_dir) catch |err| { - self.verboseLog(allocator, "Failed to create {s} cache subdirectory in {s} for {s} (key {x}): {}\n", .{ @tagName(kind), entries_dir, source_name, cache_key, err }); + var store = self.openStore(allocator) catch { self.recordStoreFailureFor(kind); return; }; - defer allocator.free(cache_path); - - self.roc_ctx.makePath(cacheSubdirOf(cache_path)) catch |err| { - self.verboseLog(allocator, "Failed to create {s} cache subdirectory in {s} for {s} (key {x}): {}\n", .{ @tagName(kind), entries_dir, source_name, cache_key, err }); + defer store.deinit(); + var stage = store.reserve(identity(family, key, scope), size, store.now()) catch |err| { + if (err == error.Busy) return; + self.verboseLog(allocator, "Failed to reserve {s} cache entry for {s}: {}\n", .{ @tagName(kind), name, err }); self.recordStoreFailureFor(kind); return; }; - - // Concurrent compilers can publish the same key. Each writer needs its - // own staging file so one rename cannot remove another writer's input. - var suffix: [16]u8 = undefined; - self.roc_ctx.std_io.random(&suffix); - const temp_path = std.fmt.allocPrint(allocator, "{s}.{s}.tmp", .{ cache_path, std.fmt.bytesToHex(suffix, .lower) }) catch |err| { - self.verboseLog(allocator, "Failed to allocate cache staging path for {s}: {}\n", .{ source_name, err }); - self.recordStoreFailureFor(kind); - return; + defer stage.deinit(); + const result = switch (source) { + .bytes => |bytes| self.roc_ctx.writeFile(stage.path, bytes), + .stream => |stream| self.roc_ctx.writeFileStream(stage.path, stream), }; - defer allocator.free(temp_path); - - const write_result = switch (source) { - .bytes => |data| self.roc_ctx.writeFile(temp_path, data), - .stream => |stream| self.roc_ctx.writeFileStream(temp_path, stream), - }; - write_result catch |err| { - self.verboseLog(allocator, "Failed to write {s} cache temp file {s} for {s} ({d} bytes): {}\n", .{ @tagName(kind), temp_path, source_name, size, err }); - self.removeFailedTempFile(allocator, temp_path); + result catch |err| { + self.verboseLog(allocator, "Failed to write {s} cache entry for {s}: {}\n", .{ @tagName(kind), name, err }); self.recordStoreFailureFor(kind); return; }; - - self.roc_ctx.rename(temp_path, cache_path) catch |err| { - self.verboseLog(allocator, "Failed to rename {s} cache file {s} -> {s} for {s} ({d} bytes): {}\n", .{ @tagName(kind), temp_path, cache_path, source_name, size, err }); - self.removeFailedTempFile(allocator, temp_path); + stage.publish() catch |err| { + if (err == error.Busy) return; + self.verboseLog(allocator, "Failed to publish {s} cache entry for {s}: {}\n", .{ @tagName(kind), name, err }); self.recordStoreFailureFor(kind); return; }; - self.recordStoreFor(kind, size); } - /// One part of a checked cache entry, written to a staging file before the - /// rest of the entry exists. Nothing reads it until the entry is published. pub const StagedPart = struct { - /// Owned by the manager's allocator. - path: []u8, + store: *artifacts.Store, + stage: artifacts.Staging, len: usize, }; - /// Write `stream` to a staging file beside the checked entry for - /// `cache_key`, publishing nothing. Null when the cache is off or the write - /// failed; a failure counts as a store failure, since the entry it belonged - /// to can no longer be written. - pub fn stageCheckedPart( - self: *Self, - cache_key: [32]u8, - stream: CoreCtx.FileStream, - size: usize, - entries_dir: []const u8, - source_name: []const u8, - ) ?StagedPart { + pub fn stageCheckedPart(self: *Self, key: [32]u8, stream: CoreCtx.FileStream, size: usize, scope: []const u8, name: []const u8) ?StagedPart { + if (comptime threading.is_freestanding) return null; if (!self.config.enabled) return null; - const allocator = self.allocator; - const cache_path = computeCacheFilePathIn(allocator, cache_key, entries_dir) catch { - self.recordStoreFailureFor(.checked); + const store = self.allocator.create(artifacts.Store) catch { + self.recordStoreFailure(); return null; }; - defer allocator.free(cache_path); - self.roc_ctx.makePath(cacheSubdirOf(cache_path)) catch |err| { - self.verboseLog(allocator, "Failed to create checked cache subdirectory in {s} for {s} (key {x}): {}\n", .{ entries_dir, source_name, cache_key, err }); - self.recordStoreFailureFor(.checked); + store.* = self.openStore(self.allocator) catch { + self.allocator.destroy(store); + self.recordStoreFailure(); return null; }; - // Staged parts are named like the writer's own staging files, so cache - // cleanup ages out one left behind by a compilation that never finished. - var suffix: [16]u8 = undefined; - self.roc_ctx.std_io.random(&suffix); - const path = std.fmt.allocPrint(allocator, "{s}.{s}.part.tmp", .{ cache_path, std.fmt.bytesToHex(suffix, .lower) }) catch { - self.recordStoreFailureFor(.checked); + var stage = store.reserve(identity(.checked, key, scope), size, store.now()) catch |err| { + store.deinit(); + self.allocator.destroy(store); + if (err != error.Busy) self.recordStoreFailure(); return null; }; - self.roc_ctx.writeFileStream(path, stream) catch |err| { - self.verboseLog(allocator, "Failed to write checked cache part {s} for {s} ({d} bytes): {}\n", .{ path, source_name, size, err }); - self.removeFailedTempFile(allocator, path); - allocator.free(path); - self.recordStoreFailureFor(.checked); + self.roc_ctx.writeFileStream(stage.path, stream) catch |err| { + self.verboseLog(self.allocator, "Failed to stage checked cache part for {s}: {}\n", .{ name, err }); + stage.deinit(); + store.deinit(); + self.allocator.destroy(store); + self.recordStoreFailure(); return null; }; - return .{ .path = path, .len = size }; + return .{ .store = store, .stage = stage, .len = size }; } - /// The bytes of a staged part, owned by `allocator`. Null, counted as a - /// store failure, when the staging file can no longer be read whole. pub fn readStagedPart(self: *Self, part: StagedPart, allocator: Allocator) ?[]u8 { - const bytes = self.roc_ctx.readFile(part.path, allocator) catch |err| { - self.verboseLog(allocator, "Failed to read checked cache part {s}: {}\n", .{ part.path, err }); - self.recordStoreFailureFor(.checked); + const bytes = self.roc_ctx.readFile(part.stage.path, allocator) catch { + self.recordStoreFailure(); return null; }; if (bytes.len != part.len) { allocator.free(bytes); - self.recordStoreFailureFor(.checked); + self.recordStoreFailure(); return null; } return bytes; } - /// Delete a staged part's file and release it. pub fn discardStagedPart(self: *Self, part: StagedPart) void { - self.removeFailedTempFile(self.allocator, part.path); - self.allocator.free(part.path); + self.roc_ctx.deleteFile(part.stage.path) catch {}; + var stage = part.stage; + stage.deinit(); + // The catalog owns reclamation if this process exits or cannot acquire + // metadata ownership here. It never depends on this destructor running. + part.store.deinit(); + self.allocator.destroy(part.store); } - fn removeFailedTempFile(self: *Self, allocator: Allocator, path: []const u8) void { - self.roc_ctx.deleteFile(path) catch |err| switch (err) { - error.FileNotFound => {}, - error.AccessDenied, error.IoError => self.verboseLog(allocator, "Failed to remove cache temp file {s}: {}\n", .{ path, err }), - }; + pub fn loadRawBytes(self: *Self, key: [32]u8, scope: []const u8) ?[]const u8 { + return self.loadFamilyBytes(.checked, key, scope); } - pub fn loadRawBytes(self: *Self, cache_key: [32]u8, entries_dir: []const u8) ?[]const u8 { + pub fn loadFamilyBytes(self: *Self, family: artifacts.Family, key: [32]u8, scope: []const u8) ?[]const u8 { + if (comptime threading.is_freestanding) return null; if (!self.config.enabled) return null; - - const cache_path = self.computeCacheFilePath(cache_key, entries_dir) catch { + var store = self.openStore(self.allocator) catch { self.recordMissFor(.checked); return null; }; - defer self.allocator.free(cache_path); - - if (!self.roc_ctx.fileExists(cache_path)) { - self.recordMissFor(.checked); - return null; - } - - const data = self.roc_ctx.readFile(cache_path, self.allocator) catch |err| { - self.verboseLog(self.allocator, "Failed to read cache file {s}: {}\n", .{ cache_path, err }); + defer store.deinit(); + const bytes = (store.read(self.allocator, identity(family, key, scope), store.now()) catch null) orelse { self.recordMissFor(.checked); return null; }; + self.recordHitFor(.checked, bytes.len); + return bytes; + } - self.recordHitFor(.checked, data.len); - return data; - } - - /// Load a cache entry as a `CacheModule.CacheData`, memory-mapping the file on - /// POSIX targets and reading it onto the heap elsewhere. The returned value must - /// be released with `CacheData.deinit(self.allocator)`, which unmaps a mapped - /// entry and frees a heap-read entry. The caller must keep the entry alive for - /// as long as it reads from the returned bytes. - pub fn loadRawBytesMapped(self: *Self, cache_key: [32]u8, entries_dir: []const u8) ?CacheModule.CacheData { - return self.loadRawBytesMappedIn(self.allocator, .checked, cache_key, entries_dir); - } - - /// Load one cache entry, allocating from `allocator` (which must also - /// release the returned `CacheData`) and counting the operation against - /// `kind`'s counters. - pub fn loadRawBytesMappedIn( - self: *Self, - allocator: Allocator, - kind: Kind, - cache_key: [32]u8, - entries_dir: []const u8, - ) ?CacheModule.CacheData { - if (!self.config.enabled) return null; + pub fn loadRawBytesMapped(self: *Self, key: [32]u8, scope: []const u8) ?CacheModule.CacheData { + return self.loadFamilyMapped(self.allocator, .checked, .checked, key, scope); + } + + pub fn loadRawBytesMappedIn(self: *Self, allocator: Allocator, kind: Kind, key: [32]u8, scope: []const u8) ?CacheModule.CacheData { + return self.loadFamilyMapped(allocator, kind, familyFor(kind), key, scope); + } + + pub fn loadFamilyMapped(self: *Self, allocator: Allocator, kind: Kind, family: artifacts.Family, key: [32]u8, scope: []const u8) ?CacheModule.CacheData { + return self.loadMapped(allocator, kind, family, key, scope, true); + } - const cache_path = computeCacheFilePathIn(allocator, cache_key, entries_dir) catch { - self.recordMissFor(kind); + fn loadMapped(self: *Self, allocator: Allocator, kind: Kind, family: artifacts.Family, key: [32]u8, scope: []const u8, counted: bool) ?CacheModule.CacheData { + if (comptime threading.is_freestanding) return null; + if (!self.config.enabled) return null; + var store = self.openStore(allocator) catch { + if (counted) self.recordMissFor(kind); return null; }; - defer allocator.free(cache_path); - - if (!self.roc_ctx.fileExists(cache_path)) { - self.recordMissFor(kind); + defer store.deinit(); + var artifact = (store.acquire(identity(family, key, scope), store.now()) catch null) orelse { + if (counted) self.recordMissFor(kind); return null; + }; + defer artifact.deinit(); + const size = std.math.cast(usize, artifact.size) orelse return null; + if (comptime CoreCtx.can_map_files) { + if (size != 0) { + const region = std.posix.mmap(null, size, .{ .READ = true, .WRITE = true }, .{ .TYPE = .PRIVATE }, artifact.file.handle, 0) catch null; + if (region) |mapped| { + if (counted) self.recordHitFor(kind, size); + return .{ .mapped = .{ .region = mapped, .file_len = size } }; + } + } } - - // Prefer a copy-on-write mapping of the file. A `roc_ctx` that cannot - // map files (a virtual filesystem, a target without `mmap`) yields - // `null` and reads onto the heap instead. - if (CacheModule.tryMapCacheFile(self.roc_ctx, cache_path)) |mapped| { - self.recordHitFor(kind, mapped.data().len); - return mapped; - } - - const data = self.roc_ctx.readFile(cache_path, allocator) catch |err| { - self.verboseLog(allocator, "Failed to read cache file {s}: {}\n", .{ cache_path, err }); - self.recordMissFor(kind); + const bytes = allocator.alignedAlloc(u8, CacheModule.SERIALIZATION_ALIGNMENT, size) catch return null; + const len = artifact.file.readPositionalAll(store.io, bytes, 0) catch { + allocator.free(bytes); return null; }; - defer allocator.free(data); - - const buffer = allocator.alignedAlloc(u8, CacheModule.SERIALIZATION_ALIGNMENT, data.len) catch { - self.recordMissFor(kind); + if (len != size) { + allocator.free(bytes); return null; - }; - @memcpy(buffer, data); - - self.recordHitFor(kind, buffer.len); - return CacheModule.CacheData{ .allocated = buffer }; + } + if (counted) self.recordHitFor(kind, size); + return .{ .allocated = bytes }; } const placement_pointer_magic = "roc-mod-latest-v1"; const placement_pointer_len = placement_pointer_magic.len + 32; - /// Record `key` as the most recently stored checked entry of the module - /// filed under `placement`, replacing the previous pointer. A failure - /// leaves no pointer and only costs a later edit its reuse. pub fn storePlacementPointer(self: *Self, placement: [32]u8, key: [32]u8) void { - if (!self.config.enabled) return; - const allocator = self.allocator; - const dir = self.config.getCheckedPlacementDir(allocator) catch return; - defer allocator.free(dir); - const path = computeCacheFilePathIn(allocator, placement, dir) catch return; - defer allocator.free(path); - self.roc_ctx.makePath(cacheSubdirOf(path)) catch return; var contents: [placement_pointer_len]u8 = undefined; @memcpy(contents[0..placement_pointer_magic.len], placement_pointer_magic); @memcpy(contents[placement_pointer_magic.len..], &key); - var suffix: [16]u8 = undefined; - self.roc_ctx.std_io.random(&suffix); - const temp_path = std.fmt.allocPrint(allocator, "{s}.{s}.tmp", .{ path, std.fmt.bytesToHex(suffix, .lower) }) catch return; - defer allocator.free(temp_path); - self.roc_ctx.writeFile(temp_path, &contents) catch { - self.removeFailedTempFile(allocator, temp_path); - return; - }; - self.roc_ctx.rename(temp_path, path) catch self.removeFailedTempFile(allocator, temp_path); + self.storeFamilyBytes(.placement, placement, &contents, "placement", "placement"); } - /// The key `storePlacementPointer` last recorded for `placement`, or - /// null when there is none or it is unreadable. pub fn loadPlacementPointer(self: *Self, placement: [32]u8) ?[32]u8 { - if (!self.config.enabled) return null; - const allocator = self.allocator; - const dir = self.config.getCheckedPlacementDir(allocator) catch return null; - defer allocator.free(dir); - const path = computeCacheFilePathIn(allocator, placement, dir) catch return null; - defer allocator.free(path); - var contents: [placement_pointer_len]u8 = undefined; - const len = self.roc_ctx.readFileInto(path, &contents) catch return null; - if (len != placement_pointer_len or !std.mem.eql(u8, contents[0..placement_pointer_magic.len], placement_pointer_magic)) return null; - return contents[placement_pointer_magic.len..].*; + const bytes = self.loadFamilyBytes(.placement, placement, "placement") orelse return null; + defer self.allocator.free(bytes); + if (bytes.len != placement_pointer_len or !std.mem.eql(u8, bytes[0..placement_pointer_magic.len], placement_pointer_magic)) return null; + return bytes[placement_pointer_magic.len..][0..32].*; } - /// Replace the literal values file of the program root module filed - /// under `placement` with `bytes`. A failure leaves the previous file, or - /// none, and only costs a later compilation its reuse. pub fn storeLiteralValues(self: *Self, placement: [32]u8, bytes: []const u8) void { - if (!self.config.enabled) return; - const allocator = self.allocator; - const dir = self.config.getLiteralValuesDir(allocator) catch return; - defer allocator.free(dir); - const path = computeCacheFilePathIn(allocator, placement, dir) catch return; - defer allocator.free(path); - self.roc_ctx.makePath(cacheSubdirOf(path)) catch return; - var suffix: [16]u8 = undefined; - self.roc_ctx.std_io.random(&suffix); - const temp_path = std.fmt.allocPrint(allocator, "{s}.{s}.tmp", .{ path, std.fmt.bytesToHex(suffix, .lower) }) catch return; - defer allocator.free(temp_path); - self.roc_ctx.writeFile(temp_path, bytes) catch { - self.removeFailedTempFile(allocator, temp_path); - return; - }; - self.roc_ctx.rename(temp_path, path) catch self.removeFailedTempFile(allocator, temp_path); + self.storeFamilyBytes(.literals, placement, bytes, "literals", "literals"); } - /// The literal values file `storeLiteralValues` last wrote for - /// `placement`, or null when there is none or it is unreadable. Owned. pub fn loadLiteralValues(self: *Self, placement: [32]u8) ?[]u8 { - if (!self.config.enabled) return null; - const allocator = self.allocator; - const dir = self.config.getLiteralValuesDir(allocator) catch return null; - defer allocator.free(dir); - const path = computeCacheFilePathIn(allocator, placement, dir) catch return null; - defer allocator.free(path); - if (!self.roc_ctx.fileExists(path)) return null; - return self.roc_ctx.readFile(path, allocator) catch null; - } - - /// Load a checked entry without counting it as a cache lookup: reading a - /// module's previous entry for value reuse is not a probe of its key. - pub fn loadCheckedEntryUncounted(self: *Self, cache_key: [32]u8) ?CacheModule.CacheData { - if (!self.config.enabled) return null; - const allocator = self.allocator; - const entries_dir = self.config.getCheckedArtifactCacheDir(allocator) catch return null; - defer allocator.free(entries_dir); - const cache_path = computeCacheFilePathIn(allocator, cache_key, entries_dir) catch return null; - defer allocator.free(cache_path); - if (!self.roc_ctx.fileExists(cache_path)) return null; - if (CacheModule.tryMapCacheFile(self.roc_ctx, cache_path)) |mapped| return mapped; - const data = self.roc_ctx.readFile(cache_path, allocator) catch return null; - defer allocator.free(data); - const buffer = allocator.alignedAlloc(u8, CacheModule.SERIALIZATION_ALIGNMENT, data.len) catch return null; - @memcpy(buffer, data); - return CacheModule.CacheData{ .allocated = buffer }; + return if (self.loadFamilyBytes(.literals, placement, "literals")) |bytes| @constCast(bytes) else null; + } + + pub fn loadCheckedEntryUncounted(self: *Self, key: [32]u8) ?CacheModule.CacheData { + const directory = self.config.getCheckedArtifactCacheDir(self.allocator) catch return null; + defer self.allocator.free(directory); + // This is a real cache acquisition for retention, even though it is a + // reuse probe rather than the ordinary current-key lookup. + return self.loadMapped(self.allocator, .checked, .checked, key, directory, false); } pub fn getStats(self: *const Self) CacheStats { diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index e44338669fb..ca7994a0d20 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -99,8 +99,8 @@ const CheckedModuleCacheRunError = CoordinatorError || eval.BuiltinModules.InitE LowLevelOperationsNotFound, TestUnexpectedResult, }; -const OverwriteFilesUnderDirError = Allocator.Error || std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || std.Io.Dir.WriteFileError; -const CorruptCheckedModuleCacheError = Allocator.Error || std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || std.Io.Dir.ReadFileAllocError || std.Io.Dir.WriteFileError || error{FileNotFound}; +const OverwriteFilesUnderDirError = @import("artifact_store.zig").Error || Allocator.Error || std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || std.Io.Dir.WriteFileError; +const CorruptCheckedModuleCacheError = @import("artifact_store.zig").Error || Allocator.Error || std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || std.Io.Dir.ReadFileAllocError || std.Io.Dir.WriteFileError || error{FileNotFound}; const TypeCheckedResult = messages.TypeCheckedResult; const DiscoveredLocalImport = messages.DiscoveredLocalImport; const DiscoveredExternalImport = messages.DiscoveredExternalImport; @@ -2870,6 +2870,7 @@ pub const Coordinator = struct { // cannot return the error itself, so it recorded the OOM here. if (self.worker_oom.load(.acquire)) return error.OutOfMemory; try self.checkCancelRequest(); + if (comptime !threading.is_freestanding) @import("cache_cleanup.zig").safePoint(.serial); var made_progress = false; @@ -2884,7 +2885,14 @@ pub const Coordinator = struct { } else { // Multi-threaded: receive from workers via channel // Use blocking recv with timeout to avoid busy spinning - if (self.result_channel.recvTimeout(10_000_000)) |result| { // 10ms timeout + const next_result = self.result_channel.tryRecv() orelse blk: { + if (comptime !threading.is_freestanding) @import("cache_cleanup.zig").safePoint(.{ .waiting = .{ + .budget = self.max_threads, + .tasks = self.inflight.load(.acquire), + } }); + break :blk self.result_channel.recvTimeout(10_000_000); + }; + if (next_result) |result| { _ = self.inflight.fetchSub(1, .monotonic); try self.handleResult(result); made_progress = true; @@ -4160,7 +4168,7 @@ pub const Coordinator = struct { }; writeCheckedModuleCacheHeader(.platform_pairing, bytes[0..checked_module_cache_header_len], key, 0, writer.total_bytes, @splat(0)); _ = writer.writeToBuffer(bytes[checked_module_cache_header_len..]) catch unreachable; // bytes was allocated with writer.total_bytes after the header - manager.storeRawBytes(key.bytes, bytes, directory, artifact.moduleEnvConst().module_name); + manager.storeFamilyBytes(.pairing, key.bytes, bytes, directory, artifact.moduleEnvConst().module_name); } fn tryLoadCachedPlatformPairing(self: *Coordinator, platform: *const CheckedArtifact.CheckedModuleArtifact, app: *const CheckedArtifact.CheckedModuleArtifact) ?CheckedArtifact.CheckedModuleArtifact { @@ -4168,7 +4176,7 @@ pub const Coordinator = struct { if (!manager.config.enabled) return null; const directory = self.cache_entry_dirs.platform_pairing orelse return null; const key = cache_key_mod.platformPairingCacheKey(platform.key, app.key); - const data = manager.loadRawBytesMapped(key.bytes, directory) orelse return null; + const data = manager.loadFamilyMapped(manager.allocator, .checked, .pairing, key.bytes, directory) orelse return null; defer data.deinit(manager.allocator); const bodies = decodeCheckedCacheEnvelope(.platform_pairing, key, data.data()) orelse { manager.stats.recordInvalidation(); @@ -7677,7 +7685,7 @@ test "issue 11389 invalid pairing cache recomposes from immutable modules" { const config = CacheConfig{ .enabled = true, .cache_dir = cache }; const pair_directory = try config.getPlatformPairingCacheDir(allocator); defer allocator.free(pair_directory); - const corrupted = try overwriteFilesUnderDir(allocator, pair_directory, "invalid pairing"); + const corrupted = try overwriteFilesUnderDir(allocator, cache, pair_directory, .pairing, "invalid pairing"); try std.testing.expectEqual(@as(usize, 1), corrupted); var repaired = try compileAppRootIdentity(allocator, cache, app); defer repaired.deinit(allocator); @@ -9932,46 +9940,55 @@ fn countHoistedValidations( return count; } -fn overwriteFilesUnderDir(allocator: Allocator, absolute_dir: []const u8, contents: []const u8) OverwriteFilesUnderDirError!usize { - const io = std.testing.io; - var dir = try std.Io.Dir.openDirAbsolute(io, absolute_dir, .{ .iterate = true }); - defer dir.close(io); - - var walker = try dir.walk(allocator); - defer walker.deinit(); - - var overwritten: usize = 0; - while (try walker.next(io)) |entry| { - if (entry.kind != .file) continue; - try dir.writeFile(io, .{ .sub_path = entry.path, .data = contents }); - overwritten += 1; +/// Test-only access to published payloads, without corrupting control records. +fn testCachePaths(allocator: Allocator, root: []const u8, scope: []const u8, family: @import("artifact_store.zig").Family) @import("artifact_store.zig").Error![][]u8 { + var store = try @import("artifact_store.zig").Store.init(allocator, std.testing.io, root); + defer store.deinit(); + const keys = try store.groupKeys(allocator, CacheManager.identity(family, @splat(0), scope)); + defer allocator.free(keys); + var paths: std.ArrayList([]u8) = .empty; + errdefer { + for (paths.items) |path| allocator.free(path); + paths.deinit(allocator); + } + for (keys) |key| { + var artifact = (try store.acquireKey(key, store.now())) orelse continue; + defer artifact.deinit(); + try paths.ensureUnusedCapacity(allocator, 1); + paths.appendAssumeCapacity(try allocator.dupe(u8, artifact.path)); } - return overwritten; + return paths.toOwnedSlice(allocator); } -/// Drop the last byte of every cache entry under `absolute_dir`, leaving a body -/// whose header lengths no longer describe it. -fn truncateFilesUnderDir(allocator: Allocator, absolute_dir: []const u8) CorruptCheckedModuleCacheError!usize { - const io = std.testing.io; - var dir = try std.Io.Dir.openDirAbsolute(io, absolute_dir, .{ .iterate = true }); - defer dir.close(io); - - var walker = try dir.walk(allocator); - defer walker.deinit(); +fn overwriteFilesUnderDir(allocator: Allocator, root: []const u8, scope: []const u8, family: @import("artifact_store.zig").Family, contents: []const u8) OverwriteFilesUnderDirError!usize { + const paths = try testCachePaths(allocator, root, scope, family); + defer { + for (paths) |path| allocator.free(path); + allocator.free(paths); + } + for (paths) |path| try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = path, .data = contents }); + return paths.len; +} +/// Drop the last byte, leaving header lengths that no longer describe the body. +fn truncateFilesUnderDir(allocator: Allocator, root: []const u8, scope: []const u8, family: @import("artifact_store.zig").Family) CorruptCheckedModuleCacheError!usize { + const paths = try testCachePaths(allocator, root, scope, family); + defer { + for (paths) |path| allocator.free(path); + allocator.free(paths); + } var truncated: usize = 0; - while (try walker.next(io)) |entry| { - if (entry.kind != .file) continue; - const contents = try dir.readFileAlloc(io, entry.path, allocator, .unlimited); + for (paths) |path| { + const contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, path, allocator, .unlimited); defer allocator.free(contents); if (contents.len == 0) continue; - try dir.writeFile(io, .{ .sub_path = entry.path, .data = contents[0 .. contents.len - 1] }); + try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = path, .data = contents[0 .. contents.len - 1] }); truncated += 1; } return truncated; } -fn corruptCheckedModuleEnvIdentBytesLens(allocator: Allocator, checked_module_cache_dir: []const u8) CorruptCheckedModuleCacheError!usize { +fn corruptCheckedModuleEnvIdentBytesLens(allocator: Allocator, root: []const u8, checked_module_cache_dir: []const u8) CorruptCheckedModuleCacheError!usize { const env_ident_bytes_len_offset = checked_module_cache_header_len + @offsetOf(ModuleEnv.Serialized, "common") + @@ -9981,22 +9998,20 @@ fn corruptCheckedModuleEnvIdentBytesLens(allocator: Allocator, checked_module_ca @offsetOf(collections.SafeList(u8).Serialized, "len"); const io = std.testing.io; - var dir = try std.Io.Dir.openDirAbsolute(io, checked_module_cache_dir, .{ .iterate = true }); - defer dir.close(io); - - var walker = try dir.walk(allocator); - defer walker.deinit(); - + const dir = std.Io.Dir.cwd(); + const paths = try testCachePaths(allocator, root, checked_module_cache_dir, .checked); + defer { + for (paths) |path| allocator.free(path); + allocator.free(paths); + } var corrupted: usize = 0; - while (try walker.next(io)) |entry| { - if (entry.kind != .file) continue; - - const bytes = try dir.readFileAlloc(io, entry.path, allocator, .limited(@import("cache_config.zig").Constants.MAX_CACHE_SIZE)); + for (paths) |path| { + const bytes = try dir.readFileAlloc(io, path, allocator, .limited(@import("cache_config.zig").Constants.MAX_CACHE_SIZE)); defer allocator.free(bytes); if (bytes.len < env_ident_bytes_len_offset + @sizeOf(u64)) continue; std.mem.writeInt(u64, bytes[env_ident_bytes_len_offset..][0..8], std.math.maxInt(u64), .little); - try dir.writeFile(io, .{ .sub_path = entry.path, .data = bytes }); + try dir.writeFile(io, .{ .sub_path = path, .data = bytes }); corrupted += 1; } @@ -10251,7 +10266,7 @@ test "Coordinator corrupt checked module cache entries compile from source" { const config = CacheConfig{ .cache_dir = cache_dir }; const checked_module_cache_dir = try config.getCheckedArtifactCacheDir(allocator); defer allocator.free(checked_module_cache_dir); - const overwritten = try overwriteFilesUnderDir(allocator, checked_module_cache_dir, "not a checked module cache entry"); + const overwritten = try overwriteFilesUnderDir(allocator, cache_dir, checked_module_cache_dir, .checked, "not a checked module cache entry"); try std.testing.expect(overwritten > 0); const second = try compileAppWithCheckedModuleCache(allocator, cache_dir, "test/str/app_message.roc"); @@ -10274,7 +10289,7 @@ test "Coordinator corrupt checked module cache env relocations compile from sour const config = CacheConfig{ .cache_dir = cache_dir }; const checked_module_cache_dir = try config.getCheckedArtifactCacheDir(allocator); defer allocator.free(checked_module_cache_dir); - const corrupted = try corruptCheckedModuleEnvIdentBytesLens(allocator, checked_module_cache_dir); + const corrupted = try corruptCheckedModuleEnvIdentBytesLens(allocator, cache_dir, checked_module_cache_dir); try std.testing.expect(corrupted > 0); const second = try compileAppWithCheckedModuleCache(allocator, cache_dir, "test/str/app_message.roc"); @@ -10400,15 +10415,9 @@ fn readCheckedModuleCacheEntry(allocator: Allocator, cache_dir: []const u8, key: const config = CacheConfig{ .cache_dir = cache_dir }; const entries_dir = try config.getCheckedArtifactCacheDir(allocator); defer allocator.free(entries_dir); - var dir = try std.Io.Dir.openDirAbsolute(io, entries_dir, .{}); - defer dir.close(io); - const hex = std.fmt.bytesToHex(key, .lower); - const sub_path = try std.fs.path.join(allocator, &.{ hex[0..2], hex[2..] }); - defer allocator.free(sub_path); - return dir.readFileAlloc(io, sub_path, allocator, .unlimited) catch |err| switch (err) { - error.FileNotFound => null, - else => |e| return e, - }; + var store = try @import("artifact_store.zig").Store.init(allocator, io, cache_dir); + defer store.deinit(); + return store.read(allocator, CacheManager.identity(.checked, key, entries_dir), store.now()); } test "issue 12059: a checked module's cache entry does not depend on how an importer reads its constants" { @@ -10488,22 +10497,13 @@ test "issue 12059: an app reading an imported constant at a defaulted nominal ty } /// Count the entry files one cache directory holds. -fn countCacheEntries(allocator: Allocator, absolute_dir: []const u8) (std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || Allocator.Error)!usize { - const io = std.testing.io; - var dir = std.Io.Dir.openDirAbsolute(io, absolute_dir, .{ .iterate = true }) catch |err| switch (err) { - error.FileNotFound => return 0, - else => |e| return e, - }; - defer dir.close(io); - - var walker = try dir.walk(allocator); - defer walker.deinit(); - - var count: usize = 0; - while (try walker.next(io)) |entry| { - if (entry.kind == .file) count += 1; +fn countCacheEntries(allocator: Allocator, root: []const u8, scope: []const u8, family: @import("artifact_store.zig").Family) @import("artifact_store.zig").Error!usize { + const paths = try testCachePaths(allocator, root, scope, family); + defer { + for (paths) |path| allocator.free(path); + allocator.free(paths); } - return count; + return paths.len; } test "canonicalized module cache hits every module on a second compile and produces identical output" { @@ -10661,7 +10661,7 @@ test "canonicalized module cache shares one entry between identical modules in d // the cache holds exactly one fewer entry than the build canonicalized // modules, because nothing about a module's package, path, or importers // reaches its key. - const entry_count = try countCacheEntries(allocator, canonicalized_dir); + const entry_count = try countCacheEntries(allocator, cache_dir, canonicalized_dir, .canonicalized); try std.testing.expectEqual(first.build.canonicalized_cache_misses - 1, @as(u32, @intCast(entry_count))); // Both of them load that one entry on the next build. @@ -10669,7 +10669,7 @@ test "canonicalized module cache shares one entry between identical modules in d defer second.deinit(allocator); try std.testing.expectEqual(first.build.canonicalized_cache_misses, second.build.canonicalized_cache_hits); try std.testing.expectEqual(@as(u32, 0), second.build.canonicalized_cache_misses); - try std.testing.expectEqual(entry_count, try countCacheEntries(allocator, canonicalized_dir)); + try std.testing.expectEqual(entry_count, try countCacheEntries(allocator, cache_dir, canonicalized_dir, .canonicalized)); try std.testing.expectEqualStrings(first.reports, second.reports); } @@ -10919,7 +10919,7 @@ test "Coordinator corrupt canonicalized module cache entries compile from source defer allocator.free(canonicalized_dir); // A body that is not a canonicalized entry at all. - const overwritten = try overwriteFilesUnderDir(allocator, canonicalized_dir, "not a canonicalized module cache entry"); + const overwritten = try overwriteFilesUnderDir(allocator, cache_dir, canonicalized_dir, .canonicalized, "not a canonicalized module cache entry"); try std.testing.expect(overwritten > 0); var second = try compileAppFacts(allocator, cache_dir, app_path); @@ -10935,7 +10935,7 @@ test "Coordinator corrupt canonicalized module cache entries compile from source try std.testing.expectEqualStrings(first.reports, third.reports); // A truncated body is rejected the same way. - const truncated = try truncateFilesUnderDir(allocator, canonicalized_dir); + const truncated = try truncateFilesUnderDir(allocator, cache_dir, canonicalized_dir, .canonicalized); try std.testing.expect(truncated > 0); var fourth = try compileAppFacts(allocator, cache_dir, app_path); diff --git a/src/compile/mod.zig b/src/compile/mod.zig index feb4c163e2a..fff9d750a4d 100644 --- a/src/compile/mod.zig +++ b/src/compile/mod.zig @@ -28,30 +28,9 @@ pub const app_header = @import("app_header.zig"); pub const key = @import("cache_key.zig"); pub const config = @import("cache_config.zig"); pub const manager = @import("cache_manager.zig"); -pub const cleanup = if (!threading_mod.is_freestanding) @import("cache_cleanup.zig") else struct { - pub const CleanupStats = struct { - temp_dirs_deleted: u32 = 0, - temp_files_deleted: u32 = 0, - cache_files_deleted: u32 = 0, - stale_namespaces_deleted: u32 = 0, - evicted_files_deleted: u32 = 0, - empty_dirs_deleted: u32 = 0, - errors: u32 = 0, - }; - - pub const CleanupThread = struct {}; - - pub const Limits = struct { - object_pack_format_version: u32, - max_size_bytes: u64, - }; - - pub fn startBackgroundCleanup(_: []const u8, _: std.Io, _: Limits) Allocator.Error!?CleanupThread { - return null; - } - - pub fn deleteTempDir(_: std.Io, _: []const u8) void {} -}; +pub const artifacts = @import("artifact_store.zig"); +pub const maintenance = @import("cache_maintenance.zig"); +pub const cleanup = @import("cache_cleanup.zig"); pub const CacheManager = manager.CacheManager; pub const CacheConfig = config.CacheConfig; @@ -84,6 +63,8 @@ pub const CoreCtx = @import("ctx").CoreCtx; test "compile tests" { if (!threading_mod.is_freestanding) { std.testing.refAllDecls(@import("cache_cleanup.zig")); + std.testing.refAllDecls(artifacts); + std.testing.refAllDecls(maintenance); } std.testing.refAllDecls(@import("cache_config.zig")); std.testing.refAllDecls(@import("cache_key.zig")); diff --git a/src/compile/test/cache_test.zig b/src/compile/test/cache_test.zig index a10b5106a43..30e0ceeeab1 100644 --- a/src/compile/test/cache_test.zig +++ b/src/compile/test/cache_test.zig @@ -22,29 +22,6 @@ fn captureStderr(ctx: ?*anyopaque, _: std.Io, bytes: []const u8) CoreCtx.StdioEr capture.stderr.appendSlice(capture.allocator, bytes) catch return error.IoError; } -const CacheWriteBarrier = struct { - base: CoreCtx, - mutex: std.Io.Mutex = .init, - condition: std.Io.Condition = .init, - writes: usize = 0, - - fn writeFile(ctx: ?*anyopaque, io: std.Io, path: []const u8, data: []const u8) CoreCtx.WriteError!void { - const self: *CacheWriteBarrier = @ptrCast(@alignCast(ctx.?)); - try self.base.writeFile(path, data); - - // Both publishers finish staging before either can rename. A shared - // temp filename makes the second rename fail with FileNotFound. - self.mutex.lockUncancelable(io); - defer self.mutex.unlock(io); - self.writes += 1; - if (self.writes == 2) { - self.condition.broadcast(io); - } else { - while (self.writes < 2) self.condition.waitUncancelable(io, &self.mutex); - } - } -}; - const CacheStoreTask = struct { manager: *CacheManager, directory: []const u8, @@ -81,48 +58,6 @@ test "getTestCacheDir returns test subdirectory" { try testing.expect(std.mem.startsWith(u8, test_dir, version_dir)); } -test "getScratchDir is the version cache dir's scratch subdirectory" { - const allocator = testing.allocator; - const config = CacheConfig{ - .cache_dir = "/home/user/.cache/roc", - .roc_ctx = CoreCtx.testing(testing.allocator, testing.allocator), - }; - - const version_dir = try config.getVersionCacheDir(allocator); - defer allocator.free(version_dir); - - const scratch_dir = try config.getScratchDir(allocator); - defer allocator.free(scratch_dir); - - const expected = try std.fs.path.join(allocator, &.{ version_dir, "tmp" }); - defer allocator.free(expected); - try testing.expectEqualStrings(expected, scratch_dir); -} - -test "computeCacheFilePath uses subdirectory splitting" { - const allocator = testing.allocator; - const filesystem = CoreCtx.testing(std.testing.allocator, std.testing.allocator); - const config = CacheConfig{ .roc_ctx = filesystem }; - - var manager = CacheManager.init(allocator, config, filesystem); - - const cache_key = [_]u8{ - 0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90, - 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, - 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, - 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, - }; - - // Test with a custom entries dir - const path = try manager.computeCacheFilePath(cache_key, "/tmp/test_cache"); - defer allocator.free(path); - - // Should contain the subdirectory split: first byte "ab" as subdir - try testing.expect(std.mem.containsAtLeast(u8, path, 1, "ab")); - // Path should start with our test dir - try testing.expect(std.mem.startsWith(u8, path, "/tmp/test_cache")); -} - test "storeRawBytes and loadRawBytes round-trip" { const allocator = testing.allocator; @@ -134,7 +69,7 @@ test "storeRawBytes and loadRawBytes round-trip" { defer allocator.free(tmp_path); const filesystem = CoreCtx.os(std.testing.allocator, std.testing.allocator, std.testing.io); - const config = CacheConfig{ .roc_ctx = filesystem }; + const config = CacheConfig{ .cache_dir = tmp_path, .roc_ctx = filesystem }; var manager = CacheManager.init(allocator, config, filesystem); @@ -160,11 +95,8 @@ test "concurrent cache stores of one key use separate staging files" { const tmp_path = try tmp_dir.dir.realPathFileAlloc(std.testing.io, ".", allocator); defer allocator.free(tmp_path); - var barrier = CacheWriteBarrier{ .base = CoreCtx.os(std.heap.page_allocator, std.heap.page_allocator, std.testing.io) }; - var filesystem = barrier.base; - filesystem.ctx = &barrier; - filesystem.vtable.writeFile = &CacheWriteBarrier.writeFile; - const config = CacheConfig{ .roc_ctx = filesystem }; + const filesystem = CoreCtx.os(std.heap.page_allocator, std.heap.page_allocator, std.testing.io); + const config = CacheConfig{ .cache_dir = tmp_path, .roc_ctx = filesystem }; var first = CacheManager.init(std.heap.page_allocator, config, filesystem); var second = CacheManager.init(std.heap.page_allocator, config, filesystem); @@ -177,8 +109,7 @@ test "concurrent cache stores of one key use separate staging files" { first_thread.join(); second_thread.join(); - try testing.expectEqual(@as(u64, 1), first.stats.stores); - try testing.expectEqual(@as(u64, 1), second.stats.stores); + try testing.expect(first.stats.stores + second.stats.stores >= 1); try testing.expectEqual(@as(u64, 0), first.stats.store_failures); try testing.expectEqual(@as(u64, 0), second.stats.store_failures); const loaded = first.loadRawBytes(key, tmp_path).?; @@ -197,7 +128,7 @@ test "loadRawBytes returns null on miss" { defer allocator.free(tmp_path); const filesystem = CoreCtx.os(std.testing.allocator, std.testing.allocator, std.testing.io); - const config = CacheConfig{ .roc_ctx = filesystem }; + const config = CacheConfig{ .cache_dir = tmp_path, .roc_ctx = filesystem }; var manager = CacheManager.init(allocator, config, filesystem); @@ -256,7 +187,7 @@ test "issue 11961: streamed cache publication preserves the old entry on write f var filesystem = CoreCtx.os(gpa, gpa, io); filesystem.ctx = &capture; filesystem.vtable.writeStderr = captureStderr; - var manager = CacheManager.init(gpa, .{ .roc_ctx = filesystem }, filesystem); + var manager = CacheManager.init(gpa, .{ .cache_dir = path, .roc_ctx = filesystem }, filesystem); const key = @as([32]u8, @splat(0x42)); manager.storeRawBytes(key, "old entry", path, "Test"); const bad = Stream{ .fail = true }; @@ -266,27 +197,7 @@ test "issue 11961: streamed cache publication preserves the old entry on write f try testing.expectEqualStrings("old entry", old); try testing.expectEqual(@as(u64, 1), manager.stats.store_failures); - var directory = try tmp.dir.openDir(io, "42", .{ .iterate = true }); - defer directory.close(io); - var iterator = directory.iterate(); - var files: usize = 0; - while (try iterator.next(io)) |_| files += 1; - try testing.expectEqual(@as(usize, 1), files); - const good = Stream{ .fail = false }; - const rename = manager.roc_ctx.vtable.rename; - manager.roc_ctx.vtable.rename = Stream.rejectRename; - manager.storeStream(key, .{ .context = &good, .write = Stream.write }, 9, path, "Test"); - manager.roc_ctx.vtable.rename = rename; - try testing.expectEqual(@as(u64, 2), manager.stats.store_failures); - const preserved = manager.loadRawBytes(key, path).?; - defer gpa.free(preserved); - try testing.expectEqualStrings("old entry", preserved); - iterator = directory.iterate(); - files = 0; - while (try iterator.next(io)) |_| files += 1; - try testing.expectEqual(@as(usize, 1), files); - manager.storeStream(key, .{ .context = &good, .write = Stream.write }, 9, path, "Test"); const current = manager.loadRawBytes(key, path).?; defer gpa.free(current); @@ -295,29 +206,22 @@ test "issue 11961: streamed cache publication preserves the old entry on write f const FailingCacheFilesystem = struct { capture: WarningCapture, - stage: enum { directory, write, rename }, - - fn makePath(ctx: ?*anyopaque, _: std.Io, _: []const u8) CoreCtx.MakePathError!void { - const self: *FailingCacheFilesystem = @ptrCast(@alignCast(ctx.?)); - if (self.stage == .directory) return error.ReadOnlyFileSystem; - } + base: CoreCtx, + stage: enum { directory, write, publish }, - fn writeFile(ctx: ?*anyopaque, _: std.Io, _: []const u8, _: []const u8) CoreCtx.WriteError!void { - const self: *FailingCacheFilesystem = @ptrCast(@alignCast(ctx.?)); + fn writeFile(ctx: ?*anyopaque, io: std.Io, path: []const u8, bytes: []const u8) CoreCtx.WriteError!void { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); if (self.stage == .write) return error.FileTooBig; - } - - fn rename(ctx: ?*anyopaque, _: std.Io, _: []const u8, _: []const u8) CoreCtx.RenameError!void { - const self: *FailingCacheFilesystem = @ptrCast(@alignCast(ctx.?)); - if (self.stage == .rename) return error.DiskQuota; - } - - fn deleteFile(_: ?*anyopaque, _: std.Io, _: []const u8) CoreCtx.DeleteError!void { - return error.AccessDenied; + try self.base.writeFile(path, bytes); + if (self.stage == .publish) { + const blocked = std.fmt.allocPrint(self.capture.allocator, "{s}.data", .{path[0 .. path.len - ".pending".len]}) catch return error.IoError; + defer self.capture.allocator.free(blocked); + std.Io.Dir.cwd().createDirPath(io, blocked) catch return error.IoError; + } } fn writeStderr(ctx: ?*anyopaque, _: std.Io, bytes: []const u8) CoreCtx.StdioError!void { - const self: *FailingCacheFilesystem = @ptrCast(@alignCast(ctx.?)); + const self: *@This() = @ptrCast(@alignCast(ctx.?)); self.capture.stderr.appendSlice(self.capture.allocator, bytes) catch return error.IoError; } }; @@ -325,60 +229,33 @@ const FailingCacheFilesystem = struct { test "cache write failures report every operation only in verbose mode" { const allocator = testing.allocator; const warning = "warning: Roc cache writes are failing; compilation will continue without updating the cache. Run with --verbose for details.\n"; - // Long diagnostic context must not silently discard a failure report. - const source_name = comptime blk: { - const piece = "LongModuleName"; - var buffer: [8 + piece.len * 100]u8 = undefined; - @memcpy(buffer[0..8], "package."); - for (0..100) |index| @memcpy(buffer[8 + index * piece.len ..][0..piece.len], piece); - const final = buffer; - break :blk &final; - }; - const key = @as([32]u8, @splat(0x42)); - const expected_path = try CacheManager.computeCacheFilePathIn(allocator, key, "cache"); - defer allocator.free(expected_path); + const key: [32]u8 = @splat(0x42); for ([_]bool{ false, true }) |verbose| { for (std.enums.values(@FieldType(FailingCacheFilesystem, "stage"))) |stage| { - var capture = FailingCacheFilesystem{ - .capture = .{ .allocator = allocator }, - .stage = stage, - }; + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(testing.io, ".", allocator); + defer allocator.free(root); + if (stage == .directory) try tmp.dir.writeFile(testing.io, .{ .sub_path = @import("../artifact_store.zig").format_directory, .data = "blocked directory" }); + var capture = FailingCacheFilesystem{ .capture = .{ .allocator = allocator }, .base = CoreCtx.os(allocator, allocator, testing.io), .stage = stage }; defer capture.capture.deinit(); - var filesystem = CoreCtx.testing(allocator, allocator); - filesystem.std_io = testing.io; + var filesystem = capture.base; filesystem.ctx = &capture; - filesystem.vtable.makePath = &FailingCacheFilesystem.makePath; filesystem.vtable.writeFile = &FailingCacheFilesystem.writeFile; - filesystem.vtable.rename = &FailingCacheFilesystem.rename; - filesystem.vtable.deleteFile = &FailingCacheFilesystem.deleteFile; filesystem.vtable.writeStderr = &FailingCacheFilesystem.writeStderr; - var manager = CacheManager.init(allocator, .{ .verbose = verbose }, filesystem); - for ([_]CacheManager.Kind{ .checked, .canonicalized }) |kind| { - manager.storeRawBytesIn(allocator, kind, key, "cache data", "cache", source_name); - } + var manager = CacheManager.init(allocator, .{ .cache_dir = root, .verbose = verbose }, filesystem); + for ([_]CacheManager.Kind{ .checked, .canonicalized }) |kind| manager.storeRawBytesIn(allocator, kind, key, "cache data", "scope", "package.Module"); const output = capture.capture.stderr.items; try testing.expectEqual(@as(u64, 1), manager.stats.store_failures); try testing.expectEqual(@as(u64, 1), manager.stats.canonicalized_store_failures); + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, output, warning)); if (!verbose) { try testing.expectEqualStrings(warning, output); - continue; - } - const cause = switch (stage) { - .directory => "error.ReadOnlyFileSystem", - .write => "error.FileTooBig", - .rename => "error.DiskQuota", - }; - try testing.expectEqual(@as(usize, 2), std.mem.count(u8, output, cause)); - try testing.expectEqual(@as(usize, 2), std.mem.count(u8, output, source_name)); - try testing.expectEqual(@as(usize, 1), std.mem.count(u8, output, warning)); - try testing.expect(std.mem.containsAtLeast(u8, output, 1, "checked cache")); - try testing.expect(std.mem.containsAtLeast(u8, output, 1, "canonicalized cache")); - if (stage == .directory) { - try testing.expectEqual(@as(usize, 2), std.mem.count(u8, output, "in cache for ")); } else { - try testing.expect(std.mem.containsAtLeast(u8, output, 2, expected_path)); - try testing.expectEqual(@as(usize, 2), std.mem.count(u8, output, "(10 bytes)")); - try testing.expectEqual(@as(usize, 2), std.mem.count(u8, output, "Failed to remove cache temp file")); + try testing.expectEqual(@as(usize, 2), std.mem.count(u8, output, if (stage == .write) "error.FileTooBig" else "error.IoFailure")); + try testing.expectEqual(@as(usize, 2), std.mem.count(u8, output, "package.Module")); + try testing.expect(std.mem.containsAtLeast(u8, output, 1, "checked cache")); + try testing.expect(std.mem.containsAtLeast(u8, output, 1, "canonicalized cache")); } } } diff --git a/src/glue/glue.zig b/src/glue/glue.zig index 9f8fd62eb31..7c874959106 100644 --- a/src/glue/glue.zig +++ b/src/glue/glue.zig @@ -546,7 +546,6 @@ fn buildGlueSpecPluginFileInner( const glue_plugin_stamp_magic = [8]u8{ 'R', 'O', 'C', 'P', 'L', 'G', '1', 0 }; const glue_plugin_abi_version: u32 = 1; -var glue_cache_temp_counter = std.atomic.Value(usize).init(0); const GluePluginKind = enum(u32) { glue = 1, @@ -569,11 +568,12 @@ const GluePluginStampV1 = extern struct { /// came from when a stamp mismatch should evict it. const BuiltGluePlugin = struct { bytes: []u8, - cache_path: ?[:0]const u8, + cache_root: ?[]u8, + cache_identity: ?compile.artifacts.Identity = null, fn deinit(self: BuiltGluePlugin, allocator: Allocator) void { allocator.free(self.bytes); - if (self.cache_path) |path| allocator.free(path); + if (self.cache_root) |path| allocator.free(path); } }; @@ -618,7 +618,7 @@ fn runGlueSpecPlugin( } // An installed plugin is a managed artifact: never rebuild past a // failure to load it; the remedy is reinstalling the shorthand. - if (args.no_cache or args.installed_plugin_path != null or first.cache_path == null) return err; + if (args.no_cache or args.installed_plugin_path != null or first.cache_root == null) return err; deleteGluePluginCacheEntry(first, std_io); first.deinit(gpa); @@ -689,53 +689,30 @@ fn getOrBuildGluePlugin( ) GlueError!BuiltGluePlugin { if (args.installed_plugin_path) |installed_path| { const bytes = readPluginFile(gpa, installed_path, std_io) orelse return error.GluePluginUnavailable; - return .{ .bytes = bytes, .cache_path = null }; + return .{ .bytes = bytes, .cache_root = null }; } if (args.no_cache) { - return .{ .bytes = try buildGluePlugin(gpa, lowered, glue_proc, arg_layouts, stamp, args.opt, std_io), .cache_path = null }; + return .{ .bytes = try buildGluePlugin(gpa, lowered, glue_proc, arg_layouts, stamp, args.opt, std_io), .cache_root = null }; } - const cache_path = gluePluginCachePath(gpa, executable_identity, stamp, args.opt, roc_ctx) catch |err| switch (err) { + const logical_path = gluePluginCachePath(gpa, executable_identity, stamp, args.opt, roc_ctx) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, - error.NoHomeDirectory, - => return error.BuildEnvInit, + error.NoHomeDirectory => return error.BuildEnvInit, }; - errdefer gpa.free(cache_path); - - if (readPluginFile(gpa, cache_path, std_io)) |bytes| { - return .{ .bytes = bytes, .cache_path = cache_path }; - } - - const cache_dir = std.fs.path.dirname(std.mem.sliceTo(cache_path, 0)) orelse return error.BuildEnvInit; - std.Io.Dir.cwd().createDirPath(std_io, cache_dir) catch return error.BuildEnvInit; - + defer gpa.free(logical_path); + const config: compile.CacheConfig = .{ .roc_ctx = roc_ctx }; + const root = config.getEffectiveCacheDir(gpa) catch return error.BuildEnvInit; + errdefer gpa.free(root); + var store = try compile.artifacts.Store.init(gpa, std_io, root); + defer store.deinit(); + var id: compile.artifacts.Identity = .{ .namespace = undefined, .family = .glue_plugin, .key = undefined, .variant = compile.artifacts.hashBytes(@tagName(args.opt)) }; + std.crypto.hash.sha2.Sha256.hash(build_options.compiler_compatibility_id, &id.namespace, .{}); + std.crypto.hash.sha2.Sha256.hash(logical_path, &id.key, .{}); + if (store.read(gpa, id, store.now()) catch null) |bytes| return .{ .bytes = bytes, .cache_root = root, .cache_identity = id }; const bytes = try buildGluePlugin(gpa, lowered, glue_proc, arg_layouts, stamp, args.opt, std_io); - errdefer gpa.free(bytes); - - const cache_temp_path = gluePluginCacheTempPath(gpa, cache_path) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - }; - defer { - std.Io.Dir.cwd().deleteFile(std_io, std.mem.sliceTo(cache_temp_path, 0)) catch {}; - gpa.free(cache_temp_path); - } - - std.Io.Dir.cwd().writeFile(std_io, .{ - .sub_path = std.mem.sliceTo(cache_temp_path, 0), - .data = bytes, - }) catch return error.CompilationFailed; - - // Another process may have published the same entry meanwhile; the - // bytes are the same either way. - std.Io.Dir.cwd().rename( - std.mem.sliceTo(cache_temp_path, 0), - std.Io.Dir.cwd(), - std.mem.sliceTo(cache_path, 0), - std_io, - ) catch {}; - - return .{ .bytes = bytes, .cache_path = cache_path }; + store.write(id, bytes, store.now()) catch {}; + return .{ .bytes = bytes, .cache_root = root, .cache_identity = id }; } /// The bytes of a plugin object file, or null when it cannot be read. @@ -883,24 +860,11 @@ fn gluePluginCachePath( }); } -fn gluePluginCacheTempPath(allocator: Allocator, cache_path: [:0]const u8) Allocator.Error![:0]u8 { - const counter = glue_cache_temp_counter.fetchAdd(1, .monotonic); - const pid: u64 = if (builtin.os.tag == .windows) - std.os.windows.GetCurrentProcessId() - else - @intCast(std.c.getpid()); - const path = try std.fmt.allocPrint(allocator, "{s}.{x}.{x}.tmp", .{ - std.mem.sliceTo(cache_path, 0), - pid, - counter, - }); - defer allocator.free(path); - return try allocator.dupeSentinel(u8, path, 0); -} - fn deleteGluePluginCacheEntry(plugin: BuiltGluePlugin, std_io: std.Io) void { - const path = plugin.cache_path orelse return; - std.Io.Dir.cwd().deleteFile(std_io, std.mem.sliceTo(path, 0)) catch {}; + const root = plugin.cache_root orelse return; + var store = compile.artifacts.Store.init(std.heap.page_allocator, std_io, root) catch return; + defer store.deinit(); + store.invalidate(plugin.cache_identity.?) catch {}; } fn gluePluginOutputHash(executable_identity: [32]u8, stamp: GluePluginStampV1) [32]u8 { diff --git a/src/ipc/coordination.zig b/src/ipc/coordination.zig index 6bc5702b55a..595403c80d5 100644 --- a/src/ipc/coordination.zig +++ b/src/ipc/coordination.zig @@ -97,26 +97,14 @@ fn readFdInfoFromFile(allocator: std.mem.Allocator, io: std.Io) CoordinationErro }; defer allocator.free(exe_path); - // Get the directory containing our executable (should be "{cache}/{version}/tmp/{random}") + // The launcher owns the executable directory and publishes its sibling + // coordination file before spawning this process. The storage layout is + // not part of this IPC protocol. const exe_dir = std.fs.path.dirname(exe_path) orelse { std.log.err("Invalid executable path: no directory component", .{}); return error.FdInfoReadFailed; }; - // Verify we're in a roc scratch directory: the parent of the exe directory - // is the per-version scratch directory, named by - // `cache_config.scratch_dir_name` in the compile module. - const scratch_dir = std.fs.path.dirname(exe_dir) orelse { - std.log.err("Invalid executable path: missing scratch directory component", .{}); - return error.FdInfoReadFailed; - }; - const scratch_basename = std.fs.path.basename(scratch_dir); - - if (!std.mem.eql(u8, scratch_basename, "tmp")) { - std.log.err("Unexpected directory structure: expected 'tmp' parent, got '{s}'", .{scratch_basename}); - return error.FdInfoReadFailed; - } - // Construct the fd file path by appending .txt to the directory path var dir_path = exe_dir; while (dir_path.len > 0 and (dir_path[dir_path.len - 1] == '/' or dir_path[dir_path.len - 1] == '\\')) { From e5609cbb6be395085ea500ae9ef64005b23b3b0c Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 11:41:52 -0400 Subject: [PATCH 14/97] Reuse complete dev test outcomes before compilation --- design.md | 62 +++- src/base/dev_diagnostics.zig | 25 ++ src/cli/completed_test.zig | 259 +++++++++++++++ src/cli/main.zig | 441 ++++++++++++++++++++++--- src/cli/test/parallel_cli_runner.zig | 183 ++++++++++ src/compile/compile_build.zig | 45 +++ src/compile/coordinator.zig | 2 + src/eval/compile_time_finalization.zig | 13 + src/eval/inspected.zig | 2 + src/lir/arc.zig | 1 + src/lir/checked_pipeline.zig | 1 + src/static_data.zig | 1 + test/cli/completed_test/Helper.roc | 15 + test/cli/completed_test/Main.roc | 12 + test/cli/completed_test/input.txt | 1 + 15 files changed, 1018 insertions(+), 45 deletions(-) create mode 100644 src/cli/completed_test.zig create mode 100644 test/cli/completed_test/Helper.roc create mode 100644 test/cli/completed_test/Main.roc create mode 100644 test/cli/completed_test/input.txt diff --git a/design.md b/design.md index 59231fa5bd6..15beaa7b896 100644 --- a/design.md +++ b/design.md @@ -23609,6 +23609,51 @@ tested root reaches into a dependency is compiled and linked the same way. ## Optimized Test Execution +### Completed dev test commands + +A completed `roc test --opt=dev` outcome is an orchestration result, independent +of checked graphs and lowering sessions. After resolving the current package +graph, the command can validate its recorded consumed source and imported-file +contents and replay the complete outcome before loading checked modules. The +test command has its own identity domain, including compiler compatibility, +root selection, checking context, resolved package graph and execution policy. +Neither a build nor a check publishes a test-command outcome. + +The early input plan is producer data: the source and file-import reads of a +completed compilation, including missing-file facts, and the physical source +paths admitted by import resolution. Validation hashes bytes and revalidates +those physical paths, never timestamps. Resolution runs before admission, so +package sidecars, replacement requests and resolution failures retain their +ordinary semantics. +An input plan only locates a content-addressed outcome; changing its inputs +cannot admit the previous outcome. Older outcomes remain reusable when those +inputs are restored. A changed dependency closure may replace the input plan +and conservatively miss an older outcome with a different closure. + +The command owns ordered compile-time debug observations and structured +diagnostic documents. Test observations remain in the existing per-module +result payloads, referenced by digest rather than copied into the command +record. Each observation has exactly one replay owner. Source reporting uses +current validated source bytes and paths; it requires no `PreparedSolved`, CIR +or type store. Presentation settings and elapsed time are never cached. + +Publication follows completed execution and durable referenced payloads; an +atomic manifest publishes the outcome last. Passes, failed expects and Roc +crashes qualify. Commands with semantic compilation errors or transient backend +failures do not publish complete outcomes. Missing, incompatible, truncated or +corrupt records and evicted payloads are misses. A complete hit returns before +runtime demand construction, specialization, CTFE, LIR/ARC, static data, +native test code generation and execution. It does not load procedure packs. + +The command envelope and per-module result payloads carry content checksums. +Command source paths and diagnostic path elements are relative to the current +working directory and are rebound only after validation. Compilation diagnostics +are replayed from their document elements, before test observations; color, +verbosity and terminal width are current renderer inputs. The command's arena +owns validated source bytes and lightweight source environments throughout replay. + +### Command-level execution + `roc test --opt=size` and `roc test --opt=speed` use one command-level test plan for all discovered test roots in the app and its imports. The plan is explicit compiler data. It owns the stable source-order display order, the @@ -23765,12 +23810,12 @@ results use the same ordering path. Diagnostics, `dbg`, `expect` failures, `expect_err` regions, crashes, and backend compiler errors stay attached to their test-plan entries until rendering and cache writes replay the plan order. -Checked test-result cache entries use the same checked module cache identity -as `roc check`. There is no second test-result cache key. -The test-result bundle may carry its own payload format and compiler-version -admission data, but those fields decide whether the decoded bundle can be used; -they do not extend the checked module cache identity. The result cache key does not -include selected optimization mode, backend, worker count, color mode, +Checked test-result cache entries derive their source identity from the same +checked module identity as `roc check`. The result key additionally covers its +payload format, compiler compatibility, specialization strategy and execution +mode: a completed outcome records that execution policy's resource behavior. +These inputs do not change checked module identity. The result cache key does not +include worker count, color mode, verbosity, terminal width, terminal style, elapsed time, final summary text, test-run completion order, test-root identity, or test-root order. Test-root identity and order are explicit checked module data and payload shape, not @@ -25392,6 +25437,11 @@ cases that set one of these variables skip against a compiler without them. Performance work that needs them on an optimized compiler builds it with `zig build roc -Doptimize=ReleaseFast -Ddev-diagnostics`. +`ROC_TEST_COMMAND_WORK` prints deterministic test-command counters at exit. +The specialization, evaluation, runtime LIR, ARC, static-data and native test +execution producers increment their own counters. A complete result hit leaves +every counter zero. This variable uses the same developer-only gate. + ## Integer SIMD Builtins diff --git a/src/base/dev_diagnostics.zig b/src/base/dev_diagnostics.zig index d7aa4bb30bb..a1123e4a1c6 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -36,6 +36,8 @@ pub const Var = enum { dev_artifact_roundtrip, /// Append one record to the named file per in-process LLVM object compile. test_llvm_object_compile_count_file, + /// Print deterministic work counters for one test command. + test_command_work, /// The variable's name in the environment. pub fn envName(comptime self: Var) [:0]const u8 { @@ -50,10 +52,33 @@ pub const Var = enum { .dev_pack_hits => "ROC_DEV_PACK_HITS", .dev_artifact_roundtrip => "ROC_DEV_ARTIFACT_ROUNDTRIP", .test_llvm_object_compile_count_file => "ROC_TEST_LLVM_OBJECT_COMPILE_COUNT_FILE", + .test_command_work => "ROC_TEST_COMMAND_WORK", }; } }; +/// Stage-owned counters used to prove complete-command reuse skips producers. +pub const TestWork = enum { + specialization, + ctfe_execution, + second_specialization, + runtime_lir, + arc, + static_data, + native_test_procedures, + test_root_execution, +}; +var test_work: [@typeInfo(TestWork).@"enum".field_names.len]std.atomic.Value(u64) = @splat(.init(0)); + +pub fn countTestWork(counter: TestWork, amount: usize) void { + if (!isSet(.test_command_work)) return; + _ = test_work[@backingInt(counter)].fetchAdd(amount, .monotonic); +} + +pub fn testWorkCount(counter: TestWork) u64 { + return test_work[@backingInt(counter)].load(.monotonic); +} + /// The variable's value, or null when it is unset or this compiler does not /// read developer diagnostics. pub inline fn get(comptime variable: Var) ?[:0]const u8 { diff --git a/src/cli/completed_test.zig b/src/cli/completed_test.zig new file mode 100644 index 00000000000..66cb7005e0a --- /dev/null +++ b/src/cli/completed_test.zig @@ -0,0 +1,259 @@ +//! Portable command records. Decoding allocates into a caller-owned arena; +//! no checked graph, lowering session or renderer state is persisted. +const std = @import("std"); +const base = @import("base"); +const reporting = @import("reporting"); +const compile = @import("compile"); +const Allocator = std.mem.Allocator; + +pub const magic = "ROC_COMPLETED_DEV_TEST_V1"; +pub const Digest = [32]u8; +pub const Input = struct { + path: []const u8, + state: compile.watch_inputs.State, +}; +pub const Resolution = struct { path: []const u8, target: []const u8 }; +pub const Plan = struct { inputs: []Input, resolutions: []Resolution }; +pub const Source = struct { name: Digest, path: []const u8 }; +pub const Root = struct { order: u32, region: base.Region }; +pub const Module = struct { + source: u32, + key: Digest, + payload_hash: Digest, + roots: []const Root, +}; +pub const Diagnostic = struct { + title: []const u8, + severity: reporting.Severity, + file: ?[]const u8, + headline: []reporting.DocumentElement, + body: []reporting.DocumentElement, + + pub fn fromReport(report: *const reporting.Report) Diagnostic { + return .{ .title = report.title, .severity = report.severity, .file = report.file, .headline = report.headline.elements.items, .body = report.document.elements.items }; + } + + pub fn render(self: Diagnostic, allocator: Allocator, writer: *std.Io.Writer, config: reporting.ReportingConfig) !void { + // The record arena owns all document content. Rendering borrows it. + var report = reporting.Report{ + .title = self.title, + .severity = self.severity, + .file = self.file, + .headline = reporting.Document.init(allocator), + .document = reporting.Document.init(allocator), + .allocator = allocator, + }; + report.headline.elements.items = self.headline; + report.document.elements.items = self.body; + try reporting.renderReportToTerminal(&report, writer, reporting.ColorUtils.getPaletteForConfig(config), config); + } +}; +pub const Record = struct { + input_key: Digest, + sources: []const Source, + modules: []const Module, + debug_messages: []const []const u8, + diagnostics: []Diagnostic, + modules_with_tests: u32, +}; + +pub fn hash(bytes: []const u8) Digest { + return compile.watch_inputs.hashBytes(bytes); +} + +/// Every field is explicitly encoded, without native padding, pointer values +/// or host endianness. This codec is private to the versioned command format. +pub fn encode(allocator: Allocator, value: anytype) Allocator.Error![]u8 { + var bytes = std.ArrayList(u8).empty; + errdefer bytes.deinit(allocator); + try bytes.appendSlice(allocator, magic); + try put(allocator, &bytes, value); + const digest = hash(bytes.items); + try bytes.appendSlice(allocator, &digest); + return bytes.toOwnedSlice(allocator); +} + +fn put(allocator: Allocator, bytes: *std.ArrayList(u8), value: anytype) Allocator.Error!void { + const T = @TypeOf(value); + switch (@typeInfo(T)) { + .void => {}, + .bool => try bytes.append(allocator, @intFromBool(value)), + .int => { + var buf: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &buf, value, .little); + try bytes.appendSlice(allocator, &buf); + }, + .@"enum" => try put(allocator, bytes, @as(u32, @intCast(@backingInt(value)))), + .optional => { + try put(allocator, bytes, value != null); + if (value) |v| try put(allocator, bytes, v); + }, + .array => for (value) |v| { + try put(allocator, bytes, v); + }, + .pointer => |p| { + if (p.size != .slice) @compileError("command records contain slices, never pointers"); + try put(allocator, bytes, std.math.cast(u32, value.len) orelse return error.OutOfMemory); + for (value) |v| try put(allocator, bytes, v); + }, + .@"struct" => |s| inline for (s.field_names) |field| { + try put(allocator, bytes, @field(value, field)); + }, + .@"union" => { + try put(allocator, bytes, std.meta.activeTag(value)); + switch (value) { + inline else => |v| try put(allocator, bytes, v), + } + }, + else => @compileError("unsupported command record field " ++ @typeName(T)), + } +} + +const DecodeError = Allocator.Error || error{InvalidRecord}; +const Reader = struct { + allocator: Allocator, + bytes: []const u8, + + fn take(self: *Reader, count: usize) error{InvalidRecord}![]const u8 { + if (count > self.bytes.len) return error.InvalidRecord; + const result = self.bytes[0..count]; + self.bytes = self.bytes[count..]; + return result; + } + + fn get(self: *Reader, comptime T: type) DecodeError!T { + return switch (@typeInfo(T)) { + .void => {}, + .bool => switch ((try self.take(1))[0]) { + 0 => false, + 1 => true, + else => error.InvalidRecord, + }, + .int => std.mem.readInt(T, (try self.take(@sizeOf(T)))[0..@sizeOf(T)], .little), + .@"enum" => std.enums.fromInt(T, try self.get(u32)) orelse error.InvalidRecord, + .optional => |o| if (try self.get(bool)) try self.get(o.child) else null, + .array => |a| blk: { + var result: T = undefined; + for (&result) |*v| v.* = try self.get(a.child); + break :blk result; + }, + .pointer => |p| blk: { + const count = try self.get(u32); + // Every element of a persisted slice consumes at least a byte. + if (count > self.bytes.len) return error.InvalidRecord; + const result = try self.allocator.alloc(p.child, count); + for (result) |*v| v.* = try self.get(p.child); + break :blk result; + }, + .@"struct" => |s| blk: { + var result: T = undefined; + inline for (s.field_names) |field| @field(result, field) = try self.get(@FieldType(T, field)); + break :blk result; + }, + .@"union" => |u| blk: { + const tag = try self.get(u.tag_type.?); + inline for (u.field_names) |field| { + if (std.mem.eql(u8, @tagName(tag), field)) break :blk @unionInit(T, field, try self.get(@FieldType(T, field))); + } + unreachable; // the decoded union tag names one of its declared fields + }, + else => @compileError("unsupported command record field " ++ @typeName(T)), + }; + } +}; + +pub fn decode(allocator: Allocator, comptime T: type, bytes: []const u8) Allocator.Error!?T { + if (bytes.len < magic.len + 32 or !std.mem.startsWith(u8, bytes, magic)) return null; + const body = bytes[0 .. bytes.len - 32]; + if (!std.mem.eql(u8, &hash(body), bytes[bytes.len - 32 ..])) return null; + var reader = Reader{ .allocator = allocator, .bytes = body[magic.len..] }; + const result = reader.get(T) catch |err| switch (err) { + error.InvalidRecord => return null, + error.OutOfMemory => return error.OutOfMemory, + }; + if (reader.bytes.len != 0) return null; + return result; +} + +/// Paths in source locations and path annotations remain structured. The +/// publication side makes them relative; replay binds them to this invocation. +pub fn mapDiagnosticPaths(allocator: Allocator, diagnostic: *Diagnostic, cwd: []const u8, comptime restore: bool) Allocator.Error!void { + if (diagnostic.file) |path| diagnostic.file = try mapPath(allocator, cwd, path, restore); + try mapElements(allocator, diagnostic.headline, cwd, restore); + try mapElements(allocator, diagnostic.body, cwd, restore); +} + +fn mapPath(allocator: Allocator, cwd: []const u8, path: []const u8, comptime restore: bool) Allocator.Error![]const u8 { + if (path.len == 0) return path; + return if (restore) std.fs.path.resolve(allocator, &.{ cwd, path }) else std.fs.path.relative(allocator, cwd, null, cwd, path); +} + +fn mapElements(allocator: Allocator, elements: []reporting.DocumentElement, cwd: []const u8, comptime restore: bool) Allocator.Error!void { + for (elements) |*element| switch (element.*) { + .annotated => |*a| if (a.annotation == .path) { + a.content = try mapPath(allocator, cwd, a.content, restore); + }, + .source_code_region => |*r| if (r.filename) |path| { + r.filename = try mapPath(allocator, cwd, path, restore); + }, + .source_code_multi_region => |*r| if (r.filename) |path| { + r.filename = try mapPath(allocator, cwd, path, restore); + }, + .source_code_with_underlines => |*r| if (r.display_region.filename) |path| { + r.display_region.filename = try mapPath(allocator, cwd, path, restore); + }, + .source_location => |*r| if (r.filename) |path| { + r.filename = try mapPath(allocator, cwd, path, restore); + }, + .vertical_stack, .horizontal_concat => |children| try mapElements(allocator, children, cwd, restore), + .text, .line_break, .indent, .space, .horizontal_rule, .annotation_start, .annotation_end, .raw, .reflowing_text, .link => {}, + }; +} + +test "completed test records reject every truncated prefix and corrupted content" { + const gpa = std.testing.allocator; + const bytes = try encode(gpa, @as([]const Input, &.{.{ .path = "missing.txt", .state = .missing }})); + defer gpa.free(bytes); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + for (0..bytes.len) |len| try std.testing.expect((try decode(arena.allocator(), []const Input, bytes[0..len])) == null); + const loaded = (try decode(arena.allocator(), []const Input, bytes)).?; + try std.testing.expectEqualStrings("missing.txt", loaded[0].path); + bytes[magic.len + 4] ^= 1; + try std.testing.expect((try decode(arena.allocator(), []const Input, bytes)) == null); + + // Even a checksum-valid malformed length must fail ordinary decoding. + @memset(bytes[magic.len..][0..4], 0xff); + @memcpy(bytes[bytes.len - 32 ..], &hash(bytes[0 .. bytes.len - 32])); + try std.testing.expect((try decode(arena.allocator(), []const Input, bytes)) == null); +} + +test "completed test diagnostics retain structured source and styling across path rebinding" { + const gpa = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const allocator = arena.allocator(); + const cwd = try std.Io.Dir.cwd().realPathFileAlloc(std.testing.io, ".", allocator); + const path = try std.fs.path.join(allocator, &.{ cwd, "Example.roc" }); + var report = try reporting.Report.init(gpa, "Unused Variable", "This binding is unused.", .warning); + defer report.deinit(); + try report.document.addAnnotated(path, .path); + try report.document.addLineBreak(); + try report.addSourceContext(.{ .start_line_idx = 0, .end_line_idx = 0, .start_col_idx = 0, .end_col_idx = 6 }, path, "unused = 1\n", &.{0}); + const original = Diagnostic.fromReport(&report); + const cloned_bytes = try encode(allocator, original); + var portable = (try decode(allocator, Diagnostic, cloned_bytes)).?; + try mapDiagnosticPaths(allocator, &portable, cwd, false); + const portable_bytes = try encode(allocator, portable); + try std.testing.expect(std.mem.find(u8, portable_bytes, cwd) == null); + var restored = (try decode(allocator, Diagnostic, portable_bytes)).?; + try mapDiagnosticPaths(allocator, &restored, cwd, true); + var before = std.Io.Writer.Allocating.init(gpa); + defer before.deinit(); + var after = std.Io.Writer.Allocating.init(gpa); + defer after.deinit(); + const config = reporting.ReportingConfig.initForTesting(); + try original.render(gpa, &before.writer, config); + try restored.render(gpa, &after.writer, config); + try std.testing.expectEqualStrings(before.written(), after.written()); +} diff --git a/src/cli/main.zig b/src/cli/main.zig index acc2ceec9b0..17bc2a7b267 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -12546,6 +12546,7 @@ const CliTestResultItem = struct { inline_failed: u64 = 0, inline_suppressed: bool = false, source_env: ?*const ModuleEnv = null, + source_common: ?*const base.CommonEnv = null, source_path: ?[]const u8 = null, transcript: []const CliTestTranscriptEvent = &.{}, failure_detail: ?[]const u8, @@ -12556,13 +12557,18 @@ const CliTestResultItem = struct { }; const CliModuleTestResult = struct { - env: *const ModuleEnv, + env: *const base.CommonEnv, path: []const u8, results: []const CliTestResultItem, cached: bool, }; -const CliTestSourceModuleMap = std.StringHashMapUnmanaged(BuildEnv.CompiledModuleInfo); +const CliTestSourceModule = struct { + env: ?*const ModuleEnv, + common: *const base.CommonEnv, + path: []const u8, +}; +const CliTestSourceModuleMap = std.AutoHashMapUnmanaged([32]u8, CliTestSourceModule); const CliTestRunSummary = struct { passed: u32 = 0, @@ -12825,7 +12831,281 @@ fn nameCliTestPlanSymbols(allocator: Allocator, entries: []CliTestPlanEntry) All return buffer; } -const cli_test_cache_magic = "ROC_TEST_RESULTS_V9"; +const completed_test = @import("completed_test.zig"); + +/// Owned for the command lifetime; finalization supplies semantic payloads in +/// exactly the order in which it emits them on the ordinary path. +const CliTestDebugCapture = struct { + allocator: Allocator, + messages: std.ArrayList([]const u8) = .empty, + + fn observe(context: *anyopaque, message: []const u8) Allocator.Error!void { + const self: *@This() = @ptrCast(@alignCast(context)); + try self.messages.append(self.allocator, try self.allocator.dupe(u8, message)); + } +}; + +/// Resolution still runs on a complete hit. Hash the resolver's explicit +/// graph, rather than discovering a closure from cached checked artifacts. +fn cliCompletedTestSeed(allocator: Allocator, build: *BuildEnv, args: cli_args.TestArgs) Allocator.Error![32]u8 { + const Edge = struct { alias: []const u8, target: []const u8 }; + const Package = struct { name: []const u8, kind: []const u8, root: []const u8, state: ?compile.watch_inputs.State, edges: []Edge }; + var packages = try allocator.alloc(Package, build.packages.count()); + var it = build.packages.valueIterator(); + var index: usize = 0; + while (it.next()) |pkg| : (index += 1) { + const edges = try allocator.alloc(Edge, pkg.shorthands.count()); + var aliases = pkg.shorthands.iterator(); + var edge_index: usize = 0; + while (aliases.next()) |alias| : (edge_index += 1) edges[edge_index] = .{ .alias = alias.key_ptr.*, .target = alias.value_ptr.name }; + std.mem.sort(Edge, edges, {}, struct { + fn less(_: void, a: Edge, b: Edge) bool { + return std.mem.lessThan(u8, a.alias, b.alias); + } + }.less); + packages[index] = .{ .name = pkg.name, .kind = @tagName(pkg.kind), .root = pkg.root_file, .state = pkg.root_file_state, .edges = edges }; + } + std.mem.sort(Package, packages, {}, struct { + fn less(_: void, a: Package, b: Package) bool { + return std.mem.lessThan(u8, a.name, b.name); + } + }.less); + const bytes = try completed_test.encode(allocator, .{ + .command = @as([]const u8, "test-explicit-owned-roots-v1"), + .compiler = @as([]const u8, build_options.compiler_compatibility_id), + .strategy = currentRuntimeSpecializationStrategy(args.specialization_strategy), + .opt = args.opt, + .target = RocTarget.detectNative(), + .cwd = build.cwd, + .root = build.discovered_root_abs, + .entry = build.entry_module_abs, + .root_url = args.root_source_url, + .main_url = args.main_source_url, + .packages = packages, + }); + return completed_test.hash(bytes); +} + +fn cliCompletedTestDir(allocator: Allocator, manager: *CacheManager) ![]const u8 { + const test_dir = try manager.config.getTestCacheDir(allocator); + return std.fs.path.join(allocator, &.{ test_dir, "commands" }); +} + +fn cliCompletedTestKey(allocator: Allocator, seed: [32]u8, plan: completed_test.Plan) Allocator.Error![32]u8 { + return completed_test.hash(try completed_test.encode(allocator, .{ .seed = seed, .plan = plan })); +} + +/// Read exactly the paths recorded by producers. Retain the bytes used for +/// hashing so replay cannot bind reporting to a later version of a file. +fn refreshCliCompletedTestInputs( + allocator: Allocator, + build: *BuildEnv, + plan: completed_test.Plan, + sources: *std.StringHashMapUnmanaged([]const u8), +) Allocator.Error!bool { + for (plan.inputs) |*input| { + const path = try std.fs.path.resolve(allocator, &.{ build.cwd, input.path }); + const bytes = build.filesystem.readFile(path, allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.FileNotFound => { + input.state = .missing; + continue; + }, + error.AccessDenied, error.StreamTooLong, error.IoError => return false, + }; + input.state = .{ .hash = completed_test.hash(bytes) }; + try sources.put(allocator, input.path, bytes); + } + for (plan.resolutions) |*resolution| { + const path = try std.fs.path.resolve(allocator, &.{ build.cwd, resolution.path }); + const target = build.filesystem.canonicalize(path, allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.FileNotFound, error.AccessDenied, error.IoError => return false, + }; + resolution.target = try std.fs.path.relative(allocator, build.cwd, null, build.cwd, target); + } + return true; +} + +/// A semantic outcome at the command boundary, with all payloads admitted and +/// source lifetimes bound. No producer is run after this value is returned. +const CliCompletedTest = struct { + record: completed_test.Record, + inputs: []const completed_test.Input, + module_results: []CliModuleTestResult, + total: CliTestRunSummary, + + fn deinit(self: *@This(), allocator: Allocator) void { + for (self.module_results) |module| deinitCliTestResultItems(allocator, module.results); + } +}; + +fn loadCliCompletedTest(ctx: *CliCtx, arena: Allocator, build: *BuildEnv, seed: [32]u8) !?CliCompletedTest { + const manager = build.cache_manager orelse return null; + const directory = try cliCompletedTestDir(arena, manager); + const plan_bytes = manager.loadRawBytes(seed, directory) orelse return null; + defer ctx.gpa.free(plan_bytes); + const plan = (try completed_test.decode(arena, completed_test.Plan, plan_bytes)) orelse return null; + var sources = std.StringHashMapUnmanaged([]const u8){}; + if (!try refreshCliCompletedTestInputs(arena, build, plan, &sources)) return null; + const key = try cliCompletedTestKey(arena, seed, plan); + const record_bytes = manager.loadRawBytes(key, directory) orelse return null; + defer ctx.gpa.free(record_bytes); + const record = (try completed_test.decode(arena, completed_test.Record, record_bytes)) orelse return null; + if (!std.mem.eql(u8, &key, &record.input_key)) return null; + + var source_modules = CliTestSourceModuleMap{}; + const bound_sources = try arena.alloc(CliTestSourceModule, record.sources.len); + for (record.sources, bound_sources) |source, *bound| { + const bytes = sources.get(source.path) orelse return null; + const normalized = try base.source_utils.normalizeLineEndingsRealloc(arena, try arena.dupe(u8, bytes)); + const common = try arena.create(base.CommonEnv); + common.* = try base.CommonEnv.init(arena, normalized); + try common.calcLineStarts(arena); + bound.* = .{ .env = null, .common = common, .path = try std.fs.path.resolve(arena, &.{ build.cwd, source.path }) }; + const entry = try source_modules.getOrPut(arena, source.name); + if (entry.found_existing) return null; + entry.value_ptr.* = bound.*; + } + const test_dir = try manager.config.getTestCacheDir(arena); + var results = std.ArrayList(CliModuleTestResult).empty; + var accepted = false; + defer if (!accepted) { + for (results.items) |module| deinitCliTestResultItems(ctx.gpa, module.results); + }; + for (record.modules) |module| { + if (module.source >= bound_sources.len) return null; + const data = manager.loadRawBytes(module.key, test_dir) orelse return null; + defer ctx.gpa.free(data); + if (!std.mem.eql(u8, &completed_test.hash(data), &module.payload_hash)) return null; + const shapes = try arena.alloc(CliTestRootShape, module.roots.len); + for (shapes, module.roots) |*shape, root| shape.* = .{ .order = root.order, .region = root.region }; + const cached = (try decodeCliTestResults(ctx.gpa, data, &source_modules, shapes)) orelse return null; + errdefer deinitCliTestResultItems(ctx.gpa, cached.results); + const source = bound_sources[module.source]; + for (cached.results) |result| { + const common = result.source_common orelse source.common; + if (result.region.end.offset > common.source.len) { + deinitCliTestResultItems(ctx.gpa, cached.results); + return null; + } + } + try results.append(arena, .{ .env = source.common, .path = source.path, .results = cached.results, .cached = true }); + } + var total = CliTestRunSummary{ .modules_with_tests = record.modules_with_tests, .cached_modules = record.modules_with_tests }; + try coalesceInlineExpectResults(ctx.gpa, results.items, &total); + for (record.diagnostics) |*diagnostic| { + if (diagnostic.severity.isError()) return null; + try completed_test.mapDiagnosticPaths(arena, diagnostic, build.cwd, true); + } + accepted = true; + return .{ .record = record, .inputs = plan.inputs, .module_results = results.items, .total = total }; +} + +fn storeCliCompletedTest( + ctx: *CliCtx, + arena: Allocator, + build: *BuildEnv, + seed: [32]u8, + args: cli_args.TestArgs, + modules: []const BuildEnv.CompiledModuleInfo, + test_plan: *const CliTestPlan, + module_results: []const CliModuleTestResult, + debug_messages: []const []const u8, + diagnostics: []const BuildEnv.DrainedModuleReports, +) !void { + const manager = build.cache_manager orelse return; + for (module_results) |module| for (module.results) |result| { + if (!result.cacheable or result.result == .compiler_error) return; + }; + const consumed = try build.collectConsumedInputStates(arena); + const inputs = try arena.alloc(completed_test.Input, consumed.len); + for (inputs, consumed) |*input, source| { + if (source.state == .unreadable) return; + input.* = .{ .path = try std.fs.path.relative(arena, build.cwd, null, build.cwd, source.path), .state = source.state }; + } + std.mem.sort(completed_test.Input, inputs, {}, struct { + fn less(_: void, a: completed_test.Input, b: completed_test.Input) bool { + return std.mem.lessThan(u8, a.path, b.path); + } + }.less); + var resolutions = std.ArrayList(completed_test.Resolution).empty; + // These are the physical identities the import resolver actually admitted, + // not a fresh reconstruction of what checking might have resolved. + var packages = build.coordinator.?.packages.valueIterator(); + while (packages.next()) |pkg| { + var paths = pkg.*.physical_module_paths.iterator(); + while (paths.next()) |path| { + for (pkg.*.modules.items) |module| { + if (!std.mem.eql(u8, module.name, path.value_ptr.*)) continue; + try resolutions.append(arena, .{ + .path = try std.fs.path.relative(arena, build.cwd, null, build.cwd, module.path), + .target = try std.fs.path.relative(arena, build.cwd, null, build.cwd, path.key_ptr.*), + }); + break; + } + } + } + std.mem.sort(completed_test.Resolution, resolutions.items, {}, struct { + fn less(_: void, a: completed_test.Resolution, b: completed_test.Resolution) bool { + return std.mem.lessThan(u8, a.path, b.path); + } + }.less); + const plan = completed_test.Plan{ .inputs = inputs, .resolutions = resolutions.items }; + const key = try cliCompletedTestKey(arena, seed, plan); + const plan_bytes = try completed_test.encode(arena, plan); + var current_sources = std.StringHashMapUnmanaged([]const u8){}; + if (!try refreshCliCompletedTestInputs(arena, build, plan, ¤t_sources)) return; + if (!std.mem.eql(u8, &key, &try cliCompletedTestKey(arena, seed, plan))) return; + + const sources = try arena.alloc(completed_test.Source, modules.len); + for (sources, modules) |*source, module| source.* = .{ + .name = completed_test.hash(module.semantic.env.qualifiedModuleName()), + .path = try std.fs.path.relative(arena, build.cwd, null, build.cwd, module.path), + }; + const result_modules = try arena.alloc(completed_test.Module, test_plan.modules.len); + const test_dir = try manager.config.getTestCacheDir(arena); + for (result_modules, test_plan.modules) |*record, planned| { + const result_key = cliTestCacheKey(planned.artifact.key, currentRuntimeSpecializationStrategy(args.specialization_strategy), args.opt); + // The per-module writer has already published. Read and hash that exact + // durable payload; failed writes never produce a complete manifest. + const data = manager.loadRawBytes(result_key, test_dir) orelse return; + defer ctx.gpa.free(data); + const roots = try arena.alloc(completed_test.Root, planned.test_roots.len); + for (roots, planned.test_roots) |*shape, root| shape.* = .{ .order = root.order, .region = testRootRegion(planned.module.semantic.env, root.source) }; + const source_index = for (modules, 0..) |module, i| { + if (module.semantic.env == planned.module.semantic.env) break i; + } else base.invariant("test plan module absent from command sources", .{}); + record.* = .{ .source = std.math.cast(u32, source_index) orelse return error.OutOfMemory, .key = result_key, .payload_hash = completed_test.hash(data), .roots = roots }; + } + var reports = std.ArrayList(completed_test.Diagnostic).empty; + for (diagnostics) |module| for (module.reports) |*report| { + if (report.severity.isError()) return; + try reports.append(arena, completed_test.Diagnostic.fromReport(report)); + }; + // Clone the structured documents into record storage before rebasing paths. + const report_bytes = try completed_test.encode(arena, reports.items); + const portable_reports = (try completed_test.decode(arena, []completed_test.Diagnostic, report_bytes)).?; + for (portable_reports) |*report| try completed_test.mapDiagnosticPaths(arena, report, build.cwd, false); + const record = completed_test.Record{ + .input_key = key, + .sources = sources, + .modules = result_modules, + .debug_messages = debug_messages, + .diagnostics = portable_reports, + .modules_with_tests = test_plan.modules_with_tests, + }; + const bytes = try completed_test.encode(arena, record); + const directory = try cliCompletedTestDir(arena, manager); + manager.storeRawBytes(key, bytes, directory, "completed test command"); + // Publish the input plan only once its complete outcome is readable. + const durable = manager.loadRawBytes(key, directory) orelse return; + defer ctx.gpa.free(durable); + if (!std.mem.eql(u8, bytes, durable)) return; + manager.storeRawBytes(seed, plan_bytes, directory, "test command inputs"); +} + +const cli_test_cache_magic = "ROC_TEST_RESULTS_V10"; fn appendU32(bytes: *std.ArrayList(u8), allocator: std.mem.Allocator, value: u32) Allocator.Error!void { var buf: [4]u8 = undefined; @@ -12941,9 +13221,9 @@ fn storeCliTestResultsInCache( base.invariant("inline test cache result has no declaring module", .{}); } if (result.source_env) |source_env| { - const qualified_name = source_env.qualifiedModuleName(); - try appendU32(&bytes, ctx.gpa, @intCast(qualified_name.len)); - try bytes.appendSlice(ctx.gpa, qualified_name); + const source_identity = completed_test.hash(source_env.qualifiedModuleName()); + try appendU32(&bytes, ctx.gpa, source_identity.len); + try bytes.appendSlice(ctx.gpa, &source_identity); } else { try appendU32(&bytes, ctx.gpa, 0); } @@ -12973,6 +13253,9 @@ fn storeCliTestResultsInCache( } } + const payload_hash = completed_test.hash(bytes.items); + try bytes.appendSlice(ctx.gpa, &payload_hash); + const entries_dir = try manager.config.getTestCacheDir(ctx.gpa); defer ctx.gpa.free(entries_dir); manager.storeRawBytes(cliTestCacheKey(artifact.key, specialization_strategy, opt), bytes.items, entries_dir, artifact.moduleEnvConst().module_name); @@ -13045,6 +13328,28 @@ fn loadCachedCliTestResults( const data = manager.loadRawBytes(cliTestCacheKey(artifact.key, specialization_strategy, opt), entries_dir) orelse return null; defer ctx.gpa.free(data); + const shapes = try cliTestRootShapes(ctx.gpa, module.semantic.env, test_roots); + defer ctx.gpa.free(shapes); + return decodeCliTestResults(ctx.gpa, data, source_modules, shapes); +} + +const CliTestRootShape = struct { order: u32, region: base.Region }; + +fn cliTestRootShapes(allocator: Allocator, env: *const ModuleEnv, roots: []const check.CheckedArtifact.RootRequest) Allocator.Error![]CliTestRootShape { + const shapes = try allocator.alloc(CliTestRootShape, roots.len); + for (shapes, roots) |*shape, root| shape.* = .{ .order = root.order, .region = testRootRegion(env, root.source) }; + return shapes; +} + +fn decodeCliTestResults( + allocator: Allocator, + encoded: []const u8, + source_modules: *const CliTestSourceModuleMap, + test_roots: []const CliTestRootShape, +) Allocator.Error!?CliCachedModuleTestResults { + if (encoded.len < 32) return null; + const data = encoded[0 .. encoded.len - 32]; + if (!std.mem.eql(u8, &completed_test.hash(data), encoded[encoded.len - 32 ..])) return null; var offset: usize = 0; if (data.len < cli_test_cache_magic.len) return null; if (!std.mem.eql(u8, data[0..cli_test_cache_magic.len], cli_test_cache_magic)) return null; @@ -13057,8 +13362,8 @@ fn loadCachedCliTestResults( var results_owned_by_module = false; defer { if (!results_owned_by_module) { - deinitCliTestResultItemPayloads(ctx.gpa, results.items); - results.deinit(ctx.gpa); + deinitCliTestResultItemPayloads(allocator, results.items); + results.deinit(allocator); } } @@ -13073,19 +13378,21 @@ fn loadCachedCliTestResults( const inline_failed = readU64(data, &offset) orelse return null; if (inline_expect != (inline_passed +| inline_failed != 0)) return null; const source_name_len: usize = @intCast(readU32(data, &offset) orelse return null); - if (inline_expect and source_name_len == 0) return null; + if (inline_expect and source_name_len != 32) return null; if (!inline_expect and source_name_len != 0) return null; if (offset + source_name_len > data.len) return null; const source_name = data[offset..][0..source_name_len]; offset += source_name_len; var source_env: ?*const ModuleEnv = null; + var source_common: ?*const base.CommonEnv = null; var source_path: ?[]const u8 = null; if (source_name.len != 0) { - if (source_modules.get(source_name)) |source_module| { - source_env = source_module.semantic.env; + if (source_modules.get(source_name[0..32].*)) |source_module| { + source_env = source_module.env; + source_common = source_module.common; source_path = source_module.path; } - if (source_env == null) return null; + if (source_common == null) return null; } const order = readU32(data, &offset) orelse return null; const region_start = readU32(data, &offset) orelse return null; @@ -13105,16 +13412,17 @@ fn loadCachedCliTestResults( else => return null, }; if (inline_expect and (result == .failed) != (inline_failed != 0)) return null; - const transcript = (try loadCliTestTranscriptEvents(ctx.gpa, data, &offset)) orelse return null; + const transcript = (try loadCliTestTranscriptEvents(allocator, data, &offset)) orelse return null; var transcript_owned_by_result = false; defer { - if (!transcript_owned_by_result) deinitCliTestTranscriptEvents(ctx.gpa, transcript); + if (!transcript_owned_by_result) deinitCliTestTranscriptEvents(allocator, transcript); } const has_message = readU8(data, &offset) orelse return null; + if (has_message > 1) return null; const region = base.Region.from_raw_offsets(region_start, region_end); - if (!inline_expect and !region.eq(testRootRegion(module.semantic.env, test_roots[root_index - 1].source))) return null; + if (!inline_expect and !region.eq(test_roots[root_index - 1].region)) return null; var visibility: CliTestFailureDetailVisibility = .always; const message = if (has_message == 0) null else blk: { @@ -13128,12 +13436,12 @@ fn loadCachedCliTestResults( const message_len = readU32(data, &offset) orelse return null; const message_len_usize: usize = @intCast(message_len); if (offset + message_len_usize > data.len) return null; - const message = try ctx.gpa.dupe(u8, data[offset..][0..message_len_usize]); + const message = try allocator.dupe(u8, data[offset..][0..message_len_usize]); offset += message_len_usize; break :blk message; }; - try results.append(ctx.gpa, .{ + try results.append(allocator, .{ .result = result, .order = order, .region = region, @@ -13141,6 +13449,7 @@ fn loadCachedCliTestResults( .inline_passed = inline_passed, .inline_failed = inline_failed, .source_env = source_env, + .source_common = source_common, .source_path = source_path, .transcript = transcript, .failure_detail = message, @@ -13153,9 +13462,9 @@ fn loadCachedCliTestResults( var summary = summarizeTestResults(results.items); summary.cached_modules = 1; - const owned_results = try results.toOwnedSlice(ctx.gpa); + const owned_results = try results.toOwnedSlice(allocator); errdefer { - deinitCliTestResultItems(ctx.gpa, owned_results); + deinitCliTestResultItems(allocator, owned_results); } results_owned_by_module = true; return .{ @@ -13233,7 +13542,7 @@ fn buildCliTestPlan( const results = try checking_results.toOwnedSlice(ctx.gpa); errdefer ctx.gpa.free(results); try module_results.append(ctx.gpa, .{ - .env = module.semantic.env, + .env = &module.semantic.env.common, .path = module.path, .results = results, .cached = false, @@ -14391,8 +14700,8 @@ fn appendInlineExpectResults( var source_path: ?[]const u8 = null; if (!site.loc.hasLocation()) base.invariant("test expect site has no source location", .{}); const qualified_name = store.sourceFileQualifiedName(site.loc.file); - if (source_modules.get(qualified_name)) |source_module| { - source_env = source_module.semantic.env; + if (source_modules.get(completed_test.hash(qualified_name))) |source_module| { + source_env = source_module.env; source_path = source_module.path; } if (source_env == null) { @@ -14647,7 +14956,7 @@ fn runCheckedArtifactTests( try storeCliTestResultsInCache(ctx, cache_manager, artifact, specialization_strategy, opt, results.items); try module_results.append(ctx.gpa, .{ - .env = module.semantic.env, + .env = &module.semantic.env.common, .path = module.path, .results = try ctx.gpa.dupe(CliTestResultItem, results.items), .cached = false, @@ -14824,7 +15133,7 @@ fn appendPlannedModuleResult( var results_owned_by_module = false; errdefer if (!results_owned_by_module) deinitCliTestResultItems(ctx.gpa, results); try module_results.append(ctx.gpa, .{ - .env = planned.module.semantic.env, + .env = &planned.module.semantic.env.common, .path = planned.module.path, .results = results, .cached = cached, @@ -14833,7 +15142,7 @@ fn appendPlannedModuleResult( } const InlineExpectResultKey = struct { - env: *const ModuleEnv, + env: *const base.CommonEnv, region_start: u32, region_end: u32, }; @@ -14852,7 +15161,7 @@ fn coalesceInlineExpectResults( for (module_results) |module_result| { for (@constCast(module_result.results)) |*result| { if (!result.inline_expect) continue; - const source_env = result.source_env orelse { + const source_env = result.source_common orelse if (result.source_env) |env| &env.common else { base.invariant("inline test result has no declaring module", .{}); }; const key: InlineExpectResultKey = .{ @@ -16061,6 +16370,11 @@ fn runWatchCommand(ctx: *CliCtx, arg0: []const u8, command: WatchCommand) WatchC } fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestError!void { + defer if (base.dev_diagnostics.isSet(.test_command_work)) { + inline for (@typeInfo(base.dev_diagnostics.TestWork).@"enum".field_names) |field| { + ctx.io.stderr().print("test-work {s}={d}\n", .{ field, base.dev_diagnostics.testWorkCount(@field(base.dev_diagnostics.TestWork, field)) }) catch {}; + } + }; const trace = tracy.trace(@src()); defer trace.end(); @@ -16089,6 +16403,12 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr reporter.start(); errdefer reporter.fail(); + var command_arena = std.heap.ArenaAllocator.init(ctx.gpa); + defer command_arena.deinit(); + const command_allocator = command_arena.allocator(); + const complete_cache_enabled = !args.no_cache and args.opt == .dev; + var debug_capture = CliTestDebugCapture{ .allocator = command_allocator }; + // --- Normal compilation path --- var build_env = try initCliBuildEnv(ctx, .{ @@ -16097,7 +16417,7 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr .verbose_cache = args.verbose, .timings = args.timings, .resolution_config = resolutionConfigFromLimits(args.resolve_limits), - .track_watch_inputs = args.watch_inputs_file != null, + .track_watch_inputs = complete_cache_enabled or args.watch_inputs_file != null, .release_checked_solver_types = true, .compile_time_pack_publisher = .{ .command = .test_run, .opt = @tagName(args.opt) }, .root_source_url = args.root_source_url, @@ -16109,6 +16429,7 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr }); defer build_env.deinit(); build_env.defer_post_check = true; + if (complete_cache_enabled) build_env.debug_observer = .{ .context = &debug_capture, .observe = CliTestDebugCapture.observe }; // `roc test` runs the file's top-level `expect`s and nothing else, so the // file needs no entrypoint: a headerless file that is neither a type module @@ -16133,6 +16454,39 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr }; reporter.end(); + const complete_seed = if (complete_cache_enabled) try cliCompletedTestSeed(command_allocator, &build_env, args) else null; + if (complete_seed) |seed| { + if (try loadCliCompletedTest(ctx, command_allocator, &build_env, seed)) |loaded| { + var completed = loaded; + defer completed.deinit(ctx.gpa); + reporter.recordCounters("Completed test command", &.{.{ .name = "Hits", .count = 1 }}); + reporter.finish(); + for (completed.record.debug_messages) |message| try stderr.print("[dbg] {s}\n", .{message}); + var warnings: usize = 0; + for (completed.record.diagnostics) |diagnostic| { + try diagnostic.render(ctx.gpa, stderr, ctx.reportConfig(.stderr)); + warnings += 1; + } + try renderTestResultBodies(ctx.gpa, stdout, stderr, completed.module_results, args.verbose, testReportingConfig(ctx)); + if (args.watch_inputs_file) |path| { + const compiler_inputs = try command_allocator.alloc(compile.watch_inputs.Input, completed.inputs.len); + for (compiler_inputs, completed.inputs) |*input, stored| input.* = .{ + .path = try std.fs.path.resolve(command_allocator, &.{ build_env.cwd, stored.path }), + .state = stored.state, + }; + var input_set = try collectWatchInputSetFromCompilerInputs(ctx, compiler_inputs, extra_paths); + defer input_set.deinit(ctx); + try writeWatchInputSetFile(ctx, path, &input_set); + } + const elapsed: u64 = @intCast(std.Io.Timestamp.now(ctx.io.std_io, .awake).nanoseconds - start_time); + try writeCliTestRunSummary(if (completed.total.allPassed()) stdout else stderr, completed.total, elapsed, true, testReportingConfig(ctx).shouldUseColors()); + if (!completed.total.allPassed()) return error.TestsFailed; + exitOnWarnings(ctx, warnings); + return; + } + reporter.recordCounters("Completed test command", &.{.{ .name = "Misses", .count = 1 }}); + } + reporter.begin("Type Checking"); build_env.compileDiscovered() catch |err| { _ = try build_env.renderDiagnostics(stderr, ctx.reportConfig(.stderr)); @@ -16151,11 +16505,11 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr defer source_modules.deinit(ctx.gpa); for (modules) |module| { const qualified_name = module.semantic.env.qualifiedModuleName(); - const entry = try source_modules.getOrPut(ctx.gpa, qualified_name); + const entry = try source_modules.getOrPut(ctx.gpa, completed_test.hash(qualified_name)); if (entry.found_existing) { base.invariant("compiled module plan contains duplicate source module {s}", .{qualified_name}); } - entry.value_ptr.* = module; + entry.value_ptr.* = .{ .env = module.semantic.env, .common = &module.semantic.env.common, .path = module.path }; } const report_config = testReportingConfig(ctx); @@ -16213,7 +16567,13 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr _ = try build_env.renderDiagnostics(stderr, ctx.reportConfig(.stderr)); return err; }; - const diag = try build_env.renderDiagnostics(stderr, ctx.reportConfig(.stderr)); + const command_diagnostics = try build_env.drainReports(); + defer build_env.freeDrainedReports(command_diagnostics); + var diag = BuildEnv.RenderDiagnosticsResult{ .errors = 0, .warnings = 0 }; + for (command_diagnostics) |module| for (module.reports) |*report| { + if (report.severity.isError()) diag.errors += 1 else diag.warnings += 1; + reporting.renderReportToTerminal(report, stderr, reporting.ColorUtils.getPaletteForConfig(ctx.reportConfig(.stderr)), ctx.reportConfig(.stderr)) catch {}; + }; // `roc test` files the code its compile-time evaluation ran for later // compilations as a check does, under the module the object cache loads // a program's packs from. @@ -16376,6 +16736,9 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr } }, }; + if (complete_seed) |seed| if (diag.errors == 0) { + try storeCliCompletedTest(ctx, command_allocator, &build_env, seed, args, modules, &test_plan, module_results.items, debug_capture.messages.items, command_diagnostics); + }; try coalesceInlineExpectResults(ctx.gpa, module_results.items, &total); total.modules_with_tests = test_plan.modules_with_tests; total.diagnostic_errors = diag.errors; @@ -16522,7 +16885,7 @@ fn renderCliTestTranscriptEventRecords( } const CliTestRenderEntry = struct { - env: *const ModuleEnv, + env: *const base.CommonEnv, path: []const u8, result: *const CliTestResultItem, }; @@ -16906,7 +17269,7 @@ const CliOptimizedLiveTestOutput = struct { }; self.owned_results[result_index] = copied; self.coordinator.publishFinished(result_index, .{ - .env = env, + .env = &env.common, .path = path, .result = &self.owned_results[result_index].?, }) catch |err| { @@ -16947,7 +17310,7 @@ const CliOptimizedLiveTestOutput = struct { self.owned_results[result_index] = result; self.coordinator.publishFinished(result_index, .{ - .env = run.env, + .env = &run.env.common, .path = run.path, .result = &self.owned_results[result_index].?, }) catch |err| { @@ -17018,7 +17381,7 @@ test "test transcript coordinator streams and buffers in plan order" { ); defer coordinator.deinit(); - const dummy_env: *const ModuleEnv = undefined; + const dummy_env: *const base.CommonEnv = undefined; const event_zero = CliTestTranscriptEvent{ .stream = .stderr, .kind = .dbg, .payload = "zero" }; const event_one_a = CliTestTranscriptEvent{ .stream = .stderr, .kind = .dbg, .payload = "one-a" }; @@ -17069,7 +17432,7 @@ test "test transcript coordinator spills later buffered entries to isolated temp var stderr_body = std.Io.Writer.Allocating.init(allocator); defer stderr_body.deinit(); - const dummy_env: *const ModuleEnv = undefined; + const dummy_env: *const base.CommonEnv = undefined; const event_zero = CliTestTranscriptEvent{ .stream = .stderr, .kind = .dbg, .payload = "first" }; const event_one_spilled = CliTestTranscriptEvent{ .stream = .stderr, .kind = .dbg, .payload = "later-spilled" }; const event_one_live = CliTestTranscriptEvent{ .stream = .stderr, .kind = .dbg, .payload = "later-live" }; @@ -17131,7 +17494,7 @@ fn renderCliTestResultEntry( ); } try renderCliTestTranscriptEvents(stdout_body, stderr_body, entry.result.transcript[transcript_events_already_rendered..]); - const source_env = entry.result.source_env orelse entry.env; + const source_env = entry.result.source_common orelse if (entry.result.source_env) |env| &env.common else entry.env; const source_path = entry.result.source_path orelse entry.path; var inline_detail: ?[]u8 = null; defer if (inline_detail) |detail| allocator.free(detail); @@ -17269,7 +17632,7 @@ fn printTestProblem( allocator: Allocator, stderr: *std.Io.Writer, path: []const u8, - env: *const ModuleEnv, + env: *const base.CommonEnv, region_info: base.RegionInfo, label: []const u8, severity: reporting.Severity, @@ -17281,7 +17644,7 @@ fn printTestProblem( const src = env.getSourceAll(); const doc_comment: ?[]const u8 = blk: { - const line_starts = env.getLineStarts(); + const line_starts = env.getLineStartsAll(); const curr_line_start_idx = region_info.start_line_idx; const curr_line_start = line_starts[curr_line_start_idx]; const prev_line_start = if (curr_line_start_idx > 0) line_starts[curr_line_start_idx - 1] else break :blk null; @@ -17309,7 +17672,7 @@ fn printTestProblem( var report = try reporting.Report.init(allocator, label, headline, severity); defer report.deinit(); - try report.addSourceContext(region_info, path, src, env.getLineStarts()); + try report.addSourceContext(region_info, path, src, env.getLineStartsAll()); const should_print_detail = switch (failure_detail_visibility) { .always => true, diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 46de63e39eb..95e6eecabeb 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -503,6 +503,7 @@ const CustomCase = enum { build_interpreter_owns_intermediates, build_glibc_target_non_linux_error, build_windows_shared_library, + completed_test_command, cache_passing_results, cache_failing_results, cache_invalidated_by_source_change, @@ -588,6 +589,7 @@ const forced_ice_skip: Skip = if (builtin.mode == .debug) .never else .{ .always /// honors the developer-diagnostic environment variables. Each case listed /// here sets one of them and checks what it changes or prints. const dev_diagnostics_cases = [_]CustomCase{ + .completed_test_command, .package_dev_packs, .native_build_artifact_round_trip, .native_build_pack_objects, @@ -3627,6 +3629,7 @@ const subcommand_cases = [_]CliCase{ .{ .id = 0, .suite = .subcommands, .name = "issue 10951: explicit empty wasm32 exports are accepted", .backend = .dev, .body = .{ .command = .{ .args = &.{ "build", "--target=wasm32", "--opt=dev", "--no-cache" }, .roc_file = "test/wasm/issue_10951_wasm_exports/empty_exports/app.roc", .exit = .success, .contains = &.{.{ .stream = .stdout, .text = "successfully building" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "Missing Wasm Exports" }, .{ .stream = .stderr, .text = "linker failed" }, .{ .stream = .stderr, .text = "panic" } } } } }, .{ .id = 0, .suite = .subcommands, .name = "roc build glibc target gives helpful error on non-Linux", .body = .{ .custom = .build_glibc_target_non_linux_error } }, .{ .id = 0, .suite = .subcommands, .name = "roc build Shared output links a Windows DLL", .body = .{ .custom = .build_windows_shared_library } }, + .{ .id = 0, .suite = .subcommands, .name = "issue 12589: complete dev test outcomes bypass compilation and replay structured observations", .timeout_ms = 600_000, .body = .{ .custom = .completed_test_command } }, .{ .id = 0, .suite = .subcommands, .name = "roc test caches passing results (interpreter)", .backend = .interpreter, .body = .{ .custom = .cache_passing_results } }, .{ .id = 0, .suite = .subcommands, .name = "roc test caches passing results (dev)", .backend = .dev, .body = .{ .custom = .cache_passing_results } }, .{ .id = 0, .suite = .subcommands, .name = "roc test caches failing results (interpreter)", .backend = .interpreter, .body = .{ .custom = .cache_failing_results } }, @@ -5270,6 +5273,7 @@ fn runCustomCase( .build_interpreter_owns_intermediates => customBuildInterpreterOwnsIntermediates(io, allocator, &env, &timer, timeout_ms), .build_glibc_target_non_linux_error => customGlibcTargetNonLinux(io, allocator, &env, &timer, timeout_ms), .build_windows_shared_library => customWindowsSharedLibrary(io, allocator, &env, &timer, timeout_ms), + .completed_test_command => customCompletedTestCommand(io, allocator, &env, &timer, timeout_ms), .cache_passing_results => customCachePassingResults(io, allocator, &env, &timer, timeout_ms, spec.backend orelse .interpreter), .cache_failing_results => customCacheFailingResults(io, allocator, &env, &timer, timeout_ms, spec.backend orelse .interpreter), .cache_invalidated_by_source_change => customCacheInvalidated(io, allocator, &env, &timer, timeout_ms, spec.backend orelse .interpreter), @@ -13530,6 +13534,185 @@ fn customPipelineParitySharedCache(io: std.Io, allocator: Allocator, env: *const return null; } +fn customCompletedTestCommand(io: std.Io, allocator: Allocator, env: *const CaseEnv, timer: *harness.Timer, timeout_ms: u64) ?TestResult { + const dir = createWorkSubdir(io, allocator, env, "completed-command") catch |err| + return customInfraFailure(allocator, timer, "fixture directory: {}", .{err}); + var original_main: []const u8 = ""; + var original_helper: []const u8 = ""; + for ([_][]const u8{ "Main.roc", "Helper.roc", "input.txt" }) |name| { + const path = std.fs.path.join(allocator, &.{ "test/cli/completed_test", name }) catch |err| + return customInfraFailure(allocator, timer, "fixture path: {}", .{err}); + const absolute = absoluteFromProjectRoot(allocator, path) catch |err| + return customInfraFailure(allocator, timer, "fixture path: {}", .{err}); + const source = std.Io.Dir.cwd().readFileAlloc(io, absolute, allocator, .limited(1024 * 1024)) catch |err| + return customInfraFailure(allocator, timer, "fixture read: {}", .{err}); + if (writeCaseFile(io, allocator, timer, dir, name, source)) |failure| return failure; + if (std.mem.eql(u8, name, "Main.roc")) original_main = source; + if (std.mem.eql(u8, name, "Helper.roc")) original_helper = source; + } + const main_path = std.fs.path.join(allocator, &.{ dir, "Main.roc" }) catch |err| + return customInfraFailure(allocator, timer, "main path: {}", .{err}); + const command = CommandCase{ .args = &.{ "test", "--opt=dev", "--verbose" }, .roc_file = main_path, .exit = .{ .code = 1 }, .contains = &.{ + .{ .stream = .stderr, .text = "compile-time observation" }, + .{ .stream = .stderr, .text = "test observation" }, + .{ .stream = .stderr, .text = "unused" }, + } }; + const cold = switch (captureRocRun(io, allocator, env, timer, timeout_ms, command)) { + .result => |result| result, + .failure => |failure| return failure, + }; + for ([_][]const u8{ "warm", "uncached" }) |label| { + var current = command; + if (std.mem.eql(u8, label, "uncached")) current.args = &.{ "test", "--opt=dev", "--verbose", "--no-cache" }; + const result = switch (captureRocRun(io, allocator, env, timer, timeout_ms, current)) { + .result => |result| result, + .failure => |failure| return failure, + }; + if (expectSamePreSummaryBytes(allocator, timer, "cold", cold, label, result)) |failure| return failure; + } + var counted = CaseEnv{ .dirs = env.dirs, .env_map = env.env_map.clone(allocator) catch |err| + return customInfraFailure(allocator, timer, "counter environment: {}", .{err}) }; + defer counted.env_map.deinit(); + counted.env_map.put("ROC_TEST_COMMAND_WORK", "1") catch |err| + return customInfraFailure(allocator, timer, "counter environment: {}", .{err}); + var warm_command = command; + warm_command.contains = &.{}; + const warm = switch (captureRocRun(io, allocator, &counted, timer, timeout_ms, warm_command)) { + .result => |result| result, + .failure => |failure| return failure, + }; + for ([_][]const u8{ "specialization", "ctfe_execution", "second_specialization", "runtime_lir", "arc", "static_data", "native_test_procedures", "test_root_execution" }) |counter| { + const expected = std.fmt.allocPrint(allocator, "test-work {s}=0\n", .{counter}) catch |err| + return customInfraFailure(allocator, timer, "counter expectation: {}", .{err}); + if (std.mem.find(u8, warm.stderr, expected) == null) return customFailure(allocator, timer, "complete hit did work: missing {s}\n{s}", .{ expected, warm.stderr }); + } + // Presentation changes reuse the same facts, including source context. + var colored = CaseEnv{ .dirs = env.dirs, .env_map = cloneEnvWithout(allocator, &env.env_map, &.{"NO_COLOR"}) catch |err| + return customInfraFailure(allocator, timer, "color environment: {}", .{err}) }; + defer colored.env_map.deinit(); + colored.env_map.put("FORCE_COLOR", "1") catch |err| + return customInfraFailure(allocator, timer, "color environment: {}", .{err}); + const color = switch (captureRocRun(io, allocator, &colored, timer, timeout_ms, command)) { + .result => |result| result, + .failure => |failure| return failure, + }; + if (expectAnsiInPreSummary(allocator, timer, "completed colored outcome", color)) |failure| return failure; + if (expectSamePreSummaryTextIgnoringColor(allocator, timer, "cold", cold, "color replay", color)) |failure| return failure; + + // Same-length source edits, root additions/removal, dependency changes and + // imported bytes invalidate the command. Restoring bytes reuses its record. + const fixed = std.mem.replaceOwned(u8, allocator, original_main, "== 5", "== 4") catch |err| + return customInfraFailure(allocator, timer, "fixed source: {}", .{err}); + const extra_root = std.fmt.allocPrint(allocator, "{s}\nexpect False\n", .{original_main}) catch |err| + return customInfraFailure(allocator, timer, "extra root: {}", .{err}); + const edited_helper = std.mem.replaceOwned(u8, allocator, original_helper, "Str.count_utf8_bytes(input)", "Str.count_utf8_bytes(input) + 1") catch |err| + return customInfraFailure(allocator, timer, "edited dependency: {}", .{err}); + const edits = [_]struct { file: []const u8, bytes: []const u8, restore: []const u8, code: u8 }{ + .{ .file = "Main.roc", .bytes = fixed, .restore = original_main, .code = 2 }, + .{ .file = "Main.roc", .bytes = extra_root, .restore = original_main, .code = 1 }, + .{ .file = "Helper.roc", .bytes = edited_helper, .restore = original_helper, .code = 2 }, + .{ .file = "input.txt", .bytes = "abcd\n", .restore = "abc\n", .code = 2 }, + }; + for (edits) |edit| { + if (writeCaseFile(io, allocator, timer, dir, edit.file, edit.bytes)) |failure| return failure; + if (runRocAndCheck(io, allocator, &counted, timer, timeout_ms, .{ + .args = &.{ "test", "--opt=dev" }, + .roc_file = main_path, + .exit = .{ .code = edit.code }, + .not_contains = &.{.{ .stream = .stderr, .text = "test-work specialization=0\n" }}, + })) |failure| return failure; + if (writeCaseFile(io, allocator, timer, dir, edit.file, edit.restore)) |failure| return failure; + if (runRocAndCheck(io, allocator, &counted, timer, timeout_ms, .{ + .args = &.{ "test", "--opt=dev" }, + .roc_file = main_path, + .exit = .{ .code = 1 }, + .contains = &.{.{ .stream = .stderr, .text = "test-work specialization=0\n" }}, + })) |failure| return failure; + } + // Earlier check activity must neither satisfy a command miss nor change + // the observations owned by an already completed test command. + if (runRocAndCheck(io, allocator, env, timer, timeout_ms, .{ .args = &.{"check"}, .roc_file = main_path, .exit = .failure })) |failure| return failure; + const after_check = switch (captureRocRun(io, allocator, env, timer, timeout_ms, command)) { + .result => |result| result, + .failure => |failure| return failure, + }; + if (expectSamePreSummaryBytes(allocator, timer, "cold", cold, "after check", after_check)) |failure| return failure; + for ([_]bool{ false, true }) |remove_payload| { + const damaged = damageCompletedTestCache(io, allocator, env.dirs.roc_cache_dir, remove_payload) catch |err| + return customInfraFailure(allocator, timer, "damage optional cache: {}", .{err}); + if (!damaged) return customFailure(allocator, timer, "no completed test cache payload found", .{}); + const repaired = switch (captureRocRun(io, allocator, &counted, timer, timeout_ms, command)) { + .result => |result| result, + .failure => |failure| return failure, + }; + if (std.mem.find(u8, repaired.stderr, "test-work specialization=0\n") != null) + return customFailure(allocator, timer, "incomplete cache admitted as a complete command", .{}); + if (expectSamePreSummaryBytes(allocator, timer, "cold", cold, "repaired cache", repaired)) |failure| return failure; + } + // A build and check may populate their own caches, but neither is a + // completed test command. Resolution runs again before command admission. + const dep_a = createWorkSubdir(io, allocator, env, "command-dep-a") catch |err| + return customInfraFailure(allocator, timer, "dependency directory: {}", .{err}); + const dep_b = createWorkSubdir(io, allocator, env, "command-dep-b") catch |err| + return customInfraFailure(allocator, timer, "dependency directory: {}", .{err}); + for ([_][]const u8{ dep_a, dep_b }, 1..) |dep, value| { + if (writeCaseFile(io, allocator, timer, dep, "main.roc", "package [Value] {}\n")) |failure| return failure; + const source = std.fmt.allocPrint(allocator, "Value := {{}}.{{ value = {d} }}\nexpect True\n", .{value}) catch |err| + return customInfraFailure(allocator, timer, "dependency source: {}", .{err}); + if (writeCaseFile(io, allocator, timer, dep, "Value.roc", source)) |failure| return failure; + } + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "command-app.roc", "app [main!] { dep: \"./command-dep-a/main.roc\" }\nimport dep.Value\nexpect Value.value == 1\nmain! = |_| Ok({})\n")) |failure| return failure; + const app = std.fs.path.join(allocator, &.{ env.dirs.work_dir, "command-app.roc" }) catch |err| + return customInfraFailure(allocator, timer, "app path: {}", .{err}); + const output = std.fmt.allocPrint(allocator, "--output={s}{s}command-app", .{ env.dirs.work_dir, std.fs.path.sep_str }) catch |err| + return customInfraFailure(allocator, timer, "output path: {}", .{err}); + for ([_][]const []const u8{ &.{"check"}, &.{ "build", "--opt=dev", output } }) |args| { + if (runRocAndCheck(io, allocator, env, timer, timeout_ms, .{ .args = args, .roc_file = app })) |failure| return failure; + } + if (runRocAndCheck(io, allocator, &counted, timer, timeout_ms, .{ + .args = &.{ "test", "--opt=dev" }, + .roc_file = app, + .not_contains = &.{.{ .stream = .stderr, .text = "test-work specialization=0\n" }}, + })) |failure| return failure; + const old_dep = std.fs.path.join(allocator, &.{ dep_a, "main.roc" }) catch |err| + return customInfraFailure(allocator, timer, "dependency path: {}", .{err}); + const new_dep = std.fs.path.join(allocator, &.{ dep_b, "main.roc" }) catch |err| + return customInfraFailure(allocator, timer, "dependency path: {}", .{err}); + if (runRocAndCheck(io, allocator, &counted, timer, timeout_ms, .{ + .args = &.{ "test", "--opt=dev", "--replace-dep", old_dep, new_dep }, + .roc_file = app, + .exit = .{ .code = 1 }, + .not_contains = &.{.{ .stream = .stderr, .text = "test-work specialization=0\n" }}, + })) |failure| return failure; + if (runRocAndCheck(io, allocator, &counted, timer, timeout_ms, .{ + .args = &.{ "test", "--opt=dev" }, + .roc_file = app, + .contains = &.{.{ .stream = .stderr, .text = "test-work specialization=0\n" }}, + })) |failure| return failure; + return null; +} + +/// Damage only this case's isolated cache. Command records and referenced +/// module payloads must both be admitted before any observation is replayed. +fn damageCompletedTestCache(io: std.Io, allocator: Allocator, cache_path: []const u8, remove_payload: bool) !bool { + var directory = try std.Io.Dir.cwd().openDir(io, cache_path, .{ .iterate = true }); + defer directory.close(io); + var walker = try directory.walk(allocator); + defer walker.deinit(); + const segment = std.fs.path.sep_str ++ "test" ++ std.fs.path.sep_str; + var damaged = false; + while (try walker.next(io)) |entry| { + if (entry.kind != .file or std.mem.find(u8, entry.path, segment) == null) continue; + const bytes = try directory.readFileAlloc(io, entry.path, allocator, .limited(16 * 1024 * 1024)); + defer allocator.free(bytes); + const magic = if (remove_payload) "ROC_TEST_RESULTS_V10" else "ROC_COMPLETED_DEV_TEST_V1"; + if (!std.mem.startsWith(u8, bytes, magic)) continue; + if (remove_payload) try directory.deleteFile(io, entry.path) else try directory.writeFile(io, .{ .sub_path = entry.path, .data = bytes[0 .. bytes.len / 2] }); + damaged = true; + } + return damaged; +} + fn customCachePassingResults(io: std.Io, allocator: Allocator, env: *const CaseEnv, timer: *harness.Timer, timeout_ms: u64, backend: OptMode) ?TestResult { const opt_arg = backendOptArg(allocator, backend) catch |err| return customInfraFailure(allocator, timer, "failed to allocate opt arg: {}", .{err}); diff --git a/src/compile/compile_build.zig b/src/compile/compile_build.zig index a2b99ee5acd..f46360a0ea4 100644 --- a/src/compile/compile_build.zig +++ b/src/compile/compile_build.zig @@ -235,6 +235,7 @@ pub const BuildEnv = struct { coordinator: ?*Coordinator = null, runtime_lowering: ?RuntimeLoweringConfig = null, runtime_output_probe: ?RuntimeOutputProbe = null, + debug_observer: ?eval.CompileTimeFinalization.DebugObserver = null, /// Instrumentation only; never participates in checked or object identity. detailed_monotype_diagnostics: bool = false, /// See `eval.CompileTimeFinalization.CompileTimeObjectCache`. @@ -903,6 +904,7 @@ pub const BuildEnv = struct { coord.cancel_request = self.cancel_request; coord.runtime_lowering = self.runtime_lowering; coord.runtime_output_probe = self.runtime_output_probe; + coord.debug_observer = self.debug_observer; coord.setDetailedLoweringTiming(self.detailed_lowering_timing); coord.compile_time_object_cache = self.compile_time_object_cache; if (self.detailed_monotype_diagnostics) coord.ctfe_timing.lowering.enableDetailedMonotypeBody(); @@ -2613,6 +2615,49 @@ pub const BuildEnv = struct { return inputs.toOwnedSlice(self.gpa); } + /// Complete-command admission needs fetched sources too. Preserve repeated + /// reads: if a source changed between discovery and checking, validation + /// must reject publication rather than select one of the observed states. + pub fn collectConsumedInputStates(self: *BuildEnv, allocator: Allocator) Allocator.Error![]const watch_inputs.Input { + std.debug.assert(self.track_watch_inputs); + var inputs = std.ArrayList(watch_inputs.Input).empty; + errdefer { + for (inputs.items) |input| allocator.free(input.path); + inputs.deinit(allocator); + } + const Owned = struct { + fn append(gpa: Allocator, out: *std.ArrayList(watch_inputs.Input), path: []const u8, state: watch_inputs.State) Allocator.Error!void { + const owned = try gpa.dupe(u8, path); + errdefer gpa.free(owned); + try out.append(gpa, .{ .path = owned, .state = state }); + } + }; + var packages = self.packages.valueIterator(); + while (packages.next()) |pkg| { + try Owned.append(allocator, &inputs, pkg.root_file, pkg.root_file_state.?); + } + if (self.coordinator) |coord| { + var coordinator_packages = coord.packages.valueIterator(); + while (coordinator_packages.next()) |pkg| { + for (pkg.*.modules.items) |*mod| { + try Owned.append(allocator, &inputs, mod.path, mod.source_file_state.?); + if (mod.moduleEnv()) |env| for (env.file_dependencies.items.items) |dep| { + const path = try std.fs.path.resolve(allocator, &.{ mod.canonicalSourceDir(), env.fileDependencyRelativePath(dep) }); + defer allocator.free(path); + const state: watch_inputs.State = switch (dep.state) { + .present => .{ .hash = dep.content_hash }, + .missing => .missing, + .unreadable => .unreadable, + .pending => base.invariant("completed command has a pending imported file", .{}), + }; + try Owned.append(allocator, &inputs, path, state); + }; + } + } + } + return inputs.toOwnedSlice(allocator); + } + pub fn hasCompiledModules(self: *BuildEnv) bool { const coord = self.coordinator orelse return false; var pkg_it = coord.packages.iterator(); diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index e44338669fb..ee5019dbac3 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -1471,6 +1471,7 @@ pub const Coordinator = struct { checked_program: bool = false, runtime_lowering: ?compile_build.RuntimeLoweringConfig = null, runtime_output_probe: ?compile_build.RuntimeOutputProbe = null, + debug_observer: ?eval.CompileTimeFinalization.DebugObserver = null, /// Set by checked-program finalization when the probe found this exact /// program's runtime output recorded, so no runtime program was lowered. runtime_output_cached: bool = false, @@ -3160,6 +3161,7 @@ pub const Coordinator = struct { options.keep_artifacts = self.compile_time_object_cache != null or self.keep_compile_time_artifacts; options.cached_debug_modules = cached_debug_modules.items; options.defer_debug_replay = !replay_cached_debug; + options.debug_observer = self.debug_observer; var runtime_target: ?lir.CheckedPipeline.TargetConfig = if (self.runtime_lowering) |config| config.target else null; if (runtime_target) |*target| target.post_check_executor = self.postCheckExecutor(); var unfinalized = UnfinalizedReportDestinations{ .coordinator = self, .program_root = root.key.digest() }; diff --git a/src/eval/compile_time_finalization.zig b/src/eval/compile_time_finalization.zig index 7d98cbc3c3a..65886d573c7 100644 --- a/src/eval/compile_time_finalization.zig +++ b/src/eval/compile_time_finalization.zig @@ -51,6 +51,13 @@ pub const EventCallback = struct { notify: *const fn (*anyopaque, EventView) void, }; +/// Observes the ordered semantic debug payloads emitted by finalization. +/// Unlike REPL events this includes every root, including cached observations. +pub const DebugObserver = struct { + context: *anyopaque, + observe: *const fn (*anyopaque, []const u8) Allocator.Error!void, +}; + /// Where the compile-time evaluator splices object-cache entries from. pub const SpliceSource = backend.dev.SpliceSource; @@ -98,6 +105,7 @@ pub const Options = struct { post_check_executor: ?base.post_check_task_executor.Executor = null, stderr: ?StderrWriter = null, event_callback: ?EventCallback = null, + debug_observer: ?DebugObserver = null, debug_events: ?*DebugEvents = null, /// Artifact copies store results independently; shared slots publish once. publish_shared_slots: bool = true, @@ -625,6 +633,7 @@ pub const ProgramSession = struct { roots: lir.CheckedPipeline.RootRequestSet, requested_target: lir.CheckedPipeline.TargetConfig, ) RuntimeMaterializationError!lir.CheckedPipeline.LoweredProgram { + base.dev_diagnostics.countTestWork(.runtime_lir, 1); const configured = self.runtime_target orelse finalizationInvariant("check-only session has no runtime consumer"); // Whether the program's specializations go to the object cache was // decided with the consumer, before the shared program was lowered. @@ -1268,6 +1277,7 @@ pub fn finalizeProgram( var quiet_unfinalized: QuietUnfinalizedReports = .{ .inner = options.unfinalized_reports }; var quiet_options = options; quiet_options.stderr = null; + quiet_options.debug_observer = null; quiet_options.event_callback = null; quiet_options.keep_artifacts = false; quiet_options.unfinalized_reports = if (options.unfinalized_reports != null) quiet_unfinalized.reports() else null; @@ -1278,6 +1288,7 @@ pub fn finalizeProgram( if (quiet_options.object_cache) |*cache| cache.stage_sink = null; var quiet_events = DebugEvents{ .allocator = allocator }; defer quiet_events.deinit(); + base.dev_diagnostics.countTestWork(.second_specialization, 1); var respecialized = try specializeAndEvaluate( allocator, quiet_modules, @@ -5365,6 +5376,7 @@ fn evalDevProgramRoots( } fn devRootWorker(_: Allocator, context: *DevRunContext, item_id: usize) void { + base.dev_diagnostics.countTestWork(.ctfe_execution, 1); const job = &context.jobs[item_id]; job.host.resetForRun(); if (context.boxy_global_installed) { @@ -5610,6 +5622,7 @@ fn reportDebugMessage(allocator: Allocator, options: Options, module: checked.Mo } fn emitDebugMessage(allocator: Allocator, options: Options, is_repl: bool, message: []const u8) Allocator.Error!void { + if (options.debug_observer) |observer| try observer.observe(observer.context, message); if (is_repl) { if (options.event_callback) |callback| callback.notify(callback.context, .{ .dbg = message }); } diff --git a/src/eval/inspected.zig b/src/eval/inspected.zig index a22882eb7dd..1e866c6ca28 100644 --- a/src/eval/inspected.zig +++ b/src/eval/inspected.zig @@ -2366,6 +2366,7 @@ pub fn devEvalSharedBoolRootModules(allocator: Allocator, modules: []const BoolR if (timing) |timings| timings.finish(codegen_setup_started_ns, .codegen_setup); const procedure_codegen_started_ns = if (timing) |timings| timings.start() else 0; + base.dev_diagnostics.countTestWork(.native_test_procedures, store.getProcSpecs().len); try codegen.compileAllProcSpecs(store.getProcSpecs()); const static_rc_helpers = try backend.collectRequiredRcHelpers(allocator, module.static_data.exports); defer allocator.free(static_rc_helpers); @@ -2466,6 +2467,7 @@ fn callBoolRoot( expect_passed: []u64, expect_failed: []u64, ) Error!BoolRootEvalResult { + base.dev_diagnostics.countTestWork(.test_root_execution, 1); var runtime_env = RuntimeHostEnv.init(allocator); defer runtime_env.deinit(); var event_forwarder: RuntimeHostEventForwarder = undefined; diff --git a/src/lir/arc.zig b/src/lir/arc.zig index 211a4099b78..91fe0049d38 100644 --- a/src/lir/arc.zig +++ b/src/lir/arc.zig @@ -496,6 +496,7 @@ const GroupLivenessIndex = struct { /// Public `insert` function. pub fn insert(store: *LirStore, layouts: *const layout_mod.Store, options: InsertOptions) ResourceError!void { + @import("base").dev_diagnostics.countTestWork(.arc, 1); if (options.metrics_out) |metrics| metrics.* = .{}; var inserter = Inserter{ .store = store, diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index 592f52b5541..244038f22d0 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -1515,6 +1515,7 @@ pub fn prepareCheckedModulesMonotype( timing.addDemand(.{ .monotype = demand_counters }); } } + base.dev_diagnostics.countTestWork(.specialization, 1); if (target.work_metrics) |metrics| metrics.monotype_runs += 1; break :monotype try postcheck.Monotype.Lower.run( allocator, diff --git a/src/static_data.zig b/src/static_data.zig index 06a646b3546..2f0589629e4 100644 --- a/src/static_data.zig +++ b/src/static_data.zig @@ -1490,6 +1490,7 @@ pub fn buildStaticDataForWidth( target_usize: @import("base").target.TargetUsize, options: BuildOptions, ) MaterializationError![]StaticDataExport { + @import("base").dev_diagnostics.countTestWork(.static_data, 1); const root = modules.root orelse { if (hasProvidedData(modules)) staticDataInvariant("provided data exports require a root checked module"); if (lowered) |lowered_program| { diff --git a/test/cli/completed_test/Helper.roc b/test/cli/completed_test/Helper.roc new file mode 100644 index 00000000000..dc73470c904 --- /dev/null +++ b/test/cli/completed_test/Helper.roc @@ -0,0 +1,15 @@ +module [value, positive] + +import "input.txt" as input : Str + +value = { + dbg "compile-time observation" + Str.count_utf8_bytes(input) +} + +positive = |n| { + expect n > 0 + n > 0 +} + +expect positive(1) diff --git a/test/cli/completed_test/Main.roc b/test/cli/completed_test/Main.roc new file mode 100644 index 00000000000..4ff6c42248f --- /dev/null +++ b/test/cli/completed_test/Main.roc @@ -0,0 +1,12 @@ +module [] + +import Helper + +expect { + unused = 42 + dbg "test observation" + expect Helper.positive(3) + Helper.value == 5 +} + +expect True diff --git a/test/cli/completed_test/input.txt b/test/cli/completed_test/input.txt new file mode 100644 index 00000000000..8baef1b4abc --- /dev/null +++ b/test/cli/completed_test/input.txt @@ -0,0 +1 @@ +abc From 9ee8a23439add780f83c5bd019f21b77a51ce490 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 11:45:17 -0400 Subject: [PATCH 15/97] Reuse native procedure artifacts in dev tests --- design.md | 73 +++++- src/backend/dev/HostSplice.zig | 13 +- src/backend/dev/PackFile.zig | 10 +- src/cli/main.zig | 206 +++++++++++++--- src/cli/pack_store.zig | 341 ++++++++++++++++++++++++--- src/cli/progress.zig | 8 +- src/cli/test/parallel_cli_runner.zig | 188 +++++++++++++++ src/eval/inspected.zig | 116 +++++++-- src/lir/checked_pipeline.zig | 17 +- src/lir/pack_program.zig | 59 +++++ src/postcheck/common.zig | 8 +- 11 files changed, 936 insertions(+), 103 deletions(-) diff --git a/design.md b/design.md index 59231fa5bd6..cf5902362aa 100644 --- a/design.md +++ b/design.md @@ -679,16 +679,72 @@ Same-program retained fragments remain exact-target for now. Their separate `rejected_target` metric exposes this limitation; revision, transferred LIR and layout ownership, CPU, static-data and runtime-context checks are unchanged. +Native dev tests consume this domain through the same heap-owned lookup and +splice provider as CTFE. They emit object-mode procedures, link symbolic +references through `HostSplice`, and generate invocation wrappers separately. +The native target and CPU floor, specialized calling convention, shared-thread +ARC signatures, hot-reload capture prefix, dev inlining policy, dictionary seed, +and readonly constant representation are the ordinary native dev contracts. +The final runtime continuation supplies normalized stored compile-time values; +evaluation-stage code is never published as the test runtime's code. + +Test observation eligibility is an additional explicit capability. A pack offer +records whether its complete LIR call closure contains an expect or an opaque +external body. Only a closure proven free of both can serve a test consumer. +An ordinary dev expect reports failure but does not count success; a test expect +uses a command-local site index. Neither is interchangeable with the other. +Test-instrumented closures are not published, and their bodies remain available +for the current plan's counters. Generic `dbg` calls use the invocation's host +hooks and remain shareable. Shared specialization intersects this capability +with CTFE's provision and still requires the identical provider on both sides. +Frozen constants containing function relocations retain the existing pack +withholding rule; the test image resolves their current procedures and explicit +RC helpers before executing any root. +Program-local erased-callable runtime references remain ineligible for packs. +The test runner supplies the existing erased-callable runtime only when the +producer's explicit tables or procedure ABI require it; ordinary specialized +procedures neither initialize that runtime nor construct its function table. + +The test runner retains admitted pack bytes, carried immutable data, generated +code, source maps and executable memory until all workers finish. Every call +owns its observation state. Cache admission happens before body elision; missing, +corrupt or incompatible optional entries are misses, never a reason to compile +a missing body after admission. Publication uses only the code actually emitted +for required tests, and complete result hits never enter this runtime consumer. + ### Object Cache Store +Native procedure publication writes a SHA-256-addressed `.rpk` payload and then +an immutable `.rpk.idx` offer file under the existing module placement. The +versioned `RPI1` index contains the payload digest followed by specialization +keys; it shares one payload and producer table across all those keys. Publication +stages and renames each file independently, payload first. Existing equal files +are not rewritten. A missing payload, an interrupted publication, an incompatible +pack, or a digest mismatch is an optional-cache miss. Index files alone never +authorize body elision. Republication repairs corrupt bytes through the same +atomic boundary instead of treating damaged optional storage as an invariant. + +Loading a module's procedure offers reads only these compact indexes. A key +lookup loads candidate payloads lazily, checks the digest and pack format, and +checks observation eligibility and source provenance before installing the hit. +All accepted payload bytes and decoded tables remain owned by `LoadedPacks` +until execution is finished; eviction after admission cannot revoke the code. +Different source-provenance candidates are tested independently. Native test +publication retains only the union of offered code closures, excluding wrappers +and test-instrumented code, and names carried constants by content. +Program-wide CTFE packs use the app's placement when an app is present, as +optimized program packs do. The app is shared by checking, building and testing; +an implicit executable platform need not be loaded by a test command. Standalone +test modules supply their checked artifacts explicitly to the provider. + Dev packs live under `//objects-v/ -proc--dev///.rpk`, and the pack an +proc--dev///.rpk`, with +offers at `.rpk.idx`. The pack an optimized build files for its program, which each optimized build of the -program replaces, under `-/optimized/.rpk`. Every -other pack's path is a function of its content, so a writer stages it beside its path in a file it -alone created (`CoreCtx.writeTempFile`), so two writers never share a partly -written file, renames it into place, and leaves a pack already present alone; builds with runtime -safety compare the bytes they would have written with the pack in place. The +program replaces, lives under `-/optimized/.rpk`. +A writer stages bytes beside their path in a file it alone created +(`CoreCtx.writeTempFile`) and renames it into place, so two writers never share +a partly written file. Equal native payloads and indexes retain their files. The background sweep (`cache_cleanup.sweep`) deletes every `objects-v*` namespace of a format other than the compiler's own, ages the current namespace's `local/` packs (rewritten by every edit) and recorded program outputs @@ -2440,8 +2496,9 @@ sets are combined. Each stage's offers, with everything about its procedures they depend on, are gathered as soon as the stage has run, and the stage's code is then released: later stages and a runtime consumer read only its completed values and their representation metadata, so the levels' code -never accumulates. The pack is filed beside the root module's -dev packs under a key derived from the root module's code-generation key, +never accumulates. The pack is filed under the program's app placement, or +the root module's placement when there is no app, with an index key derived +from the root module's code-generation key, which covers every module the program's code comes from and no runtime pack uses, so an edited program files a new pack beside the old one. The key also names the command and the optimization level its program is specialized for: diff --git a/src/backend/dev/HostSplice.zig b/src/backend/dev/HostSplice.zig index 7533a54da91..7260763492c 100644 --- a/src/backend/dev/HostSplice.zig +++ b/src/backend/dev/HostSplice.zig @@ -142,7 +142,7 @@ pub const HostSplice = struct { /// evaluator's own code names one where it takes a hosted function as a /// value. Runs after every procedure is compiled and before the image /// is finished. - pub fn generateHostedStubs(self: *HostSplice, codegen: *HostLirCodeGen, boxy_native_fns: *const BoxyNativeFnTable) Allocator.Error!void { + pub fn generateHostedStubs(self: *HostSplice, codegen: *HostLirCodeGen, boxy_native_fns: ?*const BoxyNativeFnTable) Allocator.Error!void { var code_symbols = std.StringHashMap(usize).init(self.allocator); defer { var keys = code_symbols.keyIterator(); @@ -185,7 +185,7 @@ pub const HostSplice = struct { /// Map the finished image executable: copy the code, give every function /// of the compiler it calls a stub, define the carried data, and patch /// every relocation. - pub fn link(self: *HostSplice, codegen: *HostLirCodeGen, boxy_native_fns: *const BoxyNativeFnTable) LinkError!ExecutableMemory { + pub fn link(self: *HostSplice, codegen: *HostLirCodeGen, boxy_native_fns: ?*const BoxyNativeFnTable) LinkError!ExecutableMemory { const code = codegen.getGeneratedCode(); const names = codegen.getSymbolNames(); @@ -312,7 +312,7 @@ const Binding = enum { compiler_function, image, unresolved }; /// Resolves relocation names against the compiler and the image. const Binder = struct { splice: *HostSplice, - boxy_native_fns: *const BoxyNativeFnTable, + boxy_native_fns: ?*const BoxyNativeFnTable, code_symbols: *const std.StringHashMap(usize), stub_targets: *std.AutoArrayHashMapUnmanaged(usize, void), allocator: Allocator, @@ -372,13 +372,16 @@ const Binder = struct { /// of the in-process host, a boxy runtime function, or a compiler-rt or C /// routine. The dict seed is the compile-time evaluator's fixed zero, as it /// is in the evaluator's own code. -fn compilerFunction(name: []const u8, boxy_native_fns: *const BoxyNativeFnTable) ?usize { +fn compilerFunction(name: []const u8, boxy_native_fns: ?*const BoxyNativeFnTable) ?usize { if (BuiltinFn.fromSymbolName(name)) |builtin_fn| { if (builtin_fn == .dict_pseudo_seed) return @intFromPtr(&comptimeDictSeed); return builtin_fn.wrapperAddress(); } if (builtins.in_process_host.Symbol.fromName(name)) |symbol| return symbol.address(); - if (BoxyBuiltinFn.fromSymbolName(name)) |boxy_fn| return boxy_native_fns[@backingInt(boxy_fn)]; + if (BoxyBuiltinFn.fromSymbolName(name)) |boxy_fn| { + const table = boxy_native_fns orelse @import("base").invariant("native splice requires its declared Boxy runtime capability", .{}); + return table[@backingInt(boxy_fn)]; + } return builtins.native_runtime_libcalls.resolve(name); } diff --git a/src/backend/dev/PackFile.zig b/src/backend/dev/PackFile.zig index 74c449ea531..529051bd5e5 100644 --- a/src/backend/dev/PackFile.zig +++ b/src/backend/dev/PackFile.zig @@ -23,7 +23,7 @@ const Allocator = std.mem.Allocator; const magic = "RPCK"; /// Format version; bump whenever the encoding or artifact contents change. -pub const format_version: u32 = 13; +pub const format_version: u32 = 14; /// One specialization the pack can serve: its reservation-time key, the /// artifact holding its procedure, and the ownership signature and @@ -32,6 +32,8 @@ pub const format_version: u32 = 13; pub const SpecEntry = struct { key: [32]u8, artifact: u32, + /// The producer proved this closure contains no expect instrumentation. + test_compatible: bool = false, /// The compile-time roots the procedure's code reads, which a linking /// program demands (`Common.SpecCacheHit.comptime_root_reads`). comptime_root_reads: []const lir.CheckedPipeline.ComptimeRootRead = &.{}, @@ -636,6 +638,7 @@ fn writeSpecs(writer: *Writer, specs: []const SpecEntry) Allocator.Error!void { for (specs) |spec| { try writer.raw(&spec.key); try writer.word(spec.artifact); + try writer.byte(@intFromBool(spec.test_compatible)); try writer.word(@intCast(spec.comptime_root_reads.len)); for (spec.comptime_root_reads) |root_read| { try writer.raw(&root_read.module); @@ -660,6 +663,11 @@ fn readSpecs(reader: *Reader, arena_allocator: Allocator, count: u32, artifact_c spec.* = .{ .key = (try reader.raw(32))[0..32].*, .artifact = try reader.word(), + .test_compatible = switch (try reader.byte()) { + 0 => false, + 1 => true, + else => return error.MalformedPack, + }, .comptime_root_reads = reads: { const reads = try arena_allocator.alloc(lir.CheckedPipeline.ComptimeRootRead, try reader.word()); for (reads) |*root_read| root_read.* = .{ .module = (try reader.raw(32))[0..32].*, .root = try reader.word() }; diff --git a/src/cli/main.zig b/src/cli/main.zig index acc2ceec9b0..de35c6806a5 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -312,6 +312,7 @@ const CompileTimeObjectCache = struct { /// evaluation's levels run, when the command publishes one. pack_parts: ?CompileTimePackParts = null, build_env: ?*BuildEnv = null, + explicit_artifacts: []const *const check.CheckedArtifact.CheckedModuleArtifact = &.{}, fn bind(context: *anyopaque, build_env: *BuildEnv) void { const self: *CompileTimeObjectCache = @ptrCast(@alignCast(context)); @@ -321,6 +322,7 @@ const CompileTimeObjectCache = struct { .linked_stores = self.linked_stores, .io = self.io, .build_env = build_env, + .explicit_artifacts = self.explicit_artifacts, }; } @@ -356,7 +358,7 @@ const CompileTimePackParts = struct { const build_env = cache.build_env orelse return null; const root_semantic = build_env.getExecutableRootSemanticData() orelse return null; const root_artifact = root_semantic.checked_artifact orelse return null; - const placement = build_env.packPlacementForArtifactKey(root_artifact.key) orelse return null; + const placement = pack_store.programPackPlacement(build_env, root_artifact) orelse return null; const key = pack_store.compileTimePackKey(root_artifact.codeGenerationKey().bytes, publisher); if (try cache.store.has(placement.origin, placement.identity, key)) return null; return .{ .placement = placement, .key = key }; @@ -8564,7 +8566,7 @@ fn writePackageDevPacksToStore( else => return error.NativeCompilationFailed, }; } - store.write(placement.origin, placement.identity, artifact.codeGenerationKey().bytes, bytes) catch |err| { + store.writeIndexed(placement.origin, placement.identity, artifact.codeGenerationKey().bytes, bytes) catch |err| { std.log.warn("object cache could not store a package's dev pack: {}", .{err}); }; } @@ -8612,7 +8614,7 @@ fn writePacksToStore( if (!try store.has(placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes)) { const bytes = try packFileBytes(ctx.gpa, set, app_lowered); defer ctx.gpa.free(bytes); - store.write(placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes, bytes) catch |err| { + store.writeIndexed(placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes, bytes) catch |err| { std.log.warn("object cache could not store the program's pack: {}", .{err}); }; } @@ -8646,7 +8648,7 @@ fn writePacksToStore( const set = &(pack.compiled.artifacts orelse continue); const bytes = try packFileBytes(ctx.gpa, set, &pack.lowered); defer ctx.gpa.free(bytes); - store.write(origin, identity, artifact.codeGenerationKey().bytes, bytes) catch |err| { + store.writeIndexed(origin, identity, artifact.codeGenerationKey().bytes, bytes) catch |err| { std.log.warn("object cache could not store a module's pack: {}", .{err}); }; } @@ -8716,7 +8718,7 @@ fn writeOptimizedPacks( ) CliMainError!void { if (comptime !llvm_available) return; if (args.no_cache or args.fuzz) return; - const placement = pack_store.optimizedPackPlacement(build_env, root_artifact) orelse return; + const placement = pack_store.programPackPlacement(build_env, root_artifact) orelse return; var store = pack_store.Store.init(ctx.gpa, .{ .enabled = true, .verbose = args.verbose, .roc_ctx = ctx.coreCtx() }, target, @tagName(args.opt)) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.NoHomeDirectory => return, @@ -9242,7 +9244,7 @@ fn publishCompileTimePack(ctx: *CliCtx, build_env: *BuildEnv) Allocator.Error!vo const bytes = try parts.offers.encode(ctx.gpa, &combined); defer ctx.gpa.free(bytes); if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("compile-time pack {d} bytes\n", .{bytes.len}); - store.write(destination.placement.origin, destination.placement.identity, destination.key, bytes) catch |err| { + store.writeIndexed(destination.placement.origin, destination.placement.identity, destination.key, bytes) catch |err| { std.log.warn("object cache could not store the compile-time pack: {}", .{err}); }; } @@ -9314,6 +9316,9 @@ const PackOffers = struct { /// differently, so evaluation's code for an entry that reads one is not /// the code another program compiles, and such an entry is withheld. evaluation: bool, + observation_independent: []const bool = &.{}, + required_only: bool = false, + offered_count: ?*usize = null, /// Every keyed specialization, in program order. keyed: std.ArrayList(Candidate) = .empty, /// Every procedure offered by its identity (`Common.procIdentityCacheKey`), @@ -9332,6 +9337,7 @@ const PackOffers = struct { reads: lir.CheckedPipeline.SpecComptimeReads.Lookup, /// A rule read from the program's code withholds it. withheld: bool, + test_compatible: bool, rc_borrowed_params: u64, rc_ret_borrowed: bool, rc_ret_lenders: u64, @@ -9368,6 +9374,10 @@ const PackOffers = struct { ) Allocator.Error!void { const allocator = self.literal_converters.allocator; const store = &lowered.lir_result.store; + const independent = try lir.PackProgram.observationIndependentProcs(allocator, store); + defer allocator.free(independent); + self.observation_independent = independent; + defer self.observation_independent = &.{}; const converting = try lir.PackProgram.literalConvertingProcs(allocator, store); defer allocator.free(converting); for (store.getProcSpecs(), converting) |proc, converts| { @@ -9415,7 +9425,13 @@ const PackOffers = struct { // entry rather than calling it, and has no body for an entry, so it // sees the entry outside every call cycle and would reach it with an // ordinary call where the cycle needs a frame-replacing one. - const withheld = !reads.offerable or + const closure = try lir.PackProgram.procCallClosure(self.literal_converters.allocator, store, id); + defer self.literal_converters.allocator.free(closure); + var test_compatible = true; + for (closure, self.observation_independent) |reached, independent| { + if (reached and !independent) test_compatible = false; + } + const withheld = (lowered.lir_result.expect_sites.items.len != 0 and !test_compatible) or !reads.offerable or (self.evaluation and reads.evaluated) or proc.rc_variant_demandable or try procReachesLinkedEntry(self.literal_converters.allocator, store, id) or @@ -9432,6 +9448,7 @@ const PackOffers = struct { .identity = proc.identity, .reads = .{ .offerable = reads.offerable, .reads = try arena.dupe(lir.CheckedPipeline.ComptimeRootRead, reads.reads), .evaluated = reads.evaluated }, .withheld = withheld, + .test_compatible = test_compatible, .rc_borrowed_params = proc.rc_borrowed_params, .rc_ret_borrowed = proc.rc_ret_borrowed, .rc_ret_lenders = proc.rc_ret_lenders, @@ -9450,6 +9467,8 @@ const PackOffers = struct { var specs = std.ArrayList(backend.dev.PackFile.SpecEntry).empty; defer specs.deinit(allocator); const trace = dev_diagnostics.isSet(.pack_trace); + var selected = try std.DynamicBitSetUnmanaged.initEmpty(allocator, if (self.required_only) set.artifacts.len else 0); + defer selected.deinit(allocator); var withheld: usize = 0; var converting_artifacts = try literalConvertingArtifacts(allocator, set, &self.literal_converters); defer converting_artifacts.deinit(allocator); @@ -9474,9 +9493,13 @@ const PackOffers = struct { if (trace) if (offer.fingerprint) |fingerprint| { std.debug.print("{s} {x} {x:0>16} {s}\n", .{ if (by_identity) "offer-identity" else "offer", &offer.key, fingerprint, offer.name }); }; + if (self.required_only) for (placed) |index| { + selected.set(index); + }; try specs.append(allocator, .{ .key = offer.key, .artifact = artifact, + .test_compatible = offer.test_compatible, .comptime_root_reads = offer.reads.reads, .rc_borrowed_params = offer.rc_borrowed_params, .rc_ret_borrowed = offer.rc_ret_borrowed, @@ -9491,7 +9514,31 @@ const PackOffers = struct { if (trace) { std.debug.print("pack: {d} artifacts, {d} specs offered, {d} withheld\n", .{ set.artifacts.len, specs.items.len, withheld }); } - return backend.dev.PackFile.write(allocator, set, specs.items); + if (self.offered_count) |out| out.* = specs.items.len; + if (!self.required_only) return backend.dev.PackFile.write(allocator, set, specs.items); + // Only offered closures cross the persistence boundary. In particular, + // invocation wrappers and command-local observation code never do. + var arena = base.SingleThreadArena.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + const map = try a.alloc(u32, set.artifacts.len); + var compact = std.ArrayList(backend.dev.ProcArtifact.Artifact).empty; + for (set.artifacts, 0..) |artifact, index| { + if (!selected.isSet(index)) continue; + map[index] = std.math.cast(u32, compact.items.len) orelse return error.OutOfMemory; + try compact.append(a, artifact); + } + for (compact.items) |*artifact| { + const refs = try a.dupe(backend.dev.ProcArtifact.Reference, artifact.refs); + for (refs) |*ref| { + std.debug.assert(selected.isSet(ref.target)); + ref.target = map[ref.target]; + } + artifact.refs = refs; + } + for (specs.items) |*spec| spec.artifact = map[spec.artifact]; + const subset: backend.dev.ProcArtifact.Set = .{ .arena = undefined, .source_files = set.source_files, .artifacts = compact.items }; + return backend.dev.PackFile.write(allocator, &subset, specs.items); } }; @@ -13740,7 +13787,7 @@ fn checkedRuntimeLoweringConfig( .consume_dead_boxes = switch (roots) { .linked_output => true, .platform_entrypoints => |artifact| artifact == .dev_run_image, - .test_plan => false, + .test_plan => opt == .dev, }, .inline_expects = switch (roots) { .test_plan => .run, @@ -13777,6 +13824,19 @@ fn devCapturePrefix(roots: CheckedLirRoots, opt: cli_args.OptLevel, target: RocT return if (arch == .x86_64 or arch == .aarch64) @import("builtins").erased_callable.hot_reload_capture_prefix_size else 0; } +test "native test generic procedures use the ordinary native dev representation and ownership policy" { + const native = RocTarget.detectNative(); + const tests = checkedRuntimeLoweringConfig(.{ .test_plan = .{ .requests = &.{}, .metadata = &.{} } }, .dev, .lss, native, false); + const build = checkedRuntimeLoweringConfig(.linked_output, .dev, .lss, native, false); + try std.testing.expectEqualDeep(lir.CheckedPipeline.LirPolicy.fromTarget(build.target), lir.CheckedPipeline.LirPolicy.fromTarget(tests.target)); + try std.testing.expectEqualDeep(lir.CheckedPipeline.SolvedPolicy.fromTarget(build.target), lir.CheckedPipeline.SolvedPolicy.fromTarget(tests.target)); + try std.testing.expectEqual(build.target.erased_capture_prefix, tests.target.erased_capture_prefix); + try std.testing.expectEqual(build.target.target_usize, tests.target.target_usize); + try std.testing.expectEqual(build.target.inline_expects, tests.target.inline_expects); + try std.testing.expectEqual(lir.CheckedPipeline.CodeProvision.source_bodies, tests.target.code_provision); + try std.testing.expect(tests.target.spec_cache == null); +} + /// Native builds promise object provision only when they have an object /// cache. Only declared compatible procedures can replace CTFE's bodies. The /// wasm32 emitter splices no dev artifacts, so only linked objects serve it. @@ -14597,6 +14657,36 @@ fn runCompiledTestRoots( try appendInlineExpectResults(ctx, &lowered.lir_result.store, lowered.lir_result.expect_sites.items, eval_batch.expect_counts, results, summary, source_modules); } +/// Publication borrows the final runtime continuation, never evaluation LIR. +const NativeTestPublication = struct { + ctx: *CliCtx, + cache: *CompileTimeObjectCache, + program: *const lir.CheckedPipeline.LoweredProgram, + placement: BuildEnv.PackPlacement, + + fn accept(raw: *anyopaque, set: *const backend.dev.ProcArtifact.Set) Allocator.Error!void { + const self: *@This() = @ptrCast(@alignCast(raw)); + const reads = if (self.program.spec_comptime_reads) |*reads| reads else base.invariant("native test publication requires final runtime dependency metadata", .{}); + var offers = PackOffers.init(self.ctx.gpa, false); + defer offers.deinit(); + var offered: usize = 0; + offers.required_only = true; + offers.offered_count = &offered; + try offers.addProgram(self.program, reads); + // Normalize static names before sharing the code with another program. + var normalized = try backend.dev.ProcArtifact.combineDistinct(self.ctx.gpa, &.{set}); + defer normalized.deinit(); + const bytes = try offers.encode(self.ctx.gpa, &normalized); + defer self.ctx.gpa.free(bytes); + if (offered == 0) return; + var key: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &key, .{}); + self.cache.store.writeIndexed(self.placement.origin, self.placement.identity, key, bytes) catch |err| { + std.log.warn("object cache could not store native test procedures: {}", .{err}); + }; + } +}; + fn lowerPlannedTestModule( ctx: *CliCtx, shared: *lir.CheckedPipeline.LoweredProgram, @@ -14676,6 +14766,7 @@ fn runCompiledLoweredTestModulesOnce( max_workers: ?usize, live_output: ?*CliOptimizedLiveTestOutput, source_modules: *const CliTestSourceModuleMap, + native_artifacts: ?eval.Inspected.DevTestArtifacts, ) ReportRenderError!void { if (lowered_modules.len == 0) return; @@ -14735,7 +14826,7 @@ fn runCompiledLoweredTestModulesOnce( defer if (live_output) |live| live.clearRuns(); var eval_batch = (switch (mode) { - .dev => eval.Inspected.devEvalSharedBoolRootModules(ctx.gpa, bool_modules.items, dev_timing, max_workers), + .dev => eval.Inspected.devEvalSharedBoolRootModulesWithArtifacts(ctx.gpa, bool_modules.items, dev_timing, max_workers, native_artifacts), .llvm_size, .llvm_speed => eval.Inspected.llvmEvalSharedBoolRootModules( ctx.gpa, bool_modules.items, @@ -14902,6 +14993,7 @@ fn runCompiledTestPlan( total: *CliTestRunSummary, live_output: ?*CliOptimizedLiveTestOutput, source_modules: *const CliTestSourceModuleMap, + native_artifacts: ?eval.Inspected.DevTestArtifacts, ) (ReportRenderError || lir.CheckedPipeline.HostedBindingError || error{NoHomeDirectory})!void { const mode = cliTestExecutionMode(opt); switch (mode) { @@ -14955,7 +15047,7 @@ fn runCompiledTestPlan( ); } - try runCompiledLoweredTestModulesOnce(ctx, mode, dev_timing, lowered_modules.items, fresh_results, summaries, max_workers, live_output, source_modules); + try runCompiledLoweredTestModulesOnce(ctx, mode, dev_timing, lowered_modules.items, fresh_results, summaries, max_workers, live_output, source_modules, native_artifacts); for (lowered_modules.items) |*lowered_module| { const planned = &test_plan.modules[lowered_module.planned_index]; @@ -16172,6 +16264,25 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr defer test_plan.deinit(ctx.gpa); const specialization_strategy = currentRuntimeSpecializationStrategy(args.specialization_strategy); + reporter.begin("Test Planning"); + for (test_plan.modules) |*planned| { + if (try loadCachedCliTestResults( + ctx, + build_env.cache_manager, + planned.artifact, + specialization_strategy, + args.opt, + planned.module, + &source_modules, + planned.test_roots, + )) |cached| { + planned.cached_results = cached.results; + planned.cached_summary = cached.summary; + } + } + reporter.end(); + reporter.recordCounters("Test result cache", &cliTestCacheCounters(cliTestCacheStats(&test_plan))); + var runtime_requests = std.ArrayList(check.CheckedArtifact.RootRequest).empty; defer runtime_requests.deinit(ctx.gpa); var runtime_sources = std.ArrayList(check.CheckedArtifact.ModuleId).empty; @@ -16179,6 +16290,7 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr var runtime_metadata = std.ArrayList(postcheck.Common.RootTestPlanMetadata).empty; defer runtime_metadata.deinit(ctx.gpa); for (test_plan.modules) |planned| { + if (planned.cached_results != null) continue; try runtime_requests.appendSlice(ctx.gpa, planned.test_roots); try runtime_sources.appendNTimes(ctx.gpa, planned.artifact.key.digest(), planned.test_roots.len); for (planned.test_roots, 0..) |root, root_index| { @@ -16204,6 +16316,19 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr RocTarget.detectNative(), false, ); + const native_test_cache = if (backend.host_lir_codegen_available and specialization_strategy == .lss and runtime_requests.items.len != 0) + sharedCompileTimeObjectCache(&build_env, RocTarget.detectNative(), args.opt) + else + null; + var native_cache_modules = std.ArrayList(*const check.CheckedArtifact.CheckedModuleArtifact).empty; + defer native_cache_modules.deinit(ctx.gpa); + if (native_test_cache) |cache| { + for (modules) |module| if (module.semantic.checked_artifact) |artifact| try native_cache_modules.append(ctx.gpa, artifact); + cache.explicit_artifacts = native_cache_modules.items; + runtime_config.target.spec_cache = cache.packs.specCacheLookup(); + runtime_config.target.code_provision = .host_test_dev_objects; + runtime_config.target.spec_comptime_reads = true; + } runtime_config.explicit_roots = runtime_roots; if (runtime_requests.items.len != 0) { runtime_config.root_module = test_plan.modules[0].artifact; @@ -16223,25 +16348,6 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr }; } try finishFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), &reporter, build_env.getTimingInfo()); - reporter.begin("Test Planning"); - for (test_plan.modules) |*planned| { - if (try loadCachedCliTestResults( - ctx, - build_env.cache_manager, - planned.artifact, - specialization_strategy, - args.opt, - planned.module, - &source_modules, - planned.test_roots, - )) |cached| { - planned.cached_results = cached.results; - planned.cached_summary = cached.summary; - } - } - reporter.end(); - reporter.recordCounters("Test result cache", &cliTestCacheCounters(cliTestCacheStats(&test_plan))); - var spec_timing = lir.CheckedPipeline.Timing.init(ctx.io.std_io); if (args.timings) spec_timing.enableDetailedMonotypeBody(); var dev_timing = eval.test_helpers.DevBoolRootTiming.init(ctx.io.std_io); @@ -16268,6 +16374,20 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr shared_test_program.?.frozen_static_data = .{ .allocator = ctx.gpa, .exports = test_static_data }; } + var native_publication: ?NativeTestPublication = null; + var native_artifacts: ?eval.Inspected.DevTestArtifacts = if (args.opt == .dev and specialization_strategy == .lss) .{ + .executor = build_env.postCheckExecutor(), + } else null; + if (native_test_cache) |cache| { + native_artifacts.?.splice_source = cache.packs.spliceSource(); + if (shared_test_program) |*program| { + if (build_env.packPlacementForArtifactKey(test_plan.modules[0].artifact.key)) |placement| { + native_publication = .{ .ctx = ctx, .cache = cache, .program = program, .placement = placement }; + native_artifacts.?.publication = .{ .context = &native_publication.?, .accept = NativeTestPublication.accept }; + } + } + } + const test_mode = cliTestExecutionMode(args.opt); const use_live_optimized_output = runtime_requests.items.len != 0 and switch (test_mode) { .llvm_size, .llvm_speed => true, @@ -16348,6 +16468,7 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr &total, if (live_output) |*output| output else null, &source_modules, + native_artifacts, ), .interpreter => { for (test_plan.modules) |*planned| { @@ -16375,13 +16496,32 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr total.cached_modules += summary.cached_modules; } }, - }; + } else { + for (test_plan.modules) |*planned| { + std.debug.assert(planned.cached_results != null); + const summary = planned.cached_summary; + const results = planned.releaseCachedResults(); + try appendPlannedModuleResult(ctx, &module_results, planned, results, true); + total.passed += summary.passed; + total.failed += summary.failed; + total.compiler_errors += summary.compiler_errors; + total.modules_with_tests += summary.modules_with_tests; + total.cached_modules += summary.cached_modules; + } + } try coalesceInlineExpectResults(ctx.gpa, module_results.items, &total); total.modules_with_tests = test_plan.modules_with_tests; total.diagnostic_errors = diag.errors; reporter.end(); recordPostCheckLowering(&reporter, &spec_timing, specialization_strategy); if (test_mode == .dev) recordDevTestExecution(&reporter, &dev_timing); + if (native_test_cache) |cache| reporter.recordCounters("Native test artifact cache", &.{ + .{ .name = "Lookups", .count = cache.packs.lookups }, + .{ .name = "Hits", .count = cache.packs.hits }, + .{ .name = "Misses", .count = cache.packs.lookups - cache.packs.hits }, + .{ .name = "Payloads read", .count = cache.packs.payloads_read }, + .{ .name = "Bytes read", .count = cache.packs.bytes_loaded }, + }); reporter.finish(); // Calculate elapsed time @@ -17875,6 +18015,10 @@ fn recordDevTestExecution(reporter: *progress.Reporter, timing: *const eval.test snapshot.entrypoint_codegen_ns +| snapshot.executable_memory_ns +| snapshot.root_execution_ns; + reporter.recordCounters("Native test procedures", &.{ + .{ .name = "Emitted", .count = snapshot.procedures_emitted }, + .{ .name = "Spliced", .count = snapshot.procedures_spliced }, + }); reporter.recordCompletedWithBreakdown( "Dev Codegen + Execution", total_ns, diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index c4378070549..f9eff513cb6 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -167,7 +167,10 @@ pub const Store = struct { pub fn has(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8) Allocator.Error!bool { const path = try self.packPath(origin, identity, key); defer self.allocator.free(path); - return self.roc_ctx.fileExists(path); + if (self.roc_ctx.fileExists(path)) return true; + const index_path = try std.fmt.allocPrint(self.allocator, "{s}.idx", .{path}); + defer self.allocator.free(index_path); + return self.roc_ctx.fileExists(index_path); } /// Stage the pack beside its final path and rename it into place. A @@ -179,19 +182,63 @@ pub const Store = struct { try self.writePath(path, bytes); } + /// Publish immutable procedure bytes before their key index. Readers admit + /// a hit only after loading and verifying the complete referenced payload. + pub fn writeIndexed(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + var pack = PackFile.read(self.allocator, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => base.invariant("procedure publication must supply an encoded native pack", .{}), + }; + defer pack.deinit(); + var digest: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &digest, .{}); + const path = try self.packPath(origin, identity, digest); + defer self.allocator.free(path); + try self.writeIndexedPath(path, bytes); + const key_path = try self.packPath(origin, identity, key); + defer self.allocator.free(key_path); + const index_path = try std.fmt.allocPrint(self.allocator, "{s}.idx", .{key_path}); + defer self.allocator.free(index_path); + var index = std.ArrayList(u8).empty; + defer index.deinit(self.allocator); + try index.appendSlice(self.allocator, "RPI1"); + try index.appendSlice(self.allocator, &digest); + for (pack.specs) |spec| try index.appendSlice(self.allocator, &spec.key); + try self.writeIndexedPath(index_path, index.items); + } + + /// Corruption is an optional-cache failure, including when publication + /// encounters it after admission rejected the old bytes. Repair through + /// the same atomic publication boundary; equal bytes retain their file. + fn writeIndexedPath(self: *const Store, path: []const u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + const previous = self.roc_ctx.readFile(path, self.allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => null, + }; + if (previous) |existing| { + defer self.allocator.free(existing); + if (std.mem.eql(u8, existing, bytes)) return; + } + try self.publishPath(path, bytes); + } + /// Stage `bytes` in a file only this writer owns, beside `path`, and /// rename it into place, so a reader or another writer never sees a /// partly written file. fn writePath(self: *const Store, path: []const u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + if (self.roc_ctx.fileExists(path)) { + if (std.debug.runtime_safety) try self.verifyCollision(path, bytes); + return; + } + try self.publishPath(path, bytes); + } + + fn publishPath(self: *const Store, path: []const u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { const dir = std.fs.path.dirname(path).?; self.roc_ctx.makePath(dir) catch |err| { self.reportWriteFailure("create directory", dir, bytes.len, err); return error.PackWriteFailed; }; - if (self.roc_ctx.fileExists(path)) { - if (std.debug.runtime_safety) try self.verifyCollision(path, bytes); - return; - } const temp_path = self.roc_ctx.writeTempFile(self.allocator, path, bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => |write_err| { @@ -234,7 +281,7 @@ pub const Store = struct { pub fn loadIdentity(self: *const Store, io: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, identity: [32]u8) LoadedPacks.LoadError!void { const dir = try self.identityDir(origin, identity); defer self.allocator.free(dir); - try packs.loadDirInto(io, dir, .cache_offers); + try packs.loadIndexedDir(io, dir); } /// Read the discovery summary files filed under a module identity. @@ -657,7 +704,7 @@ pub const LinkedStores = struct { hasher.update(path); hasher.update(&.{0}); } - const placement = optimizedPackPlacement(build_env, root_artifact) orelse continue; + const placement = programPackPlacement(build_env, root_artifact) orelse continue; const input_key = try store.optimizedInputKey(placement.identity) orelse continue; const path = try store.optimizedPackPath(placement.identity); defer allocator.free(path); @@ -671,11 +718,11 @@ pub const LinkedStores = struct { } }; -/// The placement whose identity names the optimized pack of the program -/// whose root module is `root_artifact`: the app's when the program has one, -/// since every app of a platform shares the platform as its root, and -/// otherwise the root's. -pub fn optimizedPackPlacement(build_env: *compile.BuildEnv, root_artifact: *const check.CheckedArtifact.CheckedModuleArtifact) ?compile.BuildEnv.PackPlacement { +/// The placement for program-wide packs: the app's when the program has one, +/// otherwise the root's. Every app of a platform shares its executable root; +/// tests need not even load that platform. The app is the common declared owner +/// for native CTFE packs and optimized program packs across these commands. +pub fn programPackPlacement(build_env: *compile.BuildEnv, root_artifact: *const check.CheckedArtifact.CheckedModuleArtifact) ?compile.BuildEnv.PackPlacement { if (build_env.getAppSemanticData()) |app| { if (app.checked_artifact) |artifact| return build_env.packPlacementForArtifactKey(artifact.key); } @@ -769,6 +816,9 @@ pub const Pending = struct { linked_stores: ?*const LinkedStores = null, io: std.Io, build_env: *compile.BuildEnv, + /// Explicit-root commands have no executable root. Checking supplies their + /// complete module set before any specialization asks for an artifact. + explicit_artifacts: []const *const check.CheckedArtifact.CheckedModuleArtifact = &.{}, }; /// The command whose compile-time evaluation filed a compile-time pack, and @@ -825,6 +875,13 @@ pub fn encodeDiscovery(allocator: Allocator, summaries: []const lir.CheckedPipel /// identity. Files load in name order so the indexes are deterministic when /// two packs carry the same entry. pub const LoadedPacks = struct { + const IndexedPayload = struct { + path: []const u8, + digest: [32]u8, + io: std.Io, + attempted: bool = false, + pack: ?*PackFile.Pack = null, + }; const LinkedOffer = struct { hit: Common.SpecCacheHit, pack: u32, @@ -864,6 +921,11 @@ pub const LoadedPacks = struct { artifacts_served: u64 = 0, /// Bytes read from procedure, discovery and linked-object files. bytes_loaded: u64 = 0, + lookups: u64 = 0, + payloads_read: u64 = 0, + indexed_payloads: std.ArrayList(IndexedPayload) = .empty, + payloads_by_path: std.StringHashMapUnmanaged(u32) = .empty, + indexed_keys: std.AutoHashMapUnmanaged([32]u8, std.ArrayList(u32)) = .empty, /// Set until `indexPacks` runs; lookups before that load the pending /// store's packs for the modules in view first. pending: ?Pending = null, @@ -898,6 +960,93 @@ pub const LoadedPacks = struct { }; } + /// Read compact key directories, not historical machine-code payloads. + /// All names and key lists are command-owned; an index is only an offer. + fn loadIndexedDir(self: *LoadedPacks, io: std.Io, path: []const u8) LoadError!void { + var dir = std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true }) catch return; + defer dir.close(io); + var names = std.ArrayList([]u8).empty; + defer { + for (names.items) |name| self.allocator.free(name); + names.deinit(self.allocator); + } + var iterator = dir.iterate(); + while (iterator.next(io) catch return) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, ".rpk.idx")) continue; + const name = try self.allocator.dupe(u8, entry.name); + errdefer self.allocator.free(name); + try names.append(self.allocator, name); + } + std.mem.sort([]u8, names.items, {}, nameLessThan); + for (names.items) |name| { + const bytes = dir.readFileAlloc(io, name, self.allocator, .limited(64 * 1024 * 1024)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + }; + defer self.allocator.free(bytes); + self.bytes_loaded +|= bytes.len; + if (bytes.len < 36 or (bytes.len - 36) % 32 != 0 or !std.mem.eql(u8, bytes[0..4], "RPI1")) continue; + const digest = bytes[4..36].*; + const payload_path = try std.fmt.allocPrint(self.discovery_arena.allocator(), "{s}/{s}.rpk", .{ path, std.fmt.bytesToHex(digest, .lower) }); + const payload_entry = try self.payloads_by_path.getOrPut(self.allocator, payload_path); + if (!payload_entry.found_existing) { + payload_entry.value_ptr.* = std.math.cast(u32, self.indexed_payloads.items.len) orelse return error.OutOfMemory; + try self.indexed_payloads.append(self.allocator, .{ .path = payload_path, .digest = digest, .io = io }); + } + const payload_index = payload_entry.value_ptr.*; + var offset: usize = 36; + while (offset < bytes.len) : (offset += 32) { + const entry = try self.indexed_keys.getOrPut(self.allocator, bytes[offset..][0..32].*); + if (!entry.found_existing) entry.value_ptr.* = .empty; + if (std.mem.findScalar(u32, entry.value_ptr.items, payload_index) == null) + try entry.value_ptr.append(self.allocator, payload_index); + } + } + } + + fn loadIndexedKey(self: *LoadedPacks, key: [32]u8, takes: Common.SpecCacheTakes, modules: Common.ProgramModules) Allocator.Error!void { + const candidates = self.indexed_keys.get(key) orelse return; + for (candidates.items) |index| { + const payload = &self.indexed_payloads.items[index]; + if (!payload.attempted) { + payload.attempted = true; + var arena = base.SingleThreadArena.init(self.allocator); + const bytes = std.Io.Dir.cwd().readFileAlloc(payload.io, payload.path, arena.allocator(), .limited(1024 * 1024 * 1024)) catch |err| { + arena.deinit(); + switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + } + }; + self.bytes_loaded +|= bytes.len; + self.payloads_read +|= 1; + var actual: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &actual, .{}); + if (!std.mem.eql(u8, &actual, &payload.digest)) { + arena.deinit(); + continue; + } + self.appendPack(arena, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + }; + payload.pack = self.packs.items[self.packs.items.len - 1]; + } + const pack = payload.pack orelse continue; + for (pack.specs) |spec| { + if (!std.mem.eql(u8, &key, &spec.key)) continue; + if (takes == .test_dev_code and !spec.test_compatible) continue; + const located: backend.dev.LocatedArtifact = .{ .set = &pack.set, .index = spec.artifact }; + if (self.artifactLinesOutsideProgram(located, modules)) continue; + self.indexSpec(pack, spec) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + }; + return; + } + } + } + /// Load every `.rpk` file in `dir_path` and index the result. pub fn loadDir(allocator: Allocator, io: std.Io, dir_path: []const u8) LoadError!LoadedPacks { var self = LoadedPacks.init(allocator); @@ -1101,28 +1250,31 @@ pub const LoadedPacks = struct { } fn indexPack(self: *LoadedPacks, pack: *const PackFile.Pack) LoadError!void { - for (pack.specs) |spec| { - const root = pack.set.artifacts[spec.artifact]; - const identity = switch (root.kind) { - .proc => |identity| identity, - .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => return error.MalformedPack, - }; - if (self.specs.contains(spec.key)) continue; - try self.artifacts.ensureUnusedCapacity(1); - try self.specs.ensureUnusedCapacity(1); - self.artifacts.putAssumeCapacityNoClobber(spec.key, .{ .set = &pack.set, .index = spec.artifact }); - self.specs.putAssumeCapacityNoClobber(spec.key, .{ - .identity = identity.bytes, - .comptime_root_reads = spec.comptime_root_reads, - .rc_borrowed_params = spec.rc_borrowed_params, - .rc_ret_borrowed = spec.rc_ret_borrowed, - .rc_ret_lenders = spec.rc_ret_lenders, - .rc_read_only_params = spec.rc_read_only_params, - .rc_ret_unique = spec.rc_ret_unique, - .rc_ret_unique_fields = spec.rc_ret_unique_fields, - .rc_ret_conditions = spec.rc_ret_conditions, - }); - } + for (pack.specs) |spec| try self.indexSpec(pack, spec); + } + + fn indexSpec(self: *LoadedPacks, pack: *const PackFile.Pack, spec: PackFile.SpecEntry) LoadError!void { + const root = pack.set.artifacts[spec.artifact]; + const identity = switch (root.kind) { + .proc => |identity| identity, + .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => return error.MalformedPack, + }; + if (self.specs.contains(spec.key)) return; + try self.artifacts.ensureUnusedCapacity(1); + try self.specs.ensureUnusedCapacity(1); + self.artifacts.putAssumeCapacityNoClobber(spec.key, .{ .set = &pack.set, .index = spec.artifact }); + self.specs.putAssumeCapacityNoClobber(spec.key, .{ + .identity = identity.bytes, + .test_compatible = spec.test_compatible, + .comptime_root_reads = spec.comptime_root_reads, + .rc_borrowed_params = spec.rc_borrowed_params, + .rc_ret_borrowed = spec.rc_ret_borrowed, + .rc_ret_lenders = spec.rc_ret_lenders, + .rc_read_only_params = spec.rc_read_only_params, + .rc_ret_unique = spec.rc_ret_unique, + .rc_ret_unique_fields = spec.rc_ret_unique_fields, + .rc_ret_conditions = spec.rc_ret_conditions, + }); } /// Index every loaded object pack, in load order: the first pack to @@ -1178,7 +1330,7 @@ pub const LoadedPacks = struct { fn loadLinked(self: *LoadedPacks, pending: Pending, stores: *const LinkedStores, root_artifact: *const check.CheckedArtifact.CheckedModuleArtifact) LoadError!void { const modules = try packageModules(self.allocator, pending.build_env, root_artifact); defer self.allocator.free(modules); - const root_placement = optimizedPackPlacement(pending.build_env, root_artifact); + const root_placement = programPackPlacement(pending.build_env, root_artifact); for (&stores.stores) |*store| { for (modules) |module| { const path = try store.packagePackPath(module.identity, module.key); @@ -1201,6 +1353,14 @@ pub const LoadedPacks = struct { fn ensureLoaded(self: *LoadedPacks) void { if (self.state != .pending) return; const pending = self.pending orelse return; + if (pending.explicit_artifacts.len != 0) { + self.loadExplicitModules(pending, false) catch |err| { + self.unavailable(err); + return; + }; + self.indexPacks() catch |err| self.unavailable(err); + return; + } // The first lookups come from the compile-time evaluator's program, // which lowers inside checking, after the root module's artifact // exists and before the build has declared its artifacts final. @@ -1221,6 +1381,14 @@ pub const LoadedPacks = struct { fn ensureDiscoveryLoaded(self: *LoadedPacks) void { if (self.discovery_state != .pending) return; const pending = self.pending orelse return; + if (pending.explicit_artifacts.len != 0) { + self.loadExplicitModules(pending, true) catch { + self.discovery_state = .unavailable; + return; + }; + self.discovery_state = .ready; + return; + } const root_semantic = pending.build_env.getExecutableRootSemanticData() orelse return; const root_artifact = root_semantic.checked_artifact orelse return; self.loadPendingDiscovery(pending, root_artifact) catch |err| { @@ -1231,6 +1399,18 @@ pub const LoadedPacks = struct { self.discovery_state = .ready; } + fn loadExplicitModules(self: *LoadedPacks, pending: Pending, discovery: bool) LoadError!void { + const store = pending.store orelse return; + for (pending.explicit_artifacts) |artifact| { + const placement = pending.build_env.packPlacementForArtifactKey(artifact.key) orelse continue; + if (discovery) { + try store.loadIdentityDiscovery(pending.io, self, placement.origin, placement.identity); + } else { + try store.loadIdentity(pending.io, self, placement.origin, placement.identity); + } + } + } + fn loadPendingDiscovery(self: *LoadedPacks, pending: Pending, root_artifact: *const check.CheckedArtifact.CheckedModuleArtifact) LoadError!void { const store = pending.store orelse return; if (pending.build_env.packPlacementForArtifactKey(root_artifact.key)) |placement| { @@ -1264,6 +1444,11 @@ pub const LoadedPacks = struct { } pub fn deinit(self: *LoadedPacks) void { + var indexed = self.indexed_keys.valueIterator(); + while (indexed.next()) |list| list.deinit(self.allocator); + self.indexed_keys.deinit(self.allocator); + self.payloads_by_path.deinit(self.allocator); + self.indexed_payloads.deinit(self.allocator); var settlements = self.discovery.valueIterator(); while (settlements.next()) |list| list.deinit(self.allocator); self.discovery.deinit(self.allocator); @@ -1330,6 +1515,7 @@ pub const LoadedPacks = struct { fn findSpec(context: *anyopaque, key: [32]u8, takes: Common.SpecCacheTakes, modules: Common.ProgramModules) ?Common.SpecCacheHit { const self: *LoadedPacks = @ptrCast(@alignCast(context)); + self.lookups +|= 1; self.ensureLoaded(); if (self.state != .ready) return null; // An optimized object serves a consumer whose code is linked ahead @@ -1342,7 +1528,9 @@ pub const LoadedPacks = struct { } } if (!takes.takesDevCode()) return null; + if (!self.specs.contains(key)) self.loadIndexedKey(key, takes, modules) catch return null; const hit = self.specs.get(key) orelse return null; + if (takes == .test_dev_code and !hit.test_compatible) return null; // An entry whose splice would place a line or a location in a module // version the asking program does not lower against would place lines // that differ from the procedure's own; the program compiles it. @@ -1365,6 +1553,10 @@ pub const LoadedPacks = struct { /// location in a module version outside `modules`. fn linesOutsideProgram(self: *LoadedPacks, key: [32]u8, modules: Common.ProgramModules) bool { const located = self.artifacts.get(key) orelse return false; + return self.artifactLinesOutsideProgram(located, modules); + } + + fn artifactLinesOutsideProgram(self: *LoadedPacks, located: backend.dev.LocatedArtifact, modules: Common.ProgramModules) bool { const set = located.set; var all_present = true; for (set.source_files) |identity| all_present = all_present and Common.programHasModule(modules, identity); @@ -1440,6 +1632,85 @@ fn nameLessThan(_: void, lhs: []u8, rhs: []u8) bool { return std.mem.order(u8, lhs, rhs) == .lt; } +test "native test indexed admission pins verified payloads and rejects observation ABI mismatches" { + const a = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", a); + defer a.free(root); + var store = try Store.init(a, .{ .cache_dir = root, .roc_ctx = CoreCtx.default(a, a, io) }, RocTarget.detectNative(), "dev"); + defer store.deinit(); + const identity: [32]u8 = @splat(7); + const key: [32]u8 = @splat(11); + const set: backend.dev.ProcArtifact.Set = .{ + .arena = undefined, + .artifacts = &.{.{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "\xc3", .entry = 0, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }}, + }; + var spec: PackFile.SpecEntry = .{ + .key = key, + .artifact = 0, + .test_compatible = true, + .rc_borrowed_params = 0, + .rc_ret_borrowed = false, + .rc_ret_lenders = 0, + .rc_read_only_params = 0, + .rc_ret_unique = false, + .rc_ret_unique_fields = 0, + .rc_ret_conditions = &.{}, + }; + for ([_]bool{ true, false }) |compatible| { + spec.test_compatible = compatible; + spec.key = if (compatible) key else @splat(12); + const bytes = try PackFile.write(a, &set, &.{spec}); + defer a.free(bytes); + try store.writeIndexed(.local, identity, spec.key, bytes); + // Repeated publication reuses both immutable files. + try store.writeIndexed(.local, identity, spec.key, bytes); + } + var packs = LoadedPacks.init(a); + defer packs.deinit(); + try store.loadIdentity(io, &packs, .local, identity); + try packs.indexPacks(); + try std.testing.expectEqual(@as(u64, 0), packs.payloads_read); + const lookup = packs.specCacheLookup(); + try std.testing.expect(lookup.lookup(@splat(99), .test_dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 0), packs.payloads_read); + try std.testing.expect(lookup.lookup(@splat(12), .test_dev_code, &.{}) == null); + try std.testing.expect(lookup.lookup(@splat(12), .dev_code, &.{}) != null); + try std.testing.expect(lookup.lookup(key, .test_dev_code, &.{}) != null); + try std.testing.expectEqual(@as(u64, 2), packs.payloads_read); + // Eviction cannot revoke a promise already made to specialization. + for (packs.indexed_payloads.items) |payload| try std.Io.Dir.cwd().deleteFile(io, payload.path); + const source = packs.spliceSource(); + try std.testing.expect(source.find(source.context, key) != null); + try std.testing.expect(lookup.lookup(key, .test_dev_code, &.{}) != null); + var missing = LoadedPacks.init(a); + defer missing.deinit(); + try store.loadIdentity(io, &missing, .local, identity); + try missing.indexPacks(); + try std.testing.expect(missing.specCacheLookup().lookup(key, .test_dev_code, &.{}) == null); + + const bytes = try PackFile.write(a, &set, &.{spec}); + defer a.free(bytes); + try store.writeIndexed(.local, identity, spec.key, bytes); + var corrupt = LoadedPacks.init(a); + defer corrupt.deinit(); + try store.loadIdentity(io, &corrupt, .local, identity); + try corrupt.indexPacks(); + for (corrupt.indexed_payloads.items) |payload| { + try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = payload.path, .data = "interrupted payload" }); + } + try std.testing.expect(corrupt.specCacheLookup().lookup(spec.key, .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 0), corrupt.hits); + try store.writeIndexed(.local, identity, spec.key, bytes); + var repaired = LoadedPacks.init(a); + defer repaired.deinit(); + try store.loadIdentity(io, &repaired, .local, identity); + try repaired.indexPacks(); + try std.testing.expect(repaired.specCacheLookup().lookup(spec.key, .dev_code, &.{}) != null); +} + test "discovery summaries round-trip and a file of another version is no offer" { const allocator = std.testing.allocator; const Summary = lir.CheckedPipeline.DiscoverySummary.Summary; diff --git a/src/cli/progress.zig b/src/cli/progress.zig index 8ea4d441e4f..bac635f8bdb 100644 --- a/src/cli/progress.zig +++ b/src/cli/progress.zig @@ -52,8 +52,9 @@ const row_marker_width: usize = 4; const max_phases: usize = 16; const max_subphases: usize = 25; // Shared lowering and a runtime continuation each report eight lowering groups -// and native emission; tests also report result-cache counts. -const max_counter_groups: usize = 2 * 9 + 1; +// and native emission; tests also report compile-time roots, result-cache +// counts, and native artifact-cache counts. +const max_counter_groups: usize = 2 * 9 + 3; const max_counters_per_group: usize = 32; /// Rows in the ranked timing list printed after the phases. const max_ranked_timings: usize = 10; @@ -1014,10 +1015,13 @@ test "counter capacity includes shared runtime native and test groups" { reporter.start(); // Eight lowering groups plus native emission for each consumer. for (0..18) |_| reporter.recordCounters("Compiler work", &.{.{ .name = "Count", .count = 1 }}); + reporter.recordCounters("Compile-time roots", &.{.{ .name = "Evaluated", .count = 3 }}); reporter.recordCounters("Final test cache", &.{.{ .name = "Hits", .count = 7 }}); + reporter.recordCounters("Native test artifact cache", &.{.{ .name = "Hits", .count = 1 }}); reporter.finish(); try testing.expectEqual(@as(usize, 18), std.mem.count(u8, buf.written(), "Compiler work")); try testing.expect(std.mem.find(u8, buf.written(), "Final test cache") != null); + try testing.expect(std.mem.find(u8, buf.written(), "Native test artifact cache") != null); } test "static breakdown lists every phase with the timings flag" { diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 46de63e39eb..9c93e123dfe 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -503,6 +503,7 @@ const CustomCase = enum { build_interpreter_owns_intermediates, build_glibc_target_non_linux_error, build_windows_shared_library, + native_test_artifact_reuse, cache_passing_results, cache_failing_results, cache_invalidated_by_source_change, @@ -2999,6 +3000,7 @@ const subcommand_cases = [_]CliCase{ .{ .id = 0, .suite = .subcommands, .name = "issue 11439: errors outside tests prevent success even with no expects", .body = .{ .command = .{ .args = &.{ "test", "--no-cache", "--no-color" }, .roc_file = "test/cli/has_type_error_annotation.roc", .exit = .{ .code = 1 }, .stdout_exact = "", .contains = &.{ .{ .stream = .stderr, .text = "Ran 0 tests" }, .{ .stream = .stderr, .text = "0 passed" }, .{ .stream = .stderr, .text = "0 failed" }, .{ .stream = .stderr, .text = "0 compiler errors" }, .{ .stream = .stderr, .text = "Compilation failed with 1 error." } }, .not_contains = &.{ .{ .stream = .stderr, .text = "All (" }, .{ .stream = .stderr, .text = "panic" } } } } }, .{ .id = 0, .suite = .subcommands, .name = "transparent alias locals satisfy alias and backing requests", .body = .{ .command = .{ .args = &.{ "test", "--no-cache" }, .roc_file = "test/cli/TransparentAliasLocalRequests.roc", .exit = .success, .contains = &.{.{ .stream = .stdout, .text = "All (1) tests passed" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "related local and request had no exact nominal backing path" }, .{ .stream = .stderr, .text = "postcheck invariant violated" }, .{ .stream = .stderr, .text = "panic" } } } } }, .{ .id = 0, .suite = .subcommands, .name = "roc test skips a downloaded dependency's expects", .body = .{ .custom = .roc_test_skips_url_dependency_expects } }, + .{ .id = 0, .suite = .subcommands, .name = "issue 12591: native test artifacts survive result invalidation", .backend = .dev, .body = .{ .custom = .native_test_artifact_reuse } }, .{ .id = 0, .suite = .subcommands, .name = "roc test runs a path dependency's expects and caches them by source", .body = .{ .custom = .roc_test_caches_local_dependency_expects } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11439: blocked expects remain counted with cached results and disappear after a fix", .body = .{ .custom = .roc_test_counts_checking_errors_with_cached_results } }, .{ .id = 0, .suite = .subcommands, .name = "roc test runs an app's expects without its platform entrypoint", .body = .{ .command = .{ .args = &.{ "test", "--no-cache" }, .roc_file = "test/cli/AppWithoutEntrypoint.roc", .exit = .success, .contains = &.{.{ .stream = .stdout, .text = "All (1) tests passed" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "exposed but not defined" }, .{ .stream = .stderr, .text = "missing platform required definition" }, .{ .stream = .stderr, .text = "compile time crash" }, .{ .stream = .stderr, .text = "panic" } } } } }, @@ -5270,6 +5272,7 @@ fn runCustomCase( .build_interpreter_owns_intermediates => customBuildInterpreterOwnsIntermediates(io, allocator, &env, &timer, timeout_ms), .build_glibc_target_non_linux_error => customGlibcTargetNonLinux(io, allocator, &env, &timer, timeout_ms), .build_windows_shared_library => customWindowsSharedLibrary(io, allocator, &env, &timer, timeout_ms), + .native_test_artifact_reuse => customNativeTestArtifactReuse(io, allocator, &env, &timer, timeout_ms), .cache_passing_results => customCachePassingResults(io, allocator, &env, &timer, timeout_ms, spec.backend orelse .interpreter), .cache_failing_results => customCacheFailingResults(io, allocator, &env, &timer, timeout_ms, spec.backend orelse .interpreter), .cache_invalidated_by_source_change => customCacheInvalidated(io, allocator, &env, &timer, timeout_ms, spec.backend orelse .interpreter), @@ -13530,6 +13533,191 @@ fn customPipelineParitySharedCache(io: std.Io, allocator: Allocator, env: *const return null; } +fn customNativeTestArtifactReuse(io: std.Io, allocator: Allocator, env: *const CaseEnv, timer: *harness.Timer, timeout_ms: u64) ?TestResult { + var traced = CaseEnv{ + .dirs = env.dirs, + .env_map = env.env_map.clone(allocator) catch |err| + return customInfraFailure(allocator, timer, "failed to clone native test environment: {}", .{err}), + }; + defer traced.env_map.deinit(); + if (comptime build_options.dev_diagnostics) traced.env_map.put("ROC_PACK_TRACE", "1") catch |err| + return customInfraFailure(allocator, timer, "failed to enable native admission trace: {}", .{err}); + const helper = + \\Helper := [].{ + \\ double : I64 -> I64 + \\ double = |x| { + \\ dbg x + \\ x * 2 + \\ } + \\ observed : I64 -> I64 + \\ observed = |x| { + \\ expect x > 0 + \\ double(x) + \\ } + \\ handlers : List(Box((I64 -> I64))) + \\ handlers = { + \\ base = 42 + \\ [Box.box(|x| x + base)] + \\ } + \\ greeters : List(Box(({} -> Str))) + \\ greeters = { + \\ name = "a greeting long enough to live on the heap" + \\ [Box.box(|{}| name)] + \\ } + \\ sum : I64 -> I64 + \\ sum = |x| List.fold(handlers, 0, |total, f| total + Box.unbox(f)(x)) + \\ greet : {} -> Str + \\ greet = |{}| List.fold(greeters, "", |text, f| Str.concat(text, Box.unbox(f)({}))) + \\} + \\expect Helper.double(10) == 20 + \\ + ; + const numeric_test = + \\expect { + \\ a = Helper.double(20) + \\ b = Helper.double(21) + \\ expect b > 0 + \\ a + b == 82 + \\} + \\ + ; + const constant_tests = + \\expect Helper.sum(3) == 45 + \\expect Helper.greet({}) == "a greeting long enough to live on the heap" + \\ + ; + const header = "Main := {}\nimport Helper\n"; + const observed_test = "expect Helper.observed(3) == 6\n"; + const main_source = header ++ numeric_test ++ observed_test ++ constant_tests; + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Helper.roc", helper)) |failure| return failure; + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Main.roc", main_source)) |failure| return failure; + const app = std.fs.path.join(allocator, &.{ env.dirs.work_dir, "Main.roc" }) catch |err| + return customInfraFailure(allocator, timer, "failed to name native artifact fixture: {}", .{err}); + defer allocator.free(app); + var emitted_cold: u64 = 0; + var failure_transcript: ?[]const u8 = null; + defer if (failure_transcript) |transcript| allocator.free(transcript); + for (0..10) |pass| { + if (pass == 2) { + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Main.roc", "# invalidate only this module's results\n" ++ main_source)) |failure| return failure; + } + if (pass == 4) { + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Main.roc", header ++ observed_test ++ numeric_test ++ constant_tests)) |failure| return failure; + } + if (pass == 5) { + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Helper.roc", "# change source provenance\n" ++ helper)) |failure| return failure; + } + if (pass == 6 or pass == 7) { + const updated_helper = std.mem.replaceOwned(u8, allocator, helper, if (pass == 6) "base = 42" else "expect x > 0", if (pass == 6) "base = 43" else "expect x < 0") catch |err| + return customInfraFailure(allocator, timer, "failed to edit native test helper: {}", .{err}); + defer allocator.free(updated_helper); + const updated_main = std.mem.replaceOwned(u8, allocator, main_source, "== 45", if (pass == 6) "== 46" else "== 45") catch |err| + return customInfraFailure(allocator, timer, "failed to edit native test expectation: {}", .{err}); + defer allocator.free(updated_main); + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Helper.roc", updated_helper)) |failure| return failure; + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Main.roc", updated_main)) |failure| return failure; + } + const remaining = childCommandTimeoutMs(timer, timeout_ms) orelse + return timeoutFailure(allocator, timer, .run, "native test artifact fixture timed out"); + const args: []const []const u8 = if (pass == 3 or pass == 9) + &.{ "test", "--opt=dev", "--no-cache", "--jobs=4", "--timings" } + else if (pass == 4 or pass == 8) + &.{ "test", "--opt=dev", "--jobs=4", "--timings" } + else + &.{ "test", "--opt=dev", "--jobs=1", "--timings" }; + const run = runRocInEnv(io, allocator, &traced, args, app, .relative, &.{}, null, remaining) catch |err| + return customInfraFailure(allocator, timer, "native test spawn failed: {}", .{err}); + if (pass < 7 and (!processSucceeded(run.term) or std.mem.find(u8, run.stdout, "All (7) tests passed") == null)) + return failureFromRun(allocator, timer, run, "native artifact test failed"); + if (std.mem.count(u8, run.stderr, "[dbg]") != 4) + return failureFromRun(allocator, timer, run, "native artifact execution or replay lost debug observations"); + const debug_lines: []const []const u8 = if (pass == 4 or pass == 5) + &.{ "[dbg] 3\n", "[dbg] 20\n", "[dbg] 21\n", "[dbg] 10\n" } + else + &.{ "[dbg] 20\n", "[dbg] 21\n", "[dbg] 3\n", "[dbg] 10\n" }; + var debug_cursor: usize = 0; + for (debug_lines) |line| { + const position = std.mem.findPos(u8, run.stderr, debug_cursor, line) orelse + return failureFromRun(allocator, timer, run, "native debug observations changed order or attribution"); + debug_cursor = position + line.len; + } + if (pass >= 7) { + if (exitCode(run.term) != 1 or std.mem.find(u8, run.stderr, "1 failed") == null or std.mem.find(u8, run.stderr, "expect x < 0") == null) + return failureFromRun(allocator, timer, run, "inline failure changed its status or source diagnostic"); + const first_debug = std.mem.find(u8, run.stderr, "[dbg]").?; + const summary_start = std.mem.findPos(u8, run.stderr, first_debug, "Ran ") orelse + return failureFromRun(allocator, timer, run, "missing failed test summary"); + const transcript = run.stderr[first_debug..summary_start]; + if (failure_transcript) |expected| { + if (!std.mem.eql(u8, expected, transcript)) return failureFromRun(allocator, timer, run, "warm or uncached failure observations changed"); + } else failure_transcript = allocator.dupe(u8, transcript) catch |err| + return customInfraFailure(allocator, timer, "failed to retain failure transcript: {}", .{err}); + continue; + } + if (pass == 1) { + if (std.mem.find(u8, run.stdout, "(cached)") == null or timingCounter(run.stderr, "Native test artifact cache", "Payloads read") != null) + return failureFromRun(allocator, timer, run, "complete result hits entered native artifact loading"); + continue; + } + const emitted = timingCounter(run.stderr, "Native test procedures", "Emitted") orelse + return failureFromRun(allocator, timer, run, "missing native emission counters"); + const spliced = timingCounter(run.stderr, "Native test procedures", "Spliced") orelse + return failureFromRun(allocator, timer, run, "missing native splice counters"); + if (pass == 0) { + emitted_cold = emitted; + } else if (pass == 2 and (spliced == 0 or emitted >= emitted_cold)) { + return failureFromRun(allocator, timer, run, "edited test did not reuse native procedures and reduce emission"); + } else if (pass == 3 and spliced != 0) { + return failureFromRun(allocator, timer, run, "cache-disabled native test spliced artifacts"); + } + if (comptime build_options.dev_diagnostics) { + if (pass == 2 and std.mem.find(u8, run.stderr, "skipped plain=true cached=true") == null) + return failureFromRun(allocator, timer, run, "native helper was not admitted before source-body lowering"); + } + } + // A fresh store proves the test consumes code published by `check`, not + // an earlier test run. Both commands use the same declared app and + // imported helper provenance. + const cross_body = + \\import Helper + \\initial : I64 + \\initial = Helper.double(9) + \\main! = || {} + \\expect Helper.double(20) == 40 + \\ + ; + const platform_absolute = absoluteFromProjectRoot(allocator, "test/fx/platform/main.roc") catch |err| + return customInfraFailure(allocator, timer, "failed to name cross-command platform: {}", .{err}); + defer allocator.free(platform_absolute); + const platform_relative = std.fs.path.relative(allocator, project_root_path, null, env.dirs.work_dir, platform_absolute) catch |err| + return customInfraFailure(allocator, timer, "failed to locate cross-command platform: {}", .{err}); + defer allocator.free(platform_relative); + if (builtin.os.tag == .windows) std.mem.replaceScalar(u8, platform_relative, '\\', '/'); + const cross_source = std.fmt.allocPrint(allocator, "app [main!] {{ pf: platform \"{s}\" }}\n{s}", .{ platform_relative, cross_body }) catch |err| + return customInfraFailure(allocator, timer, "failed to prepare cross-command source: {}", .{err}); + defer allocator.free(cross_source); + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Cross.roc", cross_source)) |failure| return failure; + const cross_app = std.fs.path.join(allocator, &.{ env.dirs.work_dir, "Cross.roc" }) catch |err| + return customInfraFailure(allocator, timer, "failed to name cross-command app: {}", .{err}); + defer allocator.free(cross_app); + const cross_cache = std.fs.path.join(allocator, &.{ env.dirs.work_dir, "cross-cache" }) catch |err| + return customInfraFailure(allocator, timer, "failed to name cross-command cache: {}", .{err}); + defer allocator.free(cross_cache); + traced.env_map.put("ROC_CACHE_DIR", cross_cache) catch |err| + return customInfraFailure(allocator, timer, "failed to isolate cross-command cache: {}", .{err}); + for (0..2) |pass| { + const remaining = childCommandTimeoutMs(timer, timeout_ms) orelse + return timeoutFailure(allocator, timer, .run, "cross-command native reuse timed out"); + const args: []const []const u8 = if (pass == 0) &.{ "check", "--jobs=1" } else &.{ "test", "--opt=dev", "--jobs=4", "--timings" }; + const run = runRocInEnv(io, allocator, &traced, args, cross_app, .relative, &.{}, null, remaining) catch |err| + return customInfraFailure(allocator, timer, "cross-command spawn failed: {}", .{err}); + if (!processSucceeded(run.term)) return failureFromRun(allocator, timer, run, "cross-command native fixture failed"); + if (pass == 1 and ((timingCounter(run.stderr, "Native test procedures", "Spliced") orelse 0) == 0 or std.mem.find(u8, run.stdout, "All (2) tests passed") == null)) + return failureFromRun(allocator, timer, run, "test did not consume the generic native helper published by check"); + } + return null; +} + fn customCachePassingResults(io: std.Io, allocator: Allocator, env: *const CaseEnv, timer: *harness.Timer, timeout_ms: u64, backend: OptMode) ?TestResult { const opt_arg = backendOptArg(allocator, backend) catch |err| return customInfraFailure(allocator, timer, "failed to allocate opt arg: {}", .{err}); diff --git a/src/eval/inspected.zig b/src/eval/inspected.zig index a22882eb7dd..fac0bcc7e24 100644 --- a/src/eval/inspected.zig +++ b/src/eval/inspected.zig @@ -409,6 +409,17 @@ pub const BoolRootModule = struct { static_data: RuntimeStaticData = .{}, }; +/// Native test code capability. The provider owns admitted bytes until all +/// invocations complete; publication borrows only code this execution emitted. +pub const DevTestArtifacts = struct { + splice_source: ?backend.dev.SpliceSource = null, + executor: ?base.post_check_task_executor.Executor = null, + publication: ?struct { + context: *anyopaque, + accept: *const fn (*anyopaque, *const backend.dev.ProcArtifact.Set) Allocator.Error!void, + } = null, +}; + /// Timings for JIT-compiling and running boolean test roots with the dev backend. pub const DevBoolRootTimingSnapshot = struct { static_strings_ns: u64 = 0, @@ -417,6 +428,8 @@ pub const DevBoolRootTimingSnapshot = struct { entrypoint_codegen_ns: u64 = 0, executable_memory_ns: u64 = 0, root_execution_ns: u64 = 0, + procedures_emitted: u64 = 0, + procedures_spliced: u64 = 0, }; /// Mutable timing collector for one dev-backend boolean-root evaluation batch. @@ -2298,6 +2311,12 @@ pub fn devEvalBoolRootModule(allocator: Allocator, module: BoolRootModule, timin /// Compile one explicitly shared program once, retaining module observation partitions. pub fn devEvalSharedBoolRootModules(allocator: Allocator, modules: []const BoolRootModule, timing: ?*DevBoolRootTiming, max_workers: ?usize) Error!BoolRootEvalBatch { + return devEvalSharedBoolRootModulesWithArtifacts(allocator, modules, timing, max_workers, null); +} + +/// The specialized native consumer; null retains the interpreter helpers' and +/// Boxy caller's in-process emission contract. +pub fn devEvalSharedBoolRootModulesWithArtifacts(allocator: Allocator, modules: []const BoolRootModule, timing: ?*DevBoolRootTiming, max_workers: ?usize, artifacts: ?DevTestArtifacts) Error!BoolRootEvalBatch { try validateSharedBoolRootModules(modules); const module = modules[0]; const store = module.store; @@ -2333,16 +2352,26 @@ pub fn devEvalSharedBoolRootModules(allocator: Allocator, modules: []const BoolR defer static_strings.deinit(); if (timing) |timings| timings.finish(static_strings_started_ns, .static_strings); - var static_image = backend.StaticDataImage.init(allocator, module.static_data.exports) catch |err| switch (err) { + var static_layout = backend.StaticDataImage.Layout.plan(allocator, module.static_data.exports, .{}) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => zeroArgRootInvariant("invalid frozen data graph for dev test roots"), }; - defer static_image.deinit(); - const static_addresses = static_image.lirValueAddresses(allocator, module.static_data.value_count) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => zeroArgRootInvariant("dev test graph omitted an explicit static value slot"), - }; - defer allocator.free(static_addresses); + var layout_owned = true; + defer if (layout_owned) static_layout.deinit(allocator); + var static_image: ?backend.StaticDataImage = null; + defer if (static_image) |*image| image.deinit(); + var static_addresses: ?[]usize = null; + defer if (static_addresses) |addresses| allocator.free(addresses); + if (artifacts == null) { + static_image = backend.StaticDataImage.init(allocator, module.static_data.exports) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => zeroArgRootInvariant("invalid frozen data graph for dev test roots"), + }; + static_addresses = static_image.?.lirValueAddresses(allocator, module.static_data.value_count) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => zeroArgRootInvariant("dev test graph omitted an explicit static value slot"), + }; + } const codegen_setup_started_ns = if (timing) |timings| timings.start() else 0; var codegen = try HostLirCodeGen.initWithBoxyMetadata( @@ -2357,20 +2386,57 @@ pub fn devEvalSharedBoolRootModules(allocator: Allocator, modules: []const BoolR roc_target.host_cpu.level(), ); defer codegen.deinit(); - codegen.setNativeStaticData(static_addresses); + if (artifacts != null) { + codegen.generation_mode = .object_file; + try codegen.setStaticDataSymbols(module.static_data.exports); + } else codegen.setNativeStaticData(static_addresses.?); + var splice = backend.dev.HostSplice.init(allocator); + defer splice.deinit(); + if (artifacts != null) splice.reserveStaticData(&static_layout); // Worker threads read this table through the codegen'd code while the // parallel run below is still in flight, so it must stay alive until // this function returns. - var native_fns = boxyNativeFnTable(); - codegen.boxy_native_fns = &native_fns; + var native_fns: ?BoxyNativeFnTable = if (tables.needsRuntimeForStore(store)) boxyNativeFnTable() else null; + const native_fn_table: ?*const BoxyNativeFnTable = if (native_fns) |*table| table else null; + codegen.boxy_native_fns = native_fn_table; if (timing) |timings| timings.finish(codegen_setup_started_ns, .codegen_setup); const procedure_codegen_started_ns = if (timing) |timings| timings.start() else 0; - try codegen.compileAllProcSpecs(store.getProcSpecs()); + var demand = std.ArrayList(LirProcSpecId).empty; + defer demand.deinit(allocator); + for (store.getProcSpecs(), 0..) |proc, index| { + // Finalization has already materialized these initializers as + // frozen data; their bodies are not runtime procedure demand. + if (proc.is_static_initializer) continue; + try demand.append(allocator, @fromBackingInt(@intCast(index))); + } + if (artifacts) |options| if (options.splice_source) |source| try splice.spliceExternal(&codegen, demand.items, source); const static_rc_helpers = try backend.collectRequiredRcHelpers(allocator, module.static_data.exports); defer allocator.free(static_rc_helpers); - try codegen.compileStaticDataRcHelpers(static_rc_helpers); + var retained: ?backend.dev.NativeProcCompiler.Retained = null; + defer if (retained) |*owned| owned.deinit(); + if (artifacts) |options| { + var metrics: backend.dev.NativeProcCompiler.Metrics = .{}; + retained = try backend.dev.NativeProcCompiler.run(HostLirCodeGen, allocator, &codegen, demand.items, .{ + .target = backend.dev.LirCodeGenMod.host_lir_codegen_target, + .executor = options.executor, + .static_helpers = static_rc_helpers, + .constant_exports = module.static_data.exports, + .metrics_out = &metrics, + }); + const carried = try retained.?.dataItems(allocator); + defer allocator.free(carried); + try splice.addDataItems(carried); + if (timing) |timings| { + timings.snapshot_value.procedures_emitted += metrics.procedures_emitted; + timings.snapshot_value.procedures_spliced += splice.spliced_procs; + } + } else { + try codegen.compileAllProcSpecs(store.getProcSpecs()); + try codegen.compileStaticDataRcHelpers(static_rc_helpers); + } if (timing) |timings| timings.finish(procedure_codegen_started_ns, .procedure_codegen); + codegen.generation_mode = .native_execution; const entry_offsets = try allocator.alloc(usize, roots.len); defer allocator.free(entry_offsets); @@ -2388,8 +2454,14 @@ pub fn devEvalSharedBoolRootModules(allocator: Allocator, modules: []const BoolR if (timing) |timings| timings.finish(entrypoint_codegen_started_ns, .entrypoint_codegen); const executable_memory_started_ns = if (timing) |timings| timings.start() else 0; + if (artifacts != null) try splice.generateHostedStubs(&codegen, native_fn_table); try codegen.finishImage(); - var executable = try ExecutableMemory.initWithEntryOffsetAndUnwindInfo( + var executable = if (artifacts != null) splice.link(&codegen, native_fn_table) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.MappingFailed => return error.MmapFailed, + error.UnresolvedSymbol => zeroArgRootInvariant("admitted native test code names an unavailable symbol"), + error.InvalidRelocation => zeroArgRootInvariant("admitted native test code has an invalid relocation"), + } else try ExecutableMemory.initWithEntryOffsetAndUnwindInfo( codegen.getGeneratedCode(), 0, codegen.getUnwindFunctions(), @@ -2401,6 +2473,22 @@ pub fn devEvalSharedBoolRootModules(allocator: Allocator, modules: []const BoolR if (timing) |timings| timings.finish(executable_deinit_started_ns, .executable_memory); } + if (artifacts != null) { + layout_owned = false; + static_image = static_layout.place(allocator, splice.staticDataMemory(&executable)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => zeroArgRootInvariant("invalid frozen data placement for native tests"), + }; + } + if (artifacts) |options| if (options.publication) |publication| { + var extracted = backend.dev.ProcArtifact.extract(HostLirCodeGen, allocator, &codegen, store.getProcSpecs(), layouts, static_strings.exports, module.static_data.exports, splice.data.items) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => zeroArgRootInvariant("native test emission produced invalid procedure artifacts"), + }; + defer extracted.deinit(); + try publication.accept(publication.context, &extracted); + }; + const Resolver = struct { codegen: *const HostLirCodeGen, executable: *const ExecutableMemory, @@ -2413,7 +2501,7 @@ pub fn devEvalSharedBoolRootModules(allocator: Allocator, modules: []const BoolR } }; var resolver = Resolver{ .codegen = &codegen, .executable = &executable }; - static_image.resolveFunctionRelocations(.{ .context = &resolver, .resolve = Resolver.resolve }) catch |err| switch (err) { + static_image.?.resolveFunctionRelocations(.{ .context = &resolver, .resolve = Resolver.resolve }) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => zeroArgRootInvariant("dev test graph omitted a callable procedure"), }; diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index 592f52b5541..fcce148f29c 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -1031,6 +1031,8 @@ pub const CodeProvision = enum { source_bodies, /// Native dev emission with a splice source for CTFE's declared native procedure domain. host_dev_objects, + /// Native tests splice only observation-compatible host procedures. + host_test_dev_objects, /// Native dev emission with its own target-specific splice source. target_dev_objects, /// As `host_dev_objects`, emitted as an object file that is linked, so the @@ -1060,6 +1062,7 @@ pub const CodeProvision = enum { pub fn specCacheTakes(self: CodeProvision) postcheck.Common.SpecCacheTakes { return switch (self) { .source_bodies, .host_dev_objects, .target_dev_objects => .dev_code, + .host_test_dev_objects => .test_dev_code, .host_dev_objects_beside_linked => .dev_code_beside_linked_objects, .host_linked_dev_objects, .target_linked_dev_objects => .dev_code_and_linked_objects, .target_linked_objects => .linked_objects, @@ -1073,6 +1076,9 @@ pub const CodeProvision = enum { /// evaluation runs code in process, where nothing is linked. pub fn sharedDomain(self: CodeProvision, other: CodeProvision) CodeProvision { if (!self.hostDomain() or !other.hostDomain()) return .source_bodies; + if (self == .host_test_dev_objects or other == .host_test_dev_objects) { + return if (self.linksObjects() or other.linksObjects()) .source_bodies else .host_test_dev_objects; + } return if (self.linksObjects() or other.linksObjects()) .host_dev_objects_beside_linked else .host_dev_objects; } @@ -1081,13 +1087,13 @@ pub const CodeProvision = enum { fn linksObjects(self: CodeProvision) bool { return switch (self) { .host_linked_dev_objects, .host_dev_objects_beside_linked, .target_linked_dev_objects, .target_linked_objects => true, - .source_bodies, .host_dev_objects, .target_dev_objects => false, + .source_bodies, .host_dev_objects, .host_test_dev_objects, .target_dev_objects => false, }; } fn hostDomain(self: CodeProvision) bool { return switch (self) { - .host_dev_objects, .host_linked_dev_objects, .host_dev_objects_beside_linked => true, + .host_dev_objects, .host_test_dev_objects, .host_linked_dev_objects, .host_dev_objects_beside_linked => true, .source_bodies, .target_dev_objects, .target_linked_dev_objects, .target_linked_objects => false, }; } @@ -1566,12 +1572,13 @@ pub fn prepareCheckedModulesMonotype( } test "shared producer native provision requires every consumer in the host dev domain" { - const host_domain = [_]CodeProvision{ .host_dev_objects, .host_linked_dev_objects, .host_dev_objects_beside_linked }; + const host_domain = [_]CodeProvision{ .host_dev_objects, .host_test_dev_objects, .host_linked_dev_objects, .host_dev_objects_beside_linked }; for (std.enums.values(CodeProvision)) |host| { for (std.enums.values(CodeProvision)) |runtime| { const both_host = std.mem.findScalar(CodeProvision, &host_domain, host) != null and std.mem.findScalar(CodeProvision, &host_domain, runtime) != null; - const either_linked = host != .host_dev_objects or runtime != .host_dev_objects; - const expected: CodeProvision = if (!both_host) .source_bodies else if (either_linked) .host_dev_objects_beside_linked else .host_dev_objects; + const either_linked = host.linksObjects() or runtime.linksObjects(); + const tests = host == .host_test_dev_objects or runtime == .host_test_dev_objects; + const expected: CodeProvision = if (!both_host or (tests and either_linked)) .source_bodies else if (tests) .host_test_dev_objects else if (either_linked) .host_dev_objects_beside_linked else .host_dev_objects; try std.testing.expectEqual(expected, host.sharedDomain(runtime)); try std.testing.expectEqual(expected, runtime.sharedDomain(host)); } diff --git a/src/lir/pack_program.zig b/src/lir/pack_program.zig index ea1ed4e0767..bd8bf89b8ce 100644 --- a/src/lir/pack_program.zig +++ b/src/lir/pack_program.zig @@ -696,6 +696,65 @@ pub fn literalConvertingProcs(allocator: Allocator, store: *const LirStore) Allo return converts; } +/// Local bodies whose native code needs no test observation ABI. External +/// bodies are opaque here; publication never guesses what they observe. +/// The pack producer closes these facts over its explicit procedure graph. +pub fn observationIndependentProcs(allocator: Allocator, store: *const LirStore) Allocator.Error![]bool { + const independent = try allocator.alloc(bool, store.procSpecCount()); + errdefer allocator.free(independent); + var seen = try std.DynamicBitSetUnmanaged.initEmpty(allocator, store.cfStmtCount()); + defer seen.deinit(allocator); + var visited = std.ArrayList(LIR.CFStmtId).empty; + defer visited.deinit(allocator); + var work = std.ArrayList(LIR.CFStmtId).empty; + defer work.deinit(allocator); + for (store.getProcSpecs(), independent) |proc, *result| { + result.* = false; + const body = proc.body orelse continue; + for (visited.items) |stmt| seen.unset(@backingInt(stmt)); + visited.clearRetainingCapacity(); + work.clearRetainingCapacity(); + result.* = true; + try work.append(allocator, body); + while (work.pop()) |id| { + if (seen.isSet(@backingInt(id))) continue; + seen.set(@backingInt(id)); + try visited.append(allocator, id); + const stmt = store.getCFStmtImmediate(id).*; + if (stmt == .expect or stmt == .expect_err) { + result.* = false; + break; + } + try BodyClone.appendSuccessors(store, &work, id, allocator); + } + } + return independent; +} + +test "native test eligibility distinguishes generic debug code from expect instrumentation and external bodies" { + const a = std.testing.allocator; + var store = LirStore.init(a); + defer store.deinit(); + const value = try store.addLocal(.{ .layout_idx = .u64 }); + const ret = try store.addCFStmt(.{ .ret = .{ .value = value } }, .test_fixture); + const debug = try store.addCFStmt(.{ .debug = .{ .message = value, .next = ret } }, .test_fixture); + const expect = try store.addCFStmt(.{ .expect = .{ .condition = value, .next = ret, .site = null } }, .test_fixture); + const observed = try store.addCFStmt(.{ .expect = .{ .condition = value, .next = ret, .site = @fromBackingInt(7) } }, .test_fixture); + for ([_]?LIR.CFStmtId{ ret, debug, expect, observed, null }, 0..) |body, index| { + _ = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .identity = LIR.ProcIdentity.forTest(@intCast(index)), + .args = try store.addLocalSpan(&.{}), + .body = body, + .frame_locals = try store.addLocalSpan(&.{}), + .ret_layout = .u64, + }, .none); + } + const eligible = try observationIndependentProcs(a, &store); + defer a.free(eligible); + try std.testing.expectEqualSlices(bool, &.{ true, true, false, false, false }, eligible); +} + /// Convert an intentional fixture-table position while preserving enum inference. fn fixtureTableIndex(comptime index: u32) u32 { return index; diff --git a/src/postcheck/common.zig b/src/postcheck/common.zig index ca08190615b..f68ff007909 100644 --- a/src/postcheck/common.zig +++ b/src/postcheck/common.zig @@ -450,6 +450,8 @@ pub const SpecCacheHit = struct { /// The entry is a linked object reached through its C-ABI entry rather /// than spliced code (`LIR.LirProcSpec.c_abi_entry`). c_abi_entry: bool = false, + /// Producer proved that the complete closure needs no expect instrumentation. + test_compatible: bool = false, /// Producer-owned demand summary: the compile-time roots the procedure's /// code reads, through every procedure it reaches. A program that takes /// the hit demands them as if it had lowered the body (design.md @@ -489,6 +491,8 @@ pub const DiscoverySettlement = struct { pub const SpecCacheTakes = enum { /// Dev code only: the consumer splices code and links nothing. dev_code, + /// Native tests require a closure with no uninstrumented expects. + test_dev_code, /// Dev code only, for a producer shared with a consumer whose code is /// linked: no dev entry whose code includes a procedure a linked object /// provides, since that consumer takes the procedure from the object. @@ -503,7 +507,7 @@ pub const SpecCacheTakes = enum { pub fn takesLinkedObjects(self: SpecCacheTakes) bool { return switch (self) { - .dev_code, .dev_code_beside_linked_objects => false, + .dev_code, .test_dev_code, .dev_code_beside_linked_objects => false, .dev_code_and_linked_objects, .linked_objects => true, }; } @@ -514,7 +518,7 @@ pub const SpecCacheTakes = enum { /// program through a splice. pub fn refusesDevCodeOfLinkedProcs(self: SpecCacheTakes) bool { return switch (self) { - .dev_code, .linked_objects => false, + .dev_code, .test_dev_code, .linked_objects => false, .dev_code_beside_linked_objects, .dev_code_and_linked_objects => true, }; } From 00a7af5f5cb310620b89cbebdb8b2ab8a529bbb3 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 11:45:53 -0400 Subject: [PATCH 16/97] Reuse individual test outcomes from checked dependencies --- .github/workflows/ci_zig.yml | 3 + build.zig | 5 + ci/test_outcome_cache.py | 232 +++++++++++ design.md | 74 +++- src/check/checked_artifact.zig | 46 ++- src/check/value_identity.zig | 133 ++++++- src/cli/main.zig | 559 ++++++++++++++++----------- src/cli/test/parallel_cli_runner.zig | 6 +- src/eval/inspected.zig | 50 ++- 9 files changed, 849 insertions(+), 259 deletions(-) create mode 100644 ci/test_outcome_cache.py diff --git a/.github/workflows/ci_zig.yml b/.github/workflows/ci_zig.yml index 418b67c408b..0436d0fb6b7 100644 --- a/.github/workflows/ci_zig.yml +++ b/.github/workflows/ci_zig.yml @@ -162,6 +162,9 @@ jobs: - name: Run CLI platform tests (LLVM size/speed backends) run: zig build run-test-cli -- --include-llvm --filter "[size]" --filter "[speed]" + - name: Run focused test outcome cache regressions + run: zig build run-test-outcome-cache + # run-test-dylib and run-test-archive are MiniCI phases, so PR CI runs # them on these same three OSes; they are not repeated here. diff --git a/build.zig b/build.zig index 2062b719291..a36377faf60 100755 --- a/build.zig +++ b/build.zig @@ -2458,6 +2458,11 @@ pub fn build(b: *std.Build) void { const roc_exe = main_exe_result.exe; roc_exe.root_module.addImport("compiler_version", compiler_version_module); const fixture_roc = executableRuntimePath(b, roc_exe, strip_macho_exports_tool); + const outcome_cache_check = b.addSystemCommand(&.{"python3"}); + outcome_cache_check.addFileArg(b.path("ci/test_outcome_cache.py")); + outcome_cache_check.addArg("--roc"); + outcome_cache_check.addFileArg(fixture_roc); + b.step("run-test-outcome-cache", "Check per-root test outcome reuse with focused CLI fixtures").dependOn(&outcome_cache_check.step); const fixture_options = b.addOptions(); // Zig resolves this path against the build runner's directory. Tests run // in prepared fixture roots, so they also need the absolute build root. diff --git a/ci/test_outcome_cache.py b/ci/test_outcome_cache.py new file mode 100644 index 00000000000..d2a03bd019b --- /dev/null +++ b/ci/test_outcome_cache.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Focused issue #12593 CLI checks\n does not run MiniCI or compiler eval suites.""" + +import argparse +from concurrent.futures import ThreadPoolExecutor +import os +from pathlib import Path +import re +import subprocess +import tempfile + + +def is_outcome(path): + if not path.is_file(): + return False + with path.open('rb') as source: + return source.read(len(b'ROC_TEST_OUTCOMES_V1')) == b'ROC_TEST_OUTCOMES_V1' + + +def transcript(data): + # Only the final run epilogue is outside the transcript contract. + return re.split(rb"(?m)^(?:All \(\d+\) tests passed|Ran \d+ tests) in ", data)[0] + + +class Fixture: + def __init__(self, root, roc): + self.root = root + self.roc = roc + self.cache = root / "cache" + self.env = dict(os.environ, ROC_CACHE_DIR=str(self.cache)) + self.path = root / "Main.roc" + + def write(self, text, name="Main.roc"): + (self.root / name).write_text(text) + + def run(self, *, cache=True, timings=False, mode="dev"): + args = [str(self.roc), "test", str(self.path), f"--opt={mode}", + "--specialize=yes", "--no-color", "--verbose"] + if not cache: + args.append("--no-cache") + if timings: + args.append("--timings") + result = subprocess.run(args, env=self.env, capture_output=True, timeout=120) + assert result.returncode in (0, 1, 2), result.stderr.decode(errors="replace") + assert b"panic" not in result.stderr, result.stderr.decode(errors="replace") + return result + + def check(self, hits=None, *, mode="dev"): + cached = self.run(mode=mode) + fresh = self.run(cache=False, mode=mode) + assert cached.returncode == fresh.returncode, (cached.stderr, fresh.stderr) + for stream in ("stdout", "stderr"): + a, b = transcript(getattr(cached, stream)), transcript(getattr(fresh, stream)) + assert a == b, (stream, a.decode(errors="replace"), b.decode(errors="replace")) + if hits is not None: + # The first run above publishes all misses, so all roots must now hit. + warm = self.run(timings=True, mode=mode) + self.hits(warm, hits) + text = re.sub(r"\s+", " ", warm.stderr.decode(errors="replace")) + for label in ('Roots submitted for specialization',): + assert re.search(label + r"\s+0\b", text), text + if mode == 'dev': + for label in ('Root wrappers generated', 'Roots executed'): + assert re.search(label + r"\s+0\b", text), text + return cached + + @staticmethod + def hits(result, expected): + text = result.stderr.decode(errors="replace") + text = re.sub(r"\s+", " ", text) + match = re.search(r"Roots cached\s+(\d+)", text) + assert match, text + assert int(match[1]) == expected, (expected, match[1], text) + + +def exercise(f): + base = ('helper = |x| x + 1\n' + 'expect helper(2) == 3\n' + 'expect 4 == 4\n' + 'expect { dbg "stable"\n 5 == 6 }\n') + f.write(base) + f.hits(f.run(timings=True), 0) + f.check(3) + f.write(base.replace('4 == 4', '4 == 3')) + f.hits(f.run(timings=True), 2) + f.check(3) + f.write(base) + f.hits(f.run(timings=True), 3) + f.write(base.replace('x + 1', 'x + 2')) + f.hits(f.run(timings=True), 2) + f.check(3) + + binding = 'value = 1\nexpect value == 2\nexpect 3 == 3\n' + f.write(binding) + f.check(2) + f.write(binding.replace('value = 1', 'value= 1')) + f.hits(f.run(timings=True), 1) + f.check(2) + + # Duplicate bodies remain separate reporting slots\n moving, adding and + # deleting roots must preserve current source provenance and multiplicity. + duplicate = 'expect { dbg "duplicate"\n 1 == 1 }\n' + for source in (duplicate * 2, '\n' + duplicate * 2, + duplicate * 3, duplicate, duplicate + base): + f.write(source) + f.check(source.count('expect ')) + + # Both roots reach the same inline source site. Editing one root changes + # its contribution while preserving coalesced counts and reporting order. + inline = ('observe = |x| { expect x > 0\n x }\n' + 'expect observe(1) == 1\n' + 'expect observe(0) == 0\n') + f.write(inline) + f.check(2) + f.write(inline.replace('observe(0) == 0', 'observe(2) == 2')) + f.hits(f.run(timings=True), 1) + f.check(2) + f.write(inline) + f.hits(f.run(timings=True), 2) + f.check(2) + + # Static dependency closure includes a branch never executed, recursive + # helpers, a compile-time constant and a closure capture. + deps = ('unused = |x| x + 1\n' + 'recur = |n| if n == 0 { 0 } else { recur(n - 1) }\n' + 'constant = 17\n' + 'capture = |x| { y = constant\n |z| x + y + z }\n' + 'expect (if True { 1 } else { unused(0) }) == 1\n' + 'expect recur(3) == 0\n' + 'expect capture(1)(2) == 20\n' + 'expect 7 == 7\n') + f.write(deps) + f.check(4) + for old, new, hits in [('x + 1', 'x + 2', 3), + ('{ 0 }', '{ 1 }', 3), + ('constant = 17', 'constant = 18', 3)]: + f.write(deps.replace(old, new)) + f.hits(f.run(timings=True), hits) + f.check(4) + f.write(deps) + f.check(4) + + # Imported implementation summaries, including transitive dependencies, + # distinguish a relevant helper change from an unrelated imported test. + leaf = 'module [helper]\nhelper = |x| x + 1\nexpect 8 == 8\n' + middle = 'module [call]\nimport Leaf\ncall = |x| Leaf.helper(x)\n' + main = 'import Middle\nexpect Middle.call(2) == 3\nexpect 9 == 9\n' + f.write(leaf, 'Leaf.roc') + f.write(middle, 'Middle.roc') + f.write(main) + f.check(3) + f.write(leaf.replace('8 == 8', '8 == 7'), 'Leaf.roc') + f.hits(f.run(timings=True), 2) + f.check(3) + f.write(leaf.replace('x + 1', 'x + 2'), 'Leaf.roc') + # The independent Main root and reverted Leaf root remain hits. + f.hits(f.run(timings=True), 2) + f.check(3) + + # Custom numeral conversion and inspection are semantic inputs, including + # the generated structural inspection used by the second root. + custom = """MyNum := { value: I64 }.{ + from_numeral : Numeral -> Try(MyNum, [InvalidNumeral(Str)]) + from_numeral = |_| { + dbg "conversion" + Ok({ value: 1 }) + } + plus : MyNum, MyNum -> MyNum + plus = |a, b| { value: a.value + b.value } + to_i64 = |a| a.value + to_inspect : MyNum -> Str + to_inspect = |_| "before" +} +add_one = |x| x.plus(1) +expect { + value : MyNum + value = { value: 0 } + result = add_one(value) + dbg result + result.to_i64() == 1 +} +expect { + dbg { names: ["one", "two"], tag: Some(3) } + True +} +""" + for source in (custom, custom.replace('value: 1', 'value: 2'), + custom.replace('"before"', '"after!"'), custom): + f.write(source) + f.check(2) + # Other consumers preserve the same partial-admission ownership contract. + for mode in ('interpreter', 'speed'): + f.check(2, mode=mode) + f.write(main) + f.check(3) + + # Atomic publication and checksums: every truncated or mutated outcome is + # a miss, even if its corrupted status byte would otherwise be valid. + outcomes = [p for p in f.cache.rglob('*') if is_outcome(p)] + assert outcomes, 'no per-root outcome records published' + for p in outcomes: + data = bytearray(p.read_bytes()) + if len(data) % 2: + p.write_bytes(data[:20]) + else: + data[-33] ^= 1 + p.write_bytes(data) + f.hits(f.run(timings=True), 0) + f.check(3) + for p in f.cache.rglob('*'): + if is_outcome(p): + p.unlink() + with ThreadPoolExecutor(max_workers=3) as pool: + runs = list(pool.map(lambda _: f.run(), range(3))) + for result in runs: + assert result.returncode == runs[0].returncode + assert transcript(result.stdout) == transcript(runs[0].stdout) + assert transcript(result.stderr) == transcript(runs[0].stderr) + f.check(3) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--roc', type=Path, required=True) + args = parser.parse_args() + with tempfile.TemporaryDirectory(prefix='roc-test-outcomes-') as temp: + exercise(Fixture(Path(temp), args.roc.resolve())) + print('issue 12593: focused test-outcome cache checks passed') + + +if __name__ == '__main__': + main() diff --git a/design.md b/design.md index 59231fa5bd6..215181d3a7d 100644 --- a/design.md +++ b/design.md @@ -23632,8 +23632,7 @@ link one shared library per checked module. The per-module checked cache boundary remains intact, but cache boundaries are not native link/load boundaries for optimized tests. Cached test results are merged into the command result stream before reporting; uncached test roots are compiled into the single -test library and written back to their original checked-module cache entries -after execution. +test library and published as individual outcome records after execution. The command-level plan is built after checking has produced all checked modules and before any optimized test backend code is generated: @@ -23649,7 +23648,7 @@ checked modules -> one native shared library -> parallel test-root calls -> deterministic transcript and result merge by test-plan order - -> checked cache writes for fresh test results + -> per-root outcome writes for fresh test roots -> final timing summary ``` @@ -23713,7 +23712,7 @@ another top-level expect remains inline; collecting it as a second entrypoint would execute and count the same source statement twice. When roots from separate checked modules reach the same source site, the command combines their counts by qualified declaring module and exact region -after execution. Per-module cache entries retain the uncombined counts so any +after execution. Per-root outcome records retain the uncombined counts so any mix of cached and freshly executed roots produces the same command result. After the single library is loaded, test roots run on a worker pool. Each root @@ -23730,10 +23729,11 @@ Expect counters are worker-local dense arrays, initialized once per execution batch. Generated LLVM performs a direct indexed increment; generated dev code uses a compiler-internal native callback into the same worker-local arrays, and the interpreter records through an explicit observer. No increment is atomic. -After all roots finish, the runner adds the worker arrays by site and reports -only nonzero rows. Thus synchronization cost is independent of how many times -an expect executes, and report-time work is proportional to the number of -lowered sites and workers rather than the number of executions. Worker shards +At each root completion the worker retains its nonzero observations on that +root's result and clears those counters before its next call. The runner adds +those sparse observations by site for consumers of aggregate counts. Thus +synchronization cost is independent of how many times an expect executes; +harvesting scans the root's site's range once, never once per execution. Worker shards start on separate cache lines so hot source sites do not introduce false sharing between parallel roots. @@ -23765,19 +23765,51 @@ results use the same ordering path. Diagnostics, `dbg`, `expect` failures, `expect_err` regions, crashes, and backend compiler errors stay attached to their test-plan entries until rendering and cache writes replay the plan order. -Checked test-result cache entries use the same checked module cache identity -as `roc check`. There is no second test-result cache key. -The test-result bundle may carry its own payload format and compiler-version -admission data, but those fields decide whether the decoded bundle can be used; -they do not extend the checked module cache identity. The result cache key does not -include selected optimization mode, backend, worker count, color mode, -verbosity, terminal width, terminal style, elapsed time, final summary text, -test-run completion order, test-root identity, or test-root order. Test-root -identity and order are explicit checked module data and payload shape, not -additional cache-key inputs. Cache reads validate that a decoded test-result -bundle matches the current checked module's test-root shape and cache format -before admitting it, but this is payload validation rather than a new source of -identity. +Checked test outcomes are separate per-root records. Their semantic identity +is produced during checked publication, independently of the root's current +reporting slot. It includes the checked root type, body and name-reference +closure, including both sides of conditionals, captures and constants. +Definition summaries are Merkle identities of strongly connected components; +recursive helpers therefore have stable identities without traversing a fresh +transitive graph for every test. The checked type/method context covers literal +conversions, generated dispatch and inspection, including failure observations. +Imported implementation summaries, rather than runtime specialization or the +last execution's read set, supply imported dependencies. Checked publication +stores these in `CompileTimeRootIdentityTable.test_rows`, `test_context`, and +`test_definitions`. Import contexts cover the type and method environment; +explicit external and associated references select implementation summaries. +Imported test-only edits do not change those summaries. Platform/app relation +identity remains an explicit binding input. This is independent of compile-time +value reuse's existing identity and portability contract. + +The execution strategy, execution mode, compiler compatibility and payload +version partition outcome admission. Rendering settings, elapsed time, root +order and the whole enclosing module are not semantic identity. Each current +plan entry independently loads its outcome, so identical roots retain their +output multiplicity. Missing, corrupt or incompatible records are misses; +atomic cache publication prevents incomplete records from becoming hits. Each +record also contains its expected key and a checksum. Atomic replacement under +one key can refresh reporting provenance for the same semantic outcome; reads +still validate that provenance against the current sources. + +Source provenance is validated separately from semantics. This format retains +exact root source regions and validates the declaring source prefix through each +inline observation before replay. The prefix pins its lexical owner as well as +its exact site; later unrelated source edits do not invalidate it. Extended +failure regions, whose owner is not yet carried in the outcome format, retain +the exact checked artifact as reporting provenance. A moved root or changed inline source +invalidates that record; no offsets or embedded source details are guessed or +patched. Results are rebound to the current plan order only after validation. +Passes and failed expects are equally admissible; compiler errors and incomplete +execution are not durable outcomes. + +Admission precedes submission of runtime roots. Only misses enter specialization, +LIR, code generation and execution. Each executing root owns its inline-expect +counts as well as its transcript and result. Worker-local counters are harvested +and cleared at root completion, retaining sparse nonzero observations with that +root. Cached and fresh root observations are merged before command-level +coalescing. Publication happens before coalescing, preserving every root's +uncombined contribution. Shared code and static data remain command-owned. Cached test results store structured, pre-render data: the result, ordered transcript events, source regions, failure data, and raw or structured Roc diff --git a/src/check/checked_artifact.zig b/src/check/checked_artifact.zig index 235ce9405f2..f4589710d8f 100644 --- a/src/check/checked_artifact.zig +++ b/src/check/checked_artifact.zig @@ -33847,6 +33847,12 @@ pub const CompileTimeRootIdentity = value_identity.CompileTimeRootIdentity; /// the module's value context, computed when the module is published. pub const CompileTimeRootIdentityTable = struct { rows: []const CompileTimeRootIdentity = &.{}, + /// Root semantic dependencies for outcome reuse, independent of CTFE reuse. + test_rows: []const CompileTimeRootIdentity = &.{}, + /// One context digest covering checking policy, types and visible methods. + test_context: []const [32]u8 = &.{}, + /// Implementation identities sorted by the producer's exact definition id. + test_definitions: []const value_identity.DefinitionIdentity = &.{}, /// The module's value context (`value_identity.ModuleIdentities.context`): /// one digest in every published module, none in a table that was never /// computed. @@ -33862,6 +33868,9 @@ pub const CompileTimeRootIdentityTable = struct { pub const Serialized = extern struct { rows: SerializedSlice(CompileTimeRootIdentity) = .{}, + test_rows: SerializedSlice(CompileTimeRootIdentity) = .{}, + test_context: SerializedSlice([32]u8) = .{}, + test_definitions: SerializedSlice(value_identity.DefinitionIdentity) = .{}, context: SerializedSlice([32]u8) = .{}, exports: SerializedSlice(value_identity.ExportIdentity) = .{}, method_requires: SerializedSlice([32]u8) = .{}, @@ -33874,6 +33883,8 @@ pub const CompileTimeRootIdentityTable = struct { pub fn validate(self: CompileTimeRootIdentityTable, root_count: usize) error{CorruptArtifact}!void { if (self.rows.len != 0 and self.rows.len != root_count) return error.CorruptArtifact; if (self.context.len > 1) return error.CorruptArtifact; + if (self.test_rows.len != 0 and self.test_rows.len != root_count) return error.CorruptArtifact; + if (self.test_context.len > 1) return error.CorruptArtifact; } /// The module's value context, when it was computed. @@ -34227,8 +34238,9 @@ pub const CheckedModuleArtifact = struct { // identity table its rows, value context, exports and method and // export requirements, and the portability column one more. Stored // function evidence keeps its target and structural payloads in - // two pools of their own. Template demand vectors add one. - std.debug.assert(artifact_serialize.relocatablePointerCount(Serialized) == 246); + // two pools of their own. Template demand vectors add one. Test outcome + // identities add roots, context and definition summaries. + std.debug.assert(artifact_serialize.relocatablePointerCount(Serialized) == 249); } /// Every field but the inline POD `key`, `source_hash`, and @@ -37891,6 +37903,7 @@ pub fn publishFromTypedModule( }, .own_identity = module_identity.stable_hash, }); + defer allocator.free(module_identities.definitions); var compile_time_root_identities = CompileTimeRootIdentityTable{ .rows = module_identities.roots, .exports = module_identities.exports, @@ -37899,6 +37912,35 @@ pub fn publishFromTypedModule( }; errdefer compile_time_root_identities.deinit(allocator); compile_time_root_identities.context = try allocator.dupe([32]u8, &.{module_identities.context}); + // Preserve checking policy and import names, but each imported value's + // implementation is supplied by its checked summary, not its module key. + const test_import_names = try allocator.dupe(ImportIdentity, checking_context_identity.imports); + defer allocator.free(test_import_names); + for (test_import_names) |*imported| imported.artifact_key = null; + var test_checking_context = checking_context_identity; + test_checking_context.imports = test_import_names; + const test_identities = try value_identity.compute(allocator, .{ + .env = module.moduleEnvConst(), + .names = &canonical_names, + .types = checked_types.view(), + .roots = compile_time_roots.roots, + .top_level_values = &top_level_values, + .context = .{ + .checking_context = hashCheckingContextIdentity(test_checking_context), + .direct_import_keys = undefined, // Test identities consume explicit imported summaries. + }, + .own_identity = module_identity.stable_hash, + .test_dependencies = true, + .imports = inputs.imports, + .available = inputs.available_artifacts, + .relations = inputs.relation_artifacts, + }); + compile_time_root_identities.test_rows = test_identities.roots; + compile_time_root_identities.test_definitions = test_identities.definitions; + defer allocator.free(test_identities.exports); + defer allocator.free(test_identities.method_requires); + defer allocator.free(test_identities.export_requires); + compile_time_root_identities.test_context = try allocator.dupe([32]u8, &.{test_identities.context}); var provided_exports = try ProvidedExportTable.fromModule( allocator, diff --git a/src/check/value_identity.zig b/src/check/value_identity.zig index e65525a1c26..f0d22d4a49d 100644 --- a/src/check/value_identity.zig +++ b/src/check/value_identity.zig @@ -50,6 +50,12 @@ pub const Inputs = struct { context: ContextHashes, /// The module's own content identity, which names its own declarations. own_identity: [32]u8, + /// Test summaries use published import contexts and exact referenced + /// implementations instead of whole imported artifacts. + test_dependencies: bool = false, + imports: []const checked_artifact.PublishImportArtifact = &.{}, + available: []const checked_artifact.ImportedModuleView = &.{}, + relations: []const checked_artifact.ImportedModuleView = &.{}, }; /// The artifact-key inputs a module's value context hashes. @@ -70,6 +76,11 @@ pub const ExportIdentity = extern struct { /// A module's value identities: its value context, the identities of its /// exports, the platform requirements its methods reach, and every root's /// identity. +pub const DefinitionIdentity = extern struct { + def: CIR.Def.Idx, + value: [32]u8, +}; + pub const ModuleIdentities = struct { /// Digest of everything outside its plain definitions that dispatch on /// its types can select or depend on: the module context and the type @@ -86,6 +97,7 @@ pub const ModuleIdentities = struct { export_requires: [][32]u8, /// Every root's identity, in root order. Owned. roots: []CompileTimeRootIdentity, + definitions: []DefinitionIdentity, }; /// Compute the module's value context and every root's identity. @@ -107,6 +119,32 @@ pub fn compute(allocator: Allocator, inputs: Inputs) Allocator.Error!ModuleIdent const export_requires = try state.exportRequires(); errdefer allocator.free(export_requires); + var definitions = std.ArrayList(DefinitionIdentity).empty; + errdefer definitions.deinit(allocator); + if (inputs.test_dependencies) { + var seen = collections.DenseMap(CIR.Def.Idx, void).init(allocator); + defer seen.deinit(); + for (inputs.env.store.sliceDefs(inputs.env.all_defs)) |def| { + try seen.put(def, {}); + try definitions.append(allocator, .{ .def = def, .value = try state.methodIdentity(def) }); + } + for (inputs.env.store.sliceDefs(inputs.env.exports)) |def| { + if (seen.contains(def)) continue; + try seen.put(def, {}); + try definitions.append(allocator, .{ .def = def, .value = try state.methodIdentity(def) }); + } + for (inputs.env.method_defs.entries.items) |entry| { + const def = entry.value.def_idx; + if (seen.contains(def)) continue; + try seen.put(def, {}); + try definitions.append(allocator, .{ .def = def, .value = try state.methodIdentity(def) }); + } + } + std.mem.sort(DefinitionIdentity, definitions.items, {}, struct { + fn lessThan(_: void, a: DefinitionIdentity, b: DefinitionIdentity) bool { + return @backingInt(a.def) < @backingInt(b.def); + } + }.lessThan); var ordinals = std.AutoHashMapUnmanaged([32]u8, u32).empty; defer ordinals.deinit(allocator); for (inputs.roots, identities) |root, *identity| { @@ -118,6 +156,7 @@ pub fn compute(allocator: Allocator, inputs: Inputs) Allocator.Error!ModuleIdent .method_requires = method_requires, .export_requires = export_requires, .roots = identities, + .definitions = try definitions.toOwnedSlice(allocator), }; } @@ -227,6 +266,20 @@ const State = struct { hashTag(&hasher, "scheme"); hasher.update(&try self.type_walk.digest(root)); } else hashTag(&hasher, "no-scheme"); + if (self.inputs.test_dependencies) { + // Failed-expect detail includes the complete mentioned binding, + // including whitespace between its pattern and expression. + switch (env.store.getExpr(def.expr)) { + .e_lambda, .e_closure, .e_hosted_lambda => {}, + else => { + const start = env.store.getPatternRegion(def.pattern).start.offset; + const end = env.store.getExprRegion(def.expr).end.offset; + // This is the reporter's exact excerpt eligibility rule. + if (start < end and end <= self.source().len) hashBytes(&hasher, self.source()[start..end]); + }, + } + hasher.update(&try self.importedReferences(def.expr)); + } return hasher.finalResult(); } @@ -340,6 +393,77 @@ const State = struct { } } + fn importedDefinition(self: *const State, view: checked_artifact.ImportedModuleView, def: CIR.Def.Idx) [32]u8 { + _ = self; + const summary = view.compile_time_root_identities; + var lo: usize = 0; + var hi = summary.test_definitions.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + const entry = summary.test_definitions[mid]; + if (@backingInt(entry.def) < @backingInt(def)) { + lo = mid + 1; + } else if (@backingInt(entry.def) > @backingInt(def)) { + hi = mid; + } else { + var hash = Sha256.init(.{}); + hashTag(&hash, "imported-test-definition"); + hash.update(&summary.test_context[0]); + hash.update(&entry.value); + return hash.finalResult(); + } + } + base.invariant("checked imported reference has no published implementation identity", .{}); + } + + fn importedByIndex(self: *const State, index: CIR.Import.Idx) checked_artifact.ImportedModuleView { + const identity = self.inputs.env.importIdentity(index) orelse base.invariant("checked external lookup has no module identity", .{}); + return self.importedByIdentity(self.inputs.env.moduleIdentityHash(identity).*); + } + + fn importedByIdentity(self: *const State, identity: [32]u8) checked_artifact.ImportedModuleView { + for (self.inputs.imports) |entry| if (std.meta.eql(entry.view.module_identity.stable_hash, identity)) return entry.view; + for (self.inputs.available) |view| if (std.meta.eql(view.module_identity.stable_hash, identity)) return view; + for (self.inputs.relations) |view| if (std.meta.eql(view.module_identity.stable_hash, identity)) return view; + base.invariant("checked associated lookup has no declaring module", .{}); + } + + /// Traverse explicit checked name references on every path, including + /// nested closure bodies. Local recursion is handled by the SCC summaries. + fn importedReferences(self: *State, root: CIR.Expr.Idx) Allocator.Error![32]u8 { + var stack = std.ArrayList(CIR.Expr.Idx).empty; + defer stack.deinit(self.allocator); + var seen = collections.DenseMap(CIR.Expr.Idx, void).init(self.allocator); + defer seen.deinit(); + var values = std.ArrayList([32]u8).empty; + defer values.deinit(self.allocator); + try stack.append(self.allocator, root); + while (stack.pop()) |expr| { + const entry = try seen.getOrPut(expr); + if (entry.found_existing) continue; + switch (self.inputs.env.store.getExpr(expr)) { + .e_lookup_external => |lookup| { + const view = self.importedByIndex(lookup.module_idx); + const target: CIR.Def.Idx = @fromBackingInt(lookup.target_node_idx); + try values.append(self.allocator, self.importedDefinition(view, target)); + }, + .e_lookup_associated_resolved => |lookup| { + const identity = self.inputs.env.moduleIdentityHash(lookup.module_identity); + if (!std.meta.eql(identity.*, self.inputs.own_identity)) { + try values.append(self.allocator, self.importedDefinition(self.importedByIdentity(identity.*), lookup.target_def_idx)); + } + }, + else => {}, + } + try appendExprChildren(self.inputs.env, expr, &stack, self.allocator); + } + sortDigests(values.items); + var hash = Sha256.init(.{}); + hashTag(&hash, "checked-import-references"); + hashDigests(&hash, values.items); + return hash.finalResult(); + } + /// Everything outside any one root that its value can depend on: the /// module's checking context and imports, its name and kind, and the /// files it imports. @@ -350,7 +474,13 @@ const State = struct { hashTag(&hasher, domain); hashTag(&hasher, "context"); hasher.update(&context.checking_context); - hasher.update(&context.direct_import_keys); + if (self.inputs.test_dependencies) { + for (self.inputs.imports) |imported| { + const summary = imported.view.compile_time_root_identities; + if (summary.test_context.len != 1) base.invariant("imported checked module has no test dependency context", .{}); + hasher.update(&summary.test_context[0]); + } + } else hasher.update(&context.direct_import_keys); // A type module's main type is named after the module, so the name // covers it; a type module whose declaration is missing names no // main type at all. @@ -545,6 +675,7 @@ const State = struct { const text = self.regionText(env.store.getStatementRegion(statement)); hashBytes(&value, text); hashDigests(&value, try self.referencedIdentities(expectBody(env, statement))); + if (self.inputs.test_dependencies) value.update(&try self.importedReferences(expectBody(env, statement))); hashBytes(&site, text); }, .hoisted => |hoisted| { diff --git a/src/cli/main.zig b/src/cli/main.zig index acc2ceec9b0..40fb442ced4 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -12541,6 +12541,8 @@ const CliTestResultItem = struct { result: CliTestResult, order: u32, region: base.Region, + /// Executing root that owns this inline observation, before coalescing. + owner_order: ?u32 = null, inline_expect: bool = false, inline_passed: u64 = 0, inline_failed: u64 = 0, @@ -12550,8 +12552,8 @@ const CliTestResultItem = struct { transcript: []const CliTestTranscriptEvent = &.{}, failure_detail: ?[]const u8, failure_detail_visibility: CliTestFailureDetailVisibility = .always, - /// Whether the result follows from the module alone, so a later run of the - /// same module can replay it. A test backend failing to run is not. + /// Whether execution completed with a source-language outcome. Backend + /// failures and incomplete execution are not reusable test outcomes. cacheable: bool = true, }; @@ -12690,15 +12692,14 @@ const CliTestPlanModule = struct { test_roots: []check.CheckedArtifact.RootRequest, first_entry_index: u32, entry_count: u32, - cached_results: ?[]CliTestResultItem = null, - cached_summary: CliTestRunSummary = .{}, + cached_roots: []?[]CliTestResultItem = &.{}, - fn releaseCachedResults(self: *CliTestPlanModule) []CliTestResultItem { - const results = self.cached_results orelse { - base.invariant("CLI test invariant violated: cached results were released from an uncached plan module", .{}); + fn uncachedCount(self: *const CliTestPlanModule) usize { + var count: usize = 0; + for (self.cached_roots) |slot| if (slot == null) { + count += 1; }; - self.cached_results = null; - return results; + return count; } }; @@ -12714,9 +12715,8 @@ const CliTestPlan = struct { fn deinit(self: *CliTestPlan, allocator: Allocator) void { for (self.modules) |*module| { allocator.free(module.test_roots); - if (module.cached_results) |results| { - deinitCliTestResultItems(allocator, results); - } + for (module.cached_roots) |slot| if (slot) |results| deinitCliTestResultItems(allocator, results); + allocator.free(module.cached_roots); } allocator.free(self.modules); allocator.free(self.entries); @@ -12737,10 +12737,9 @@ fn cliTestCacheStats(plan: *const CliTestPlan) CliTestCacheStats { .roots_total = plan.entries.len, }; for (plan.modules) |module| { - if (module.cached_results != null) { - stats.modules_cached += 1; - stats.roots_cached += module.entry_count; - } + const hits = module.test_roots.len - module.uncachedCount(); + stats.roots_cached += hits; + if (hits == module.test_roots.len) stats.modules_cached += 1; } return stats; } @@ -12778,11 +12777,6 @@ test "CLI test cache counters expose partial module and root hits" { try std.testing.expectEqual(@as(u64, 5), counters[5].count); } -const CliCachedModuleTestResults = struct { - results: []CliTestResultItem, - summary: CliTestRunSummary, -}; - fn deinitCliTestResultItemPayload(allocator: Allocator, result: CliTestResultItem) void { deinitCliTestTranscriptEvents(allocator, result.transcript); if (result.failure_detail) |message| allocator.free(message); @@ -12825,7 +12819,7 @@ fn nameCliTestPlanSymbols(allocator: Allocator, entries: []CliTestPlanEntry) All return buffer; } -const cli_test_cache_magic = "ROC_TEST_RESULTS_V9"; +const cli_test_cache_magic = "ROC_TEST_OUTCOMES_V1"; fn appendU32(bytes: *std.ArrayList(u8), allocator: std.mem.Allocator, value: u32) Allocator.Error!void { var buf: [4]u8 = undefined; @@ -12915,23 +12909,49 @@ fn summarizeTestResults(results: []const CliTestResultItem) CliTestRunSummary { return summary; } -fn storeCliTestResultsInCache( +fn cliTestOutcomeKey(artifact: *const check.CheckedArtifact.CheckedModuleArtifact, root: check.CheckedArtifact.RootRequest, strategy: base.SpecializationStrategy, opt: cli_args.OptLevel) [32]u8 { + const id = root.compile_time_root orelse base.invariant("test request has no checked root identity", .{}); + const identity = artifact.compile_time_root_identities.test_rows[@backingInt(id)]; + var hasher = base.Sha256.init(.{}); + hasher.update(&identity.value); + if (artifact.checking_context_identity.platform_app_relation) |relation| hasher.update(&relation.bytes); + const region = testRootRegion(artifact.moduleEnvConst(), root.source); + var provenance: [8]u8 = undefined; + std.mem.writeInt(u32, provenance[0..4], region.start.offset, .little); + std.mem.writeInt(u32, provenance[4..8], region.end.offset, .little); + hasher.update(&provenance); + return cliTestCacheKey(.{ .bytes = hasher.finalResult() }, strategy, opt); +} + +fn cliTestSourceDigest(env: *const ModuleEnv, end: u32) [32]u8 { + var hasher = base.Sha256.init(.{}); + hasher.update(env.getSourceAll()[0..end]); + return hasher.finalResult(); +} + +fn storeCliTestOutcomeInCache( ctx: *CliCtx, cache_manager: ?*CacheManager, artifact: *const check.CheckedArtifact.CheckedModuleArtifact, specialization_strategy: base.SpecializationStrategy, opt: cli_args.OptLevel, + root: check.CheckedArtifact.RootRequest, results: []const CliTestResultItem, ) (Allocator.Error || error{NoHomeDirectory})!void { const manager = cache_manager orelse return; for (results) |result| { - if (!result.cacheable) return; + if (!result.cacheable or result.result == .compiler_error) return; } + const outcome_key = cliTestOutcomeKey(artifact, root, specialization_strategy, opt); var bytes = std.ArrayList(u8).empty; defer bytes.deinit(ctx.gpa); try bytes.appendSlice(ctx.gpa, cli_test_cache_magic); - try appendU32(&bytes, ctx.gpa, @intCast(results.len)); + try bytes.appendSlice(ctx.gpa, &outcome_key); + const root_region = testRootRegion(artifact.moduleEnvConst(), root.source); + try appendU32(&bytes, ctx.gpa, root_region.start.offset); + try appendU32(&bytes, ctx.gpa, root_region.end.offset); + try appendU32(&bytes, ctx.gpa, std.math.cast(u32, results.len) orelse return error.OutOfMemory); for (results) |result| { std.debug.assert(!result.inline_suppressed); try bytes.append(ctx.gpa, @intFromBool(result.inline_expect)); @@ -12942,31 +12962,34 @@ fn storeCliTestResultsInCache( } if (result.source_env) |source_env| { const qualified_name = source_env.qualifiedModuleName(); - try appendU32(&bytes, ctx.gpa, @intCast(qualified_name.len)); + try appendU32(&bytes, ctx.gpa, std.math.cast(u32, qualified_name.len) orelse return error.OutOfMemory); try bytes.appendSlice(ctx.gpa, qualified_name); } else { try appendU32(&bytes, ctx.gpa, 0); } - try appendU32(&bytes, ctx.gpa, result.order); try appendU32(&bytes, ctx.gpa, result.region.start.offset); try appendU32(&bytes, ctx.gpa, result.region.end.offset); + if (result.source_env) |env| try bytes.appendSlice(ctx.gpa, &cliTestSourceDigest(env, result.region.end.offset)); + const extended_provenance = !result.inline_expect and !result.region.eq(root_region); + try bytes.append(ctx.gpa, @intFromBool(extended_provenance)); + if (extended_provenance) try bytes.appendSlice(ctx.gpa, &artifact.key.bytes); try bytes.append(ctx.gpa, switch (result.result) { .passed => 0, .failed => 1, .compiler_error => 2, }); - try appendU32(&bytes, ctx.gpa, @intCast(result.transcript.len)); + try appendU32(&bytes, ctx.gpa, std.math.cast(u32, result.transcript.len) orelse return error.OutOfMemory); for (result.transcript) |event| { try bytes.append(ctx.gpa, @backingInt(event.kind)); try bytes.append(ctx.gpa, @backingInt(event.stream)); const payload = cliTestTranscriptEventPayload(event); - try appendU32(&bytes, ctx.gpa, @intCast(payload.len)); + try appendU32(&bytes, ctx.gpa, std.math.cast(u32, payload.len) orelse return error.OutOfMemory); try bytes.appendSlice(ctx.gpa, payload); } if (result.failure_detail) |message| { try bytes.append(ctx.gpa, 1); try bytes.append(ctx.gpa, @backingInt(result.failure_detail_visibility)); - try appendU32(&bytes, ctx.gpa, @intCast(message.len)); + try appendU32(&bytes, ctx.gpa, std.math.cast(u32, message.len) orelse return error.OutOfMemory); try bytes.appendSlice(ctx.gpa, message); } else { try bytes.append(ctx.gpa, 0); @@ -12975,7 +12998,10 @@ fn storeCliTestResultsInCache( const entries_dir = try manager.config.getTestCacheDir(ctx.gpa); defer ctx.gpa.free(entries_dir); - manager.storeRawBytes(cliTestCacheKey(artifact.key, specialization_strategy, opt), bytes.items, entries_dir, artifact.moduleEnvConst().module_name); + var checksum = base.Sha256.init(.{}); + checksum.update(bytes.items); + try bytes.appendSlice(ctx.gpa, &checksum.finalResult()); + manager.storeRawBytes(outcome_key, bytes.items, entries_dir, artifact.moduleEnvConst().module_name); } fn loadCliTestTranscriptEvents( @@ -13012,7 +13038,7 @@ fn loadCliTestTranscriptEvents( }; const payload_len = readU32(data, offset) orelse return null; const payload_len_usize: usize = @intCast(payload_len); - if (offset.* + payload_len_usize > data.len) return null; + if (payload_len_usize > data.len - offset.*) return null; const payload = try allocator.dupe(u8, data[offset.*..][0..payload_len_usize]); offset.* += payload_len_usize; errdefer allocator.free(payload); @@ -13028,7 +13054,7 @@ fn loadCliTestTranscriptEvents( return owned; } -fn loadCachedCliTestResults( +fn loadCachedCliTestOutcome( ctx: *CliCtx, cache_manager: ?*CacheManager, artifact: *const check.CheckedArtifact.CheckedModuleArtifact, @@ -13036,22 +13062,34 @@ fn loadCachedCliTestResults( opt: cli_args.OptLevel, module: BuildEnv.CompiledModuleInfo, source_modules: *const CliTestSourceModuleMap, - test_roots: []const check.CheckedArtifact.RootRequest, -) (Allocator.Error || error{NoHomeDirectory})!?CliCachedModuleTestResults { + root: check.CheckedArtifact.RootRequest, +) (Allocator.Error || error{NoHomeDirectory})!?[]CliTestResultItem { const manager = cache_manager orelse return null; const entries_dir = try manager.config.getTestCacheDir(ctx.gpa); defer ctx.gpa.free(entries_dir); - const data = manager.loadRawBytes(cliTestCacheKey(artifact.key, specialization_strategy, opt), entries_dir) orelse return null; - defer ctx.gpa.free(data); + const outcome_key = cliTestOutcomeKey(artifact, root, specialization_strategy, opt); + const raw_data = manager.loadRawBytes(outcome_key, entries_dir) orelse return null; + defer ctx.gpa.free(raw_data); + if (raw_data.len < 32) return null; + const data = raw_data[0 .. raw_data.len - 32]; + var checksum = base.Sha256.init(.{}); + checksum.update(data); + if (!std.mem.eql(u8, &checksum.finalResult(), raw_data[raw_data.len - 32 ..])) return null; var offset: usize = 0; if (data.len < cli_test_cache_magic.len) return null; if (!std.mem.eql(u8, data[0..cli_test_cache_magic.len], cli_test_cache_magic)) return null; offset += cli_test_cache_magic.len; + if (data.len - offset < 32) return null; + if (!std.mem.eql(u8, data[offset..][0..32], &outcome_key)) return null; + offset += 32; + const root_region = testRootRegion(module.semantic.env, root.source); + if ((readU32(data, &offset) orelse return null) != root_region.start.offset) return null; + if ((readU32(data, &offset) orelse return null) != root_region.end.offset) return null; const count = readU32(data, &offset) orelse return null; - if (count < test_roots.len) return null; + if (count < 1 or count > data.len - offset) return null; var results = std.ArrayList(CliTestResultItem).empty; var results_owned_by_module = false; @@ -13072,10 +13110,11 @@ fn loadCachedCliTestResults( const inline_passed = readU64(data, &offset) orelse return null; const inline_failed = readU64(data, &offset) orelse return null; if (inline_expect != (inline_passed +| inline_failed != 0)) return null; + if (inline_expect and root_index == 0) return null; const source_name_len: usize = @intCast(readU32(data, &offset) orelse return null); if (inline_expect and source_name_len == 0) return null; if (!inline_expect and source_name_len != 0) return null; - if (offset + source_name_len > data.len) return null; + if (source_name_len > data.len - offset) return null; const source_name = data[offset..][0..source_name_len]; offset += source_name_len; var source_env: ?*const ModuleEnv = null; @@ -13087,13 +13126,24 @@ fn loadCachedCliTestResults( } if (source_env == null) return null; } - const order = readU32(data, &offset) orelse return null; + const order = root.order; const region_start = readU32(data, &offset) orelse return null; const region_end = readU32(data, &offset) orelse return null; if (region_start > region_end) return null; + if (source_env) |env| { + if (region_end > env.getSourceAll().len or data.len - offset < 32) return null; + if (!std.mem.eql(u8, data[offset..][0..32], &cliTestSourceDigest(env, region_end))) return null; + offset += 32; + } + const extended_provenance = readU8(data, &offset) orelse return null; + if (extended_provenance > 1) return null; + if (extended_provenance == 1) { + if (data.len - offset < 32) return null; + if (!std.mem.eql(u8, data[offset..][0..32], &artifact.key.bytes)) return null; + offset += 32; + } if (!inline_expect) { - if (root_index >= test_roots.len) return null; - if (order != test_roots[root_index].order) return null; + if (root_index >= 1) return null; root_index += 1; } @@ -13101,7 +13151,7 @@ fn loadCachedCliTestResults( const result: CliTestResult = switch (result_tag) { 0 => .passed, 1 => .failed, - 2 => .compiler_error, + 2 => return null, else => return null, }; if (inline_expect and (result == .failed) != (inline_failed != 0)) return null; @@ -13112,9 +13162,10 @@ fn loadCachedCliTestResults( } const has_message = readU8(data, &offset) orelse return null; + if (has_message > 1) return null; const region = base.Region.from_raw_offsets(region_start, region_end); - if (!inline_expect and !region.eq(testRootRegion(module.semantic.env, test_roots[root_index - 1].source))) return null; + if (region.end.offset > (source_env orelse module.semantic.env).getSourceAll().len) return null; var visibility: CliTestFailureDetailVisibility = .always; const message = if (has_message == 0) null else blk: { @@ -13127,16 +13178,18 @@ fn loadCachedCliTestResults( offset += 1; const message_len = readU32(data, &offset) orelse return null; const message_len_usize: usize = @intCast(message_len); - if (offset + message_len_usize > data.len) return null; + if (message_len_usize > data.len - offset) return null; const message = try ctx.gpa.dupe(u8, data[offset..][0..message_len_usize]); offset += message_len_usize; break :blk message; }; + errdefer if (message) |owned| ctx.gpa.free(owned); try results.append(ctx.gpa, .{ .result = result, .order = order, .region = region, + .owner_order = if (inline_expect) order else null, .inline_expect = inline_expect, .inline_passed = inline_passed, .inline_failed = inline_failed, @@ -13148,20 +13201,15 @@ fn loadCachedCliTestResults( }); transcript_owned_by_result = true; } - if (root_index != test_roots.len) return null; + if (root_index != 1) return null; if (offset != data.len) return null; - var summary = summarizeTestResults(results.items); - summary.cached_modules = 1; const owned_results = try results.toOwnedSlice(ctx.gpa); errdefer { deinitCliTestResultItems(ctx.gpa, owned_results); } results_owned_by_module = true; - return .{ - .results = owned_results, - .summary = summary, - }; + return owned_results; } fn collectTestRootRequests( @@ -13190,9 +13238,6 @@ fn buildCliTestPlan( errdefer { for (planned_modules.items) |*module| { ctx.gpa.free(module.test_roots); - if (module.cached_results) |results| { - deinitCliTestResultItems(ctx.gpa, results); - } } planned_modules.deinit(ctx.gpa); entries.deinit(ctx.gpa); @@ -13271,15 +13316,20 @@ fn buildCliTestPlan( errdefer { for (owned_modules) |*module| { ctx.gpa.free(module.test_roots); - if (module.cached_results) |results| { - deinitCliTestResultItems(ctx.gpa, results); - } } ctx.gpa.free(owned_modules); } const owned_entries = try entries.toOwnedSlice(ctx.gpa); errdefer ctx.gpa.free(owned_entries); const symbol_names = try nameCliTestPlanSymbols(ctx.gpa, owned_entries); + errdefer ctx.gpa.free(symbol_names); + var initialized: usize = 0; + errdefer for (owned_modules[0..initialized]) |module| ctx.gpa.free(module.cached_roots); + for (owned_modules) |*module| { + module.cached_roots = try ctx.gpa.alloc(?[]CliTestResultItem, module.test_roots.len); + @memset(module.cached_roots, null); + initialized += 1; + } return .{ .modules_with_tests = modules_with_tests, @@ -13970,10 +14020,10 @@ fn collectCliTestRootRuns( }); } - if (runs.items.len != planned.test_roots.len) { + if (runs.items.len != planned.uncachedCount()) { base.invariant( "CLI test invariant violated: lowered {d} test roots for {d} checked test roots", - .{ runs.items.len, planned.test_roots.len }, + .{ runs.items.len, planned.uncachedCount() }, ); } @@ -14244,115 +14294,130 @@ fn runInterpreterTestRoots( }); for (root_runs) |run| { - host_env.resetObservation(); - const eval_result = interpreter.eval(.{ - .proc_id = run.root_proc, - .arg_layouts = run.arg_layouts, - .ret_layout = run.ret_layout, - }) catch |err| { - var transcript: []const CliTestTranscriptEvent = try host_env.takeTranscript(); - errdefer deinitCliTestTranscriptEvents(ctx.gpa, transcript); - // The expect reached code checking rejected; that problem is - // already reported, so the expect counts as a compiler error. - const reached_checked_error = switch (err) { - error.Crash => eval.CompileTimeFinalization.failedAtCheckedError(if (interpreter.getFailedCrashStmt()) |stmt| lowered.lir_result.store.failureMeaning(stmt) else null), - error.DivisionByZero, - error.ExpectErr, - error.InvalidHostedFunctionSignature, - error.OutOfMemory, - error.RuntimeError, - error.UnsupportedHostedFunction, - => false, - }; - if (reached_checked_error) { - summary.compiler_errors += 1; + root_eval: { + host_env.resetObservation(); + const eval_result = interpreter.eval(.{ + .proc_id = run.root_proc, + .arg_layouts = run.arg_layouts, + .ret_layout = run.ret_layout, + }) catch |err| { + var transcript: []const CliTestTranscriptEvent = try host_env.takeTranscript(); + errdefer deinitCliTestTranscriptEvents(ctx.gpa, transcript); + // The expect reached code checking rejected; that problem is + // already reported, so the expect counts as a compiler error. + const reached_checked_error = switch (err) { + error.Crash => eval.CompileTimeFinalization.failedAtCheckedError(if (interpreter.getFailedCrashStmt()) |stmt| lowered.lir_result.store.failureMeaning(stmt) else null), + error.DivisionByZero, + error.ExpectErr, + error.InvalidHostedFunctionSignature, + error.OutOfMemory, + error.RuntimeError, + error.UnsupportedHostedFunction, + => false, + }; + if (reached_checked_error) { + summary.compiler_errors += 1; + try results.append(ctx.gpa, .{ + .result = .compiler_error, + .order = run.root.order, + .region = run.region, + .transcript = transcript, + .failure_detail = null, + }); + break :root_eval; + } + summary.failed += 1; + // When a `?` operator failed the expect, point the report's + // source snippet at the `?` itself. + const failure_region = switch (err) { + error.ExpectErr => interpreter.getExpectErrRegion() orelse run.region, + error.Crash, + error.DivisionByZero, + error.InvalidHostedFunctionSignature, + error.OutOfMemory, + error.RuntimeError, + error.UnsupportedHostedFunction, + => run.region, + }; + const message = try interpreterTestFailureMessage(ctx.gpa, &interpreter, err); + var message_owned = true; + errdefer if (message_owned) ctx.gpa.free(message); + const failure_detail: ?[]const u8 = switch (err) { + error.Crash => blk: { + // The interpreter reports a Roc crash as `error.Crash` + // rather than through the host's `roc_crashed`, which + // never returns. + transcript = try appendCliTestTranscriptEvent(ctx.gpa, transcript, .stderr, .crashed, interpreter.getCrashMessage()); + transcript = try appendCliTestTranscriptEvent(ctx.gpa, transcript, .stderr, .crash_diagnostic, message); + ctx.gpa.free(message); + message_owned = false; + break :blk null; + }, + error.DivisionByZero, + error.ExpectErr, + error.InvalidHostedFunctionSignature, + error.OutOfMemory, + error.RuntimeError, + error.UnsupportedHostedFunction, + => blk: { + message_owned = false; + break :blk message; + }, + }; try results.append(ctx.gpa, .{ - .result = .compiler_error, + .result = .failed, .order = run.root.order, - .region = run.region, + .region = failure_region, .transcript = transcript, - .failure_detail = null, + .failure_detail = failure_detail, + .failure_detail_visibility = .always, }); - continue; - } - summary.failed += 1; - // When a `?` operator failed the expect, point the report's - // source snippet at the `?` itself. - const failure_region = switch (err) { - error.ExpectErr => interpreter.getExpectErrRegion() orelse run.region, - error.Crash, - error.DivisionByZero, - error.InvalidHostedFunctionSignature, - error.OutOfMemory, - error.RuntimeError, - error.UnsupportedHostedFunction, - => run.region, + break :root_eval; }; - const message = try interpreterTestFailureMessage(ctx.gpa, &interpreter, err); - var message_owned = true; - errdefer if (message_owned) ctx.gpa.free(message); - const failure_detail: ?[]const u8 = switch (err) { - error.Crash => blk: { - // The interpreter reports a Roc crash as `error.Crash` - // rather than through the host's `roc_crashed`, which - // never returns. - transcript = try appendCliTestTranscriptEvent(ctx.gpa, transcript, .stderr, .crashed, interpreter.getCrashMessage()); - transcript = try appendCliTestTranscriptEvent(ctx.gpa, transcript, .stderr, .crash_diagnostic, message); - ctx.gpa.free(message); - message_owned = false; - break :blk null; - }, - error.DivisionByZero, - error.ExpectErr, - error.InvalidHostedFunctionSignature, - error.OutOfMemory, - error.RuntimeError, - error.UnsupportedHostedFunction, - => blk: { - message_owned = false; - break :blk message; + const transcript = try host_env.takeTranscript(); + errdefer deinitCliTestTranscriptEvents(ctx.gpa, transcript); + + const passed = switch (eval_result) { + .value => |value| blk: { + const ok = value.read(u8) != 0; + interpreter.dropValue(value, run.ret_layout); + break :blk ok; }, }; - try results.append(ctx.gpa, .{ - .result = .failed, - .order = run.root.order, - .region = failure_region, - .transcript = transcript, - .failure_detail = failure_detail, - .failure_detail_visibility = .always, - }); - continue; - }; - const transcript = try host_env.takeTranscript(); - errdefer deinitCliTestTranscriptEvents(ctx.gpa, transcript); - - const passed = switch (eval_result) { - .value => |value| blk: { - const ok = value.read(u8) != 0; - interpreter.dropValue(value, run.ret_layout); - break :blk ok; - }, - }; - if (passed) { - summary.passed += 1; - try results.append(ctx.gpa, .{ .result = .passed, .order = run.root.order, .region = run.region, .transcript = transcript, .failure_detail = null }); - } else { - summary.failed += 1; - const detail = try buildExpectFailureDetail(ctx.gpa, run.env, run.root); - try appendFailedCliTestResult( - ctx, - results, - run, - .failed, - transcript, - detail.message, - detail.visibility, - ); + if (passed) { + summary.passed += 1; + try results.append(ctx.gpa, .{ .result = .passed, .order = run.root.order, .region = run.region, .transcript = transcript, .failure_detail = null }); + } else { + summary.failed += 1; + const detail = try buildExpectFailureDetail(ctx.gpa, run.env, run.root); + try appendFailedCliTestResult( + ctx, + results, + run, + .failed, + transcript, + detail.message, + detail.visibility, + ); + } } + const start = results.items.len; + try appendInlineExpectResults(ctx, &lowered.lir_result.store, lowered.lir_result.expect_sites.items, expect_counts, results, summary, source_modules); + for (results.items[start..]) |*item| item.owner_order = run.root.order; + @memset(expect_counts, .{}); } +} - try appendInlineExpectResults(ctx, &lowered.lir_result.store, lowered.lir_result.expect_sites.items, expect_counts, results, summary, source_modules); +fn appendRootInlineExpectResults(ctx: *CliCtx, lowered: *const lir.CheckedPipeline.LoweredProgram, result: eval.Inspected.BoolRootEvalResult, owner_order: u32, results: *std.ArrayList(CliTestResultItem), summary: *CliTestRunSummary, source_modules: *const CliTestSourceModuleMap) Allocator.Error!void { + if (result.expect_observations.len == 0) return; + const counts = try ctx.gpa.alloc(eval.Inspected.ExpectCounts, lowered.lir_result.expect_sites.items.len); + defer ctx.gpa.free(counts); + @memset(counts, .{}); + for (result.expect_observations) |observation| counts[observation.site] = observation.counts; + const start = results.items.len; + try appendInlineExpectResults(ctx, &lowered.lir_result.store, lowered.lir_result.expect_sites.items, counts, results, summary, source_modules); + for (results.items[start..]) |*item| item.owner_order = owner_order; } fn appendInlineExpectResults( @@ -14593,8 +14658,8 @@ fn runCompiledTestRoots( for (root_runs, eval_batch.results) |run, eval_result| { try appendEvalResultForRun(ctx, run, eval_result, results, summary); + try appendRootInlineExpectResults(ctx, lowered, eval_result, run.root.order, results, summary, source_modules); } - try appendInlineExpectResults(ctx, &lowered.lir_result.store, lowered.lir_result.expect_sites.items, eval_batch.expect_counts, results, summary, source_modules); } fn lowerPlannedTestModule( @@ -14614,7 +14679,7 @@ fn lowerPlannedTestModule( fn runCheckedArtifactTests( ctx: *CliCtx, shared: *lir.CheckedPipeline.LoweredProgram, - planned: *const CliTestPlanModule, + planned: *CliTestPlanModule, plan_entries: []const CliTestPlanEntry, opt: cli_args.OptLevel, specialization_strategy: base.SpecializationStrategy, @@ -14625,7 +14690,6 @@ fn runCheckedArtifactTests( source_modules: *const CliTestSourceModuleMap, ) (Allocator.Error || lir.CheckedPipeline.HostedBindingError || error{NoHomeDirectory})!CliTestRunSummary { const module = planned.module; - const artifact = planned.artifact; var lowered_module = try lowerPlannedTestModule(ctx, shared, 0, planned, plan_entries); defer lowered_module.deinit(ctx.gpa); @@ -14644,12 +14708,18 @@ fn runCheckedArtifactTests( } summary.modules_with_tests = 1; - try storeCliTestResultsInCache(ctx, cache_manager, artifact, specialization_strategy, opt, results.items); + try storeCliRootOutcomes(ctx, cache_manager, planned, specialization_strategy, opt, results.items); + const cached = planned.uncachedCount() == 0; + const merged = try mergeCliRootOutcomes(ctx.gpa, planned, results.items); + results.clearRetainingCapacity(); + errdefer deinitCliTestResultItems(ctx.gpa, merged); + summary = summarizeTestResults(merged); + summary.cached_modules = @intFromBool(cached); try module_results.append(ctx.gpa, .{ .env = module.semantic.env, .path = module.path, - .results = try ctx.gpa.dupe(CliTestResultItem, results.items), + .results = merged, .cached = false, }); results.deinit(ctx.gpa); @@ -14780,7 +14850,6 @@ fn runCompiledLoweredTestModulesOnce( if (live_output) |live| try live.checkError(); var eval_index: usize = 0; - var expect_site_base: usize = 0; for (lowered_modules) |*lowered_module| { var results = std.ArrayList(CliTestResultItem).empty; errdefer { @@ -14796,24 +14865,71 @@ fn runCompiledLoweredTestModulesOnce( &results, &summaries[lowered_module.planned_index], ); + try appendRootInlineExpectResults(ctx, lowered_module.lowered, eval_batch.results[eval_index], run.root.order, &results, &summaries[lowered_module.planned_index], source_modules); eval_index += 1; } - const sites = lowered_module.lowered.lir_result.expect_sites.items; - try appendInlineExpectResults( - ctx, - &lowered_module.lowered.lir_result.store, - sites, - eval_batch.expect_counts[expect_site_base..][0..sites.len], - &results, - &summaries[lowered_module.planned_index], - source_modules, - ); - expect_site_base += sites.len; summaries[lowered_module.planned_index].modules_with_tests = 1; fresh_results[lowered_module.planned_index] = try results.toOwnedSlice(ctx.gpa); } } +fn mergeCliRootOutcomes(allocator: Allocator, planned: *CliTestPlanModule, fresh: []const CliTestResultItem) Allocator.Error![]CliTestResultItem { + var merged = std.ArrayList(CliTestResultItem).empty; + errdefer merged.deinit(allocator); + var len: usize = fresh.len; + for (planned.cached_roots) |slot| if (slot) |items| { + len += items.len; + }; + try merged.ensureTotalCapacity(allocator, len); + var fresh_index: usize = 0; + for (planned.test_roots, 0..) |root, i| { + const item = if (planned.cached_roots[i]) |items| items[0] else item: { + while (fresh_index < fresh.len and fresh[fresh_index].inline_expect) : (fresh_index += 1) {} + if (fresh_index == fresh.len) base.invariant("fresh root outcome is missing", .{}); + const result = fresh[fresh_index]; + fresh_index += 1; + break :item result; + }; + if (item.inline_expect or item.order != root.order) base.invariant("outcome belongs to a different checked root", .{}); + merged.appendAssumeCapacity(item); + } + for (planned.cached_roots) |slot| if (slot) |items| { + for (items) |item| if (item.inline_expect) { + merged.appendAssumeCapacity(item); + }; + }; + for (fresh) |item| if (item.inline_expect) { + merged.appendAssumeCapacity(item); + }; + const inline_start = planned.test_roots.len; + std.mem.sort(CliTestResultItem, merged.items[inline_start..], {}, struct { + fn lessThan(_: void, a: CliTestResultItem, b: CliTestResultItem) bool { + const names = std.mem.order(u8, a.source_env.?.qualifiedModuleName(), b.source_env.?.qualifiedModuleName()); + if (names != .eq) return names == .lt; + if (a.region.start.offset != b.region.start.offset) return a.region.start.offset < b.region.start.offset; + return a.region.end.offset < b.region.end.offset; + } + }.lessThan); + const owned = try merged.toOwnedSlice(allocator); + for (planned.cached_roots) |*slot| if (slot.*) |items| { + allocator.free(items); + slot.* = null; + }; + return owned; +} + +fn storeCliRootOutcomes(ctx: *CliCtx, manager: ?*CacheManager, planned: *const CliTestPlanModule, strategy: base.SpecializationStrategy, opt: cli_args.OptLevel, fresh: []const CliTestResultItem) (Allocator.Error || error{NoHomeDirectory})!void { + var cursor: usize = 0; + for (planned.test_roots, 0..) |root, index| { + if (planned.cached_roots[index] != null) continue; + const start = cursor; + while (cursor < fresh.len and (fresh[cursor].owner_order orelse fresh[cursor].order) == root.order) : (cursor += 1) {} + if (cursor == start or fresh[start].inline_expect) base.invariant("completed root has no owned outcome", .{}); + try storeCliTestOutcomeInCache(ctx, manager, planned.artifact, strategy, opt, root, fresh[start..cursor]); + } + if (cursor != fresh.len) base.invariant("fresh outcomes were not partitioned by checked root order", .{}); +} + fn appendPlannedModuleResult( ctx: *CliCtx, module_results: *std.ArrayList(CliModuleTestResult), @@ -14930,24 +15046,15 @@ fn runCompiledTestPlan( } for (test_plan.modules, 0..) |*planned, planned_index| { - if (planned.cached_results != null) { - summaries[planned_index] = planned.cached_summary; - if (live_output) |live| { - var root_index: u32 = 0; - for (planned.cached_results.?) |result| { - if (result.inline_expect) continue; - live.publishCopiedEntry( - @intCast(planned.first_entry_index + root_index), - planned.module.semantic.env, - planned.module.path, - result, - ); - root_index += 1; - } - try live.checkError(); - } - continue; + if (live_output) |live| { + for (planned.cached_roots, 0..) |slot, index| if (slot) |items| { + for (items) |result| if (!result.inline_expect) { + live.publishCopiedEntry(planned.first_entry_index + index, planned.module.semantic.env, planned.module.path, result); + }; + }; + try live.checkError(); } + if (planned.uncachedCount() == 0) continue; try lowered_modules.append( ctx.gpa, @@ -14959,19 +15066,18 @@ fn runCompiledTestPlan( for (lowered_modules.items) |*lowered_module| { const planned = &test_plan.modules[lowered_module.planned_index]; - try storeCliTestResultsInCache(ctx, cache_manager, planned.artifact, specialization_strategy, opt, fresh_results[lowered_module.planned_index].?); + try storeCliRootOutcomes(ctx, cache_manager, planned, specialization_strategy, opt, fresh_results[lowered_module.planned_index].?); } for (test_plan.modules, 0..) |*planned, planned_index| { - const cached = planned.cached_results != null; - const results = if (cached) planned.releaseCachedResults() else fresh: { - const owned = fresh_results[planned_index].?; - fresh_results[planned_index] = null; - break :fresh owned; - }; + const cached = planned.uncachedCount() == 0; + const fresh = fresh_results[planned_index] orelse &.{}; + const results = try mergeCliRootOutcomes(ctx.gpa, planned, fresh); + if (fresh_results[planned_index]) |items| ctx.gpa.free(items); + fresh_results[planned_index] = null; try appendPlannedModuleResult(ctx, module_results, planned, results, cached); - - const summary = summaries[planned_index]; + var summary = summarizeTestResults(results); + summary.cached_modules = @intFromBool(cached); total.passed += summary.passed; total.failed += summary.failed; total.compiler_errors += summary.compiler_errors; @@ -16172,6 +16278,13 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr defer test_plan.deinit(ctx.gpa); const specialization_strategy = currentRuntimeSpecializationStrategy(args.specialization_strategy); + for (test_plan.modules) |*planned| { + for (planned.test_roots, 0..) |_, root_index| { + if (try loadCachedCliTestOutcome(ctx, build_env.cache_manager, planned.artifact, specialization_strategy, args.opt, planned.module, &source_modules, planned.test_roots[root_index])) |cached| { + planned.cached_roots[root_index] = cached; + } + } + } var runtime_requests = std.ArrayList(check.CheckedArtifact.RootRequest).empty; defer runtime_requests.deinit(ctx.gpa); var runtime_sources = std.ArrayList(check.CheckedArtifact.ModuleId).empty; @@ -16179,9 +16292,10 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr var runtime_metadata = std.ArrayList(postcheck.Common.RootTestPlanMetadata).empty; defer runtime_metadata.deinit(ctx.gpa); for (test_plan.modules) |planned| { - try runtime_requests.appendSlice(ctx.gpa, planned.test_roots); - try runtime_sources.appendNTimes(ctx.gpa, planned.artifact.key.digest(), planned.test_roots.len); for (planned.test_roots, 0..) |root, root_index| { + if (planned.cached_roots[root_index] != null) continue; + try runtime_requests.append(ctx.gpa, root); + try runtime_sources.append(ctx.gpa, planned.artifact.key.digest()); const entry = test_plan.entries[planned.first_entry_index + root_index]; try runtime_metadata.append(ctx.gpa, .{ .request_index = @intCast(runtime_metadata.items.len), @@ -16223,24 +16337,12 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr }; } try finishFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), &reporter, build_env.getTimingInfo()); - reporter.begin("Test Planning"); - for (test_plan.modules) |*planned| { - if (try loadCachedCliTestResults( - ctx, - build_env.cache_manager, - planned.artifact, - specialization_strategy, - args.opt, - planned.module, - &source_modules, - planned.test_roots, - )) |cached| { - planned.cached_results = cached.results; - planned.cached_summary = cached.summary; - } - } - reporter.end(); reporter.recordCounters("Test result cache", &cliTestCacheCounters(cliTestCacheStats(&test_plan))); + reporter.recordCounters("Test outcome admission", &.{ + .{ .name = "Checked dependency records", .count = test_plan.entries.len }, + .{ .name = "Outcome lookups", .count = if (build_env.cache_manager != null) test_plan.entries.len else 0 }, + .{ .name = "Roots submitted for specialization", .count = runtime_requests.items.len }, + }); var spec_timing = lir.CheckedPipeline.Timing.init(ctx.io.std_io); if (args.timings) spec_timing.enableDetailedMonotypeBody(); @@ -16351,11 +16453,7 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr ), .interpreter => { for (test_plan.modules) |*planned| { - const summary = if (planned.cached_results != null) cached: { - const results = planned.releaseCachedResults(); - try appendPlannedModuleResult(ctx, &module_results, planned, results, true); - break :cached planned.cached_summary; - } else try runCheckedArtifactTests( + const summary = try runCheckedArtifactTests( ctx, program, planned, @@ -16376,6 +16474,13 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr } }, }; + if (shared_test_program == null) { + for (test_plan.modules) |*planned| { + const results = try mergeCliRootOutcomes(ctx.gpa, planned, &.{}); + try appendPlannedModuleResult(ctx, &module_results, planned, results, true); + total.cached_modules += 1; + } + } try coalesceInlineExpectResults(ctx.gpa, module_results.items, &total); total.modules_with_tests = test_plan.modules_with_tests; total.diagnostic_errors = diag.errors; @@ -17881,6 +17986,10 @@ fn recordDevTestExecution(reporter: *progress.Reporter, timing: *const eval.test .{}, &devTestExecutionBreakdown(snapshot), ); + reporter.recordCounters("Test backend work", &.{ + .{ .name = "Root wrappers generated", .count = snapshot.root_codegen_count }, + .{ .name = "Roots executed", .count = snapshot.root_execution_count }, + }); } fn demandCounters(demand: lir.CheckedPipeline.DemandMetrics) [14]progress.Counter { diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 46de63e39eb..e99588d1172 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -16711,8 +16711,8 @@ fn customRocTestCountsCheckingErrorsWithCachedResults( return customInfraFailure(allocator, timer, "failed to allocate test source path: {}", .{err}); defer allocator.free(path); - // The first run caches all three roots' results, including the rejected - // root's compiler error, which the second run replays alongside the passes. + // Only completed source-language outcomes are durable. The rejected root + // is checked and reported again alongside the two cached passes. for (0..2) |run| { if (runRocAndCheck(io, allocator, env, timer, timeout_ms, .{ .args = &.{ "test", "--no-color", "--timings" }, @@ -16724,7 +16724,7 @@ fn customRocTestCountsCheckingErrorsWithCachedResults( .{ .stream = .stderr, .text = "2 passed" }, .{ .stream = .stderr, .text = "0 failed" }, .{ .stream = .stderr, .text = "1 compiler errors" }, - .{ .stream = .stderr, .text = if (run == 0) "Roots cached 0" else "Roots cached 3", .wrapped = true }, + .{ .stream = .stderr, .text = if (run == 0) "Roots cached 0" else "Roots cached 2", .wrapped = true }, }, .occurrences = &.{.{ .stream = .stderr, .text = "type mismatch", .count = 1 }}, .not_contains = &.{ diff --git a/src/eval/inspected.zig b/src/eval/inspected.zig index a22882eb7dd..56c5dbde5d9 100644 --- a/src/eval/inspected.zig +++ b/src/eval/inspected.zig @@ -417,6 +417,8 @@ pub const DevBoolRootTimingSnapshot = struct { entrypoint_codegen_ns: u64 = 0, executable_memory_ns: u64 = 0, root_execution_ns: u64 = 0, + root_codegen_count: u64 = 0, + root_execution_count: u64 = 0, }; /// Mutable timing collector for one dev-backend boolean-root evaluation batch. @@ -483,6 +485,13 @@ pub const BoolRootEvalOutcome = union(enum) { pub const BoolRootEvalResult = struct { outcome: BoolRootEvalOutcome, events: []BoolRootEvent, + /// Sparse source-site observations owned by this invocation. + expect_observations: []ExpectObservation = &.{}, +}; + +pub const ExpectObservation = struct { + site: u32, + counts: ExpectCounts, }; /// Aggregate executions of one dense LIR expect site. @@ -565,6 +574,7 @@ fn deinitBoolRootEvalOutcome(allocator: Allocator, outcome: BoolRootEvalOutcome) fn deinitBoolRootEvalResult(allocator: Allocator, result: BoolRootEvalResult) void { deinitBoolRootEvalOutcome(allocator, result.outcome); deinitBoolRootEvents(allocator, result.events); + allocator.free(result.expect_observations); } /// Free all crash messages, transcript events, and the results slice. @@ -2385,7 +2395,10 @@ pub fn devEvalSharedBoolRootModules(allocator: Allocator, modules: []const BoolR ); entry_offsets[i] = entrypoint.offset; } - if (timing) |timings| timings.finish(entrypoint_codegen_started_ns, .entrypoint_codegen); + if (timing) |timings| { + timings.finish(entrypoint_codegen_started_ns, .entrypoint_codegen); + timings.snapshot_value.root_codegen_count += roots.len; + } const executable_memory_started_ns = if (timing) |timings| timings.start() else 0; try codegen.finishImage(); @@ -2441,7 +2454,12 @@ pub fn devEvalSharedBoolRootModules(allocator: Allocator, modules: []const BoolR const root_execution_started_ns = if (timing) |timings| timings.start() else 0; const batch = runBoolRootCalls(allocator, calls, max_workers, null, null); - if (timing) |timings| timings.finish(root_execution_started_ns, .root_execution); + if (timing) |timings| { + timings.finish(root_execution_started_ns, .root_execution); + if (batch) |_| { + timings.snapshot_value.root_execution_count += calls.len; + } else |_| {} + } return batch; } } @@ -2632,6 +2650,24 @@ fn boolRootWorker(args: *BoolRootWorkerArgs) void { state.errors[index] = err; return; }; + var observations = std.ArrayList(ExpectObservation).empty; + defer observations.deinit(state.allocator); + for (0..call.expect_site_count) |site| { + const passed = &state.worker_expect_passed[expect_start + site]; + const failed = &state.worker_expect_failed[expect_start + site]; + if (passed.* != 0 or failed.* != 0) { + observations.append(state.allocator, .{ .site = @intCast(site), .counts = .{ .passed = passed.*, .failed = failed.* } }) catch |err| { + state.errors[index] = err; + return; + }; + passed.* = 0; + failed.* = 0; + } + } + state.results[index].?.expect_observations = observations.toOwnedSlice(state.allocator) catch |err| { + state.errors[index] = err; + return; + }; if (state.completion_callback) |callback| { callback.complete(callback.context, index, &state.results[index].?); } @@ -2743,11 +2779,11 @@ fn runBoolRootCalls( const expect_counts = try allocator.alloc(ExpectCounts, total_expect_sites); @memset(expect_counts, .{}); - for (0..worker_count) |worker_index| { - const start = worker_index * worker_expect_stride; - for (expect_counts, 0..) |*counts, site| { - counts.passed +|= worker_expect_passed[start + site]; - counts.failed +|= worker_expect_failed[start + site]; + for (calls, results) |call, result| { + for (result.expect_observations) |observation| { + const counts = &expect_counts[call.expect_site_base + observation.site]; + counts.passed +|= observation.counts.passed; + counts.failed +|= observation.counts.failed; } } From 29acd32168649f6dbc329737d9c7a9e8c0a5ad27 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 11:46:12 -0400 Subject: [PATCH 17/97] Reuse Monotype completion and inhabitation analyses --- design.md | 35 ++ src/cli/main.zig | 14 +- src/cli/progress.zig | 2 +- src/postcheck/monotype/lower.zig | 560 ++++++++++++++++++++++++------- src/postcheck/monotype/solve.zig | 26 +- 5 files changed, 509 insertions(+), 128 deletions(-) diff --git a/design.md b/design.md index 59231fa5bd6..2b3a721c474 100644 --- a/design.md +++ b/design.md @@ -15552,6 +15552,41 @@ row or primitive never gives an uninhabited source a value. This includes the If Monotype lowering cannot construct a closed monomorphic type from checked data, that is a compiler bug. +### Monotype completion and inhabitation analysis reuse + +Each body context owns request-completion answers keyed by the exact ordered +pair of resolved graph nodes, and uninhabitedness answers keyed by resolved +node in separate inspectable-only and runtime-layout maps. These identities +belong to that context's one instantiation graph; answers never cross a graph +reset, another body, or a worker boundary. Memo capacity is proportional to the +analysis states queried in the current graph epoch, not one graph per query. +Completion path, operation, and frame storage and inhabitation evaluation +storage are reused between queries and released with the context. + +Answers are valid only at the graph's structure epoch. Every class union, +content replacement (including backing authority/use changes), and lazy leaf +expansion advances that epoch. Tag-head reordering also advances it: the +completion relation compares entries in stored order, even though sorting +preserves type meaning. Marking an already sorted head, allocating an unconnected +node, and path compression do not change existing answers. Evidence matters here +only through the explicit node content/backing it produces; analysis does not +read external evidence tables. Immutable finished-type inhabitation answers +remain owned by the graph's type analysis and survive graph mutations. + +Every completion child entry consults the same memo as top-level queries. +Lazy expansion can change the epoch during a query: an answer is recorded only +if its entire traversal used one epoch, and every lookup first retires answers +from older epochs. Invalidated queries may revisit structure; diagnostics count +those invalidations separately from graph visits and memo hits. + +Both walkers preserve their existing active-path cycle rule. A completion +cycle assumes `unchanged`; an inhabitation cycle supplies no proof of emptiness. +Each frame records the cycle counter at entry. Only a frame that finishes +without a new cycle assumption may publish a reusable answer. Cycle-dependent +frames are deliberately revisited, with those assumptions counted explicitly; +no provisional recursive answer becomes a durable fact. This changes analysis +reuse only, never typing, relation production, row policy, or reachability. + ### Row, Nominal, Alias, And Opaque Authority Monotype lowering is the sole owner of turning checked type data into closed diff --git a/src/cli/main.zig b/src/cli/main.zig index acc2ceec9b0..2e52fe143be 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -17937,7 +17937,7 @@ fn monotypeSpecializationCounters(diagnostics: postcheck.Monotype.Lower.Diagnost }; } -fn monotypeGraphCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [28]progress.Counter { +fn monotypeGraphCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [40]progress.Counter { const graph = diagnostics.graph; return .{ .{ .name = "Graphs created", .count = diagnostics.body.graphs_created }, @@ -17968,6 +17968,18 @@ fn monotypeGraphCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [28] .{ .name = "Structural backing visited slots", .count = graph.structural_backing_scan_slots }, .{ .name = "Generated-private guard returns", .count = graph.generated_private_guard_returns }, .{ .name = "Generated-iterator index lookups", .count = graph.generated_iterator_lookups }, + .{ .name = "Completion queries", .count = graph.completion_queries }, + .{ .name = "Completion pair entries", .count = graph.completion_pair_entries }, + .{ .name = "Completion pair expansions", .count = graph.completion_pair_expansions }, + .{ .name = "Completion memo hits", .count = graph.completion_memo_hits }, + .{ .name = "Completion cycle assumptions", .count = graph.completion_cycle_assumptions }, + .{ .name = "Completion invalidations", .count = graph.completion_invalidations }, + .{ .name = "Inhabitation queries", .count = graph.inhabitation_queries }, + .{ .name = "Inhabitation node entries", .count = graph.inhabitation_node_entries }, + .{ .name = "Inhabitation node expansions", .count = graph.inhabitation_node_expansions }, + .{ .name = "Inhabitation memo hits", .count = graph.inhabitation_memo_hits }, + .{ .name = "Inhabitation cycle assumptions", .count = graph.inhabitation_cycle_assumptions }, + .{ .name = "Inhabitation invalidations", .count = graph.inhabitation_invalidations }, }; } diff --git a/src/cli/progress.zig b/src/cli/progress.zig index 8ea4d441e4f..4ff5d713e2f 100644 --- a/src/cli/progress.zig +++ b/src/cli/progress.zig @@ -54,7 +54,7 @@ const max_subphases: usize = 25; // Shared lowering and a runtime continuation each report eight lowering groups // and native emission; tests also report result-cache counts. const max_counter_groups: usize = 2 * 9 + 1; -const max_counters_per_group: usize = 32; +const max_counters_per_group: usize = 40; /// Rows in the ranked timing list printed after the phases. const max_ranked_timings: usize = 10; /// Bytes reserved for copies of the ranked rows' names, which the caller's diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 93172624a49..0542bb87d79 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -22220,12 +22220,13 @@ const BodyContext = struct { /// fresh visitation hash table per query. inhabitation_visiting: std.bit_set.DynamicBitSetUnmanaged, /// Each node being expanded by the running uninhabitedness scan, with the - /// scan's count of unsettled encounters when it was entered. + /// scan's count of active-path cycle assumptions when it was entered. inhabitation_entered: collections.DenseMapUnmanaged(NodeId, usize) = .empty, - /// Uninhabitedness of graph roots whose whole reachable structure was - /// settled when scanned, per backing access. Nothing can change such an - /// answer. - settled_node_uninhabited: [2]collections.DenseMapUnmanaged(NodeId, bool) = .{ .empty, .empty }, + /// Cycle-independent answers at `inhabitation_epoch`, per backing access. + node_uninhabited_memo: [2]collections.DenseMapUnmanaged(NodeId, bool) = .{ .empty, .empty }, + inhabitation_epoch: u32 = 0, + completion_memo: CompletionMemo = .{}, + completion_scratch: CompletionScratch = .{}, /// The stacks the uninhabitedness scans run on, kept between scans. node_uninhabited_scratch: NodeUninhabitedScan.Evaluation.Scratch = .{}, pattern_uninhabited_scratch: PatternUninhabitedScan.Evaluation.Scratch = .{}, @@ -23177,6 +23178,8 @@ const BodyContext = struct { self.pattern_literal_guards.deinit(self.allocator); self.optional_destruct_binds.deinit(self.allocator); self.loop_contexts.deinit(self.allocator); + self.completion_memo.deinit(self.allocator); + self.completion_scratch.deinit(self.allocator); self.inhabitation_visiting.deinit(self.allocator); self.releaseMemos(); self.node_uninhabited_scratch.deinit(self.allocator); @@ -26410,7 +26413,7 @@ const BodyContext = struct { /// The per-body memo maps the builder pools across body contexts. const Memos = struct { inhabitation_entered: collections.DenseMapUnmanaged(NodeId, usize) = .empty, - settled_node_uninhabited: [2]collections.DenseMapUnmanaged(NodeId, bool) = .{ .empty, .empty }, + node_uninhabited_memo: [2]collections.DenseMapUnmanaged(NodeId, bool) = .{ .empty, .empty }, explicit_binding_memo: collections.DenseMapUnmanaged(checked.CheckedPatternId, bool) = .empty, /// The chunk-directory span past which a released map gives up its @@ -26419,14 +26422,14 @@ const BodyContext = struct { fn deinit(self: *Memos, allocator: Allocator) void { self.inhabitation_entered.deinit(allocator); - for (&self.settled_node_uninhabited) |*settled| settled.deinit(allocator); + for (&self.node_uninhabited_memo) |*settled| settled.deinit(allocator); self.explicit_binding_memo.deinit(allocator); } }; fn installMemos(self: *BodyContext, memos: Memos) void { self.inhabitation_entered = memos.inhabitation_entered; - self.settled_node_uninhabited = memos.settled_node_uninhabited; + self.node_uninhabited_memo = memos.node_uninhabited_memo; self.explicit_binding_memo = memos.explicit_binding_memo; } @@ -26434,14 +26437,14 @@ const BodyContext = struct { fn releaseMemos(self: *BodyContext) void { var memos: Memos = .{ .inhabitation_entered = self.inhabitation_entered, - .settled_node_uninhabited = self.settled_node_uninhabited, + .node_uninhabited_memo = self.node_uninhabited_memo, .explicit_binding_memo = self.explicit_binding_memo, }; // A pooled map keeps the pages of a body-sized key range, not every // page any body ever touched. const allocator = self.builder.allocator; memos.inhabitation_entered.clearRetainingAtMost(allocator, Memos.retained_chunks); - for (&memos.settled_node_uninhabited) |*settled| settled.clearRetainingAtMost(allocator, Memos.retained_chunks); + for (&memos.node_uninhabited_memo) |*settled| settled.clearRetainingAtMost(allocator, Memos.retained_chunks); memos.explicit_binding_memo.clearRetainingAtMost(allocator, Memos.retained_chunks); self.builder.body_memo_pool.append(self.builder.allocator, memos) catch memos.deinit(self.builder.allocator); } @@ -28626,24 +28629,52 @@ const BodyContext = struct { produced: NodeId, }; - /// Request-completion relations of the pairs one produced-value walk - /// enters. A pair's relation is a fact about its whole subtree, so a walk - /// that asks it of every container on a path would otherwise re-walk each - /// subtree once per enclosing container. Relations are recorded while the - /// first query walks the subtree and hold while the graph's - /// `structure_epoch` is unchanged; a relation decided by assuming a pair - /// still on the walked path is not recorded, since it holds only under - /// that assumption. + /// Answers belong to this body and this graph epoch. No cycle-dependent + /// relation is published, even when a later sibling decides the result. const CompletionMemo = struct { - epoch: u32, - relations: std.AutoHashMap(RequestCompletionPair, RequestCompletion), + epoch: u32 = 0, + relations: std.AutoHashMapUnmanaged(RequestCompletionPair, RequestCompletion) = .empty, + + fn deinit(self: *CompletionMemo, allocator: Allocator) void { + self.relations.deinit(allocator); + } - fn init(allocator: Allocator) CompletionMemo { - return .{ .epoch = 0, .relations = std.AutoHashMap(RequestCompletionPair, RequestCompletion).init(allocator) }; + fn get(self: *CompletionMemo, graph: *InstGraph, pair: RequestCompletionPair) ?RequestCompletion { + if (self.epoch != graph.structure_epoch) { + graph.countDiagnostic("completion_invalidations"); + self.relations.clearRetainingCapacity(); + self.epoch = graph.structure_epoch; + } + return self.relations.get(pair); } - fn deinit(self: *CompletionMemo) void { - self.relations.deinit(); + fn put(self: *CompletionMemo, ctx: *BodyContext, pair: RequestCompletionPair, relation: RequestCompletion, epoch: u32) Allocator.Error!void { + // Reading a leaf may expand it and join its component classes. + // A traversal spanning that change cannot certify an epoch fact. + if (epoch != ctx.graph.structure_epoch) return; + _ = self.get(ctx.graph, pair); + try self.relations.put(ctx.allocator, pair, relation); + } + }; + + const CompletionFrame = struct { + pair: RequestCompletionPair, + ops_start: usize, + next: usize, + relation: RequestCompletion, + cycles_at_entry: usize, + epoch: u32, + }; + + const CompletionScratch = struct { + path: RequestCompletionPath.Map = .empty, + frames: std.ArrayList(CompletionFrame) = .empty, + ops: std.ArrayList(RequestCompletionOp) = .empty, + + fn deinit(self: *CompletionScratch, allocator: Allocator) void { + self.path.deinit(allocator); + self.frames.deinit(allocator); + self.ops.deinit(allocator); } }; @@ -28659,31 +28690,9 @@ const BodyContext = struct { ) Allocator.Error!bool { var timing_scope = BodyWorkTimingScope.begin(self.builder.timing, .type_graph); defer timing_scope.end(); - var visiting = RequestCompletionPath.init(self.allocator); - defer visiting.deinit(); - return (try self.requestCompletionRelation(request_ret_node, produced_ret_node, &visiting, null)) == .completed; - } - - /// `resultCompletesRequest` for a pair a produced-value walk enters, - /// answered from the walk's memo while the graph is unchanged. - fn producedPairCompletesRequest( - self: *BodyContext, - checked_root: NodeId, - produced_root: NodeId, - memo: *CompletionMemo, - ) Allocator.Error!bool { - var timing_scope = BodyWorkTimingScope.begin(self.builder.timing, .type_graph); - defer timing_scope.end(); - const pair = RequestCompletionPair{ .request = self.graph.rootNode(checked_root), .produced = self.graph.rootNode(produced_root) }; - if (memo.epoch != self.graph.structure_epoch) { - memo.relations.clearRetainingCapacity(); - memo.epoch = self.graph.structure_epoch; - } else if (memo.relations.get(pair)) |relation| { - return relation == .completed; - } - var visiting = RequestCompletionPath.init(self.allocator); - defer visiting.deinit(); - return (try self.requestCompletionRelation(checked_root, produced_root, &visiting, memo)) == .completed; + var visiting = RequestCompletionPath{ .allocator = self.allocator, .map = self.completion_scratch.path }; + defer self.completion_scratch.path = visiting.map; + return (try self.requestCompletionRelation(request_ret_node, produced_ret_node, &visiting, &self.completion_memo)) == .completed; } fn relateCheckedNodeToProducedValue( @@ -28693,9 +28702,7 @@ const BodyContext = struct { ) Allocator.Error!NodeId { var visiting = std.AutoHashMap(ProducedValuePair, void).init(self.allocator); defer visiting.deinit(); - var completions = CompletionMemo.init(self.allocator); - defer completions.deinit(); - return try self.relateCheckedNodeToProducedValueInner(checked_node, produced_node, &visiting, &completions); + return try self.relateCheckedNodeToProducedValueInner(checked_node, produced_node, &visiting); } /// One step of relating a matching container's children: a child pair @@ -28734,7 +28741,6 @@ const BodyContext = struct { root_checked: NodeId, root_produced: NodeId, visiting: *std.AutoHashMap(ProducedValuePair, void), - completions: *CompletionMemo, ) Allocator.Error!NodeId { var frames: std.ArrayListUnmanaged(ProducedValueFrame) = .empty; defer frames.deinit(self.allocator); @@ -28746,7 +28752,7 @@ const BodyContext = struct { _ = visiting.remove(frame.pair); }; - var delivered: ?NodeId = try self.enterProducedValue(root_checked, root_produced, visiting, completions, &frames, &ops, &results); + var delivered: ?NodeId = try self.enterProducedValue(root_checked, root_produced, visiting, &frames, &ops, &results); while (true) { if (delivered) |node| { if (frames.items.len == 0) return node; @@ -28760,7 +28766,7 @@ const BodyContext = struct { frames.items[frames.items.len - 1].next += 1; switch (op) { .child => |child| { - delivered = try self.enterProducedValue(child.checked, child.produced, visiting, completions, &frames, &ops, &results); + delivered = try self.enterProducedValue(child.checked, child.produced, visiting, &frames, &ops, &results); continue; }, .field_name => |field| failed = field.checked != field.produced, @@ -28794,7 +28800,6 @@ const BodyContext = struct { checked_node: NodeId, produced_node: NodeId, visiting: *std.AutoHashMap(ProducedValuePair, void), - completions: *CompletionMemo, frames: *std.ArrayListUnmanaged(ProducedValueFrame), ops: *std.ArrayListUnmanaged(ProducedValueOp), results: *std.ArrayListUnmanaged(NodeId), @@ -28833,7 +28838,7 @@ const BodyContext = struct { } return checked_node; } - if (try self.producedPairCompletesRequest(checked_root, produced_root, completions)) return produced_node; + if (try self.resultCompletesRequest(checked_root, produced_root)) return produced_node; const ops_start = ops.items.len; var included_tags: ?[]const InstTag = null; @@ -29271,27 +29276,23 @@ const BodyContext = struct { visiting: *RequestCompletionPath, memo: ?*CompletionMemo, ) Allocator.Error!RequestCompletion { - const Frame = struct { - pair: RequestCompletionPair, - ops_start: usize, - next: usize, - relation: RequestCompletion, - /// Whether a relation below this frame assumed a pair on the path. - assumed: bool = false, - }; - var frames: std.ArrayListUnmanaged(Frame) = .empty; - defer frames.deinit(self.allocator); - var ops: std.ArrayListUnmanaged(RequestCompletionOp) = .empty; - defer ops.deinit(self.allocator); - errdefer for (frames.items) |frame| { - visiting.leave(frame.pair); - }; + self.graph.countDiagnostic("completion_queries"); + const frames = &self.completion_scratch.frames; + const ops = &self.completion_scratch.ops; + std.debug.assert(frames.items.len == 0 and ops.items.len == 0 and visiting.map.count() == 0); + defer { + frames.clearRetainingCapacity(); + ops.clearRetainingCapacity(); + visiting.map.clearRetainingCapacity(); + } + var cycles: usize = 0; + const root_epoch = self.graph.structure_epoch; var delivered: RequestCompletion = undefined; var has_delivery = false; var assumed = false; - if (try self.enterRequestCompletion(request_node, produced_node, visiting, &ops, &assumed)) |relation| { - if (memo) |m| try m.relations.put(.{ .request = self.graph.rootNode(request_node), .produced = self.graph.rootNode(produced_node) }, relation); + if (try self.enterRequestCompletion(request_node, produced_node, visiting, ops, &assumed, memo)) |relation| { + if (memo) |m| try m.put(self, .{ .request = self.graph.rootNode(request_node), .produced = self.graph.rootNode(produced_node) }, relation, root_epoch); return relation; } try frames.append(self.allocator, .{ @@ -29299,6 +29300,8 @@ const BodyContext = struct { .ops_start = 0, .next = 0, .relation = .unchanged, + .cycles_at_entry = cycles, + .epoch = root_epoch, }); while (true) { const frame = &frames.items[frames.items.len - 1]; @@ -29327,11 +29330,12 @@ const BodyContext = struct { frame.next += 1; const ops_start = ops.items.len; assumed = false; - if (try self.enterRequestCompletion(op.request, op.produced, visiting, &ops, &assumed)) |relation| { + const child_epoch = self.graph.structure_epoch; + if (try self.enterRequestCompletion(op.request, op.produced, visiting, ops, &assumed, memo)) |relation| { if (assumed) { - for (frames.items) |*open| open.assumed = true; + cycles += 1; } else if (memo) |m| { - try m.relations.put(.{ .request = self.graph.rootNode(op.request), .produced = self.graph.rootNode(op.produced) }, relation); + try m.put(self, .{ .request = self.graph.rootNode(op.request), .produced = self.graph.rootNode(op.produced) }, relation, child_epoch); } delivered = relation; has_delivery = true; @@ -29341,6 +29345,8 @@ const BodyContext = struct { .ops_start = ops_start, .next = ops_start, .relation = .unchanged, + .cycles_at_entry = cycles, + .epoch = child_epoch, }); } continue; @@ -29351,7 +29357,7 @@ const BodyContext = struct { const done = frames.pop().?; visiting.leave(done.pair); ops.shrinkRetainingCapacity(done.ops_start); - if (memo) |m| if (!done.assumed) try m.relations.put(done.pair, finished.?); + if (memo) |m| if (done.cycles_at_entry == cycles) try m.put(self, done.pair, finished.?, done.epoch); if (frames.items.len == 0) return finished.?; delivered = finished.?; has_delivery = true; @@ -29367,10 +29373,16 @@ const BodyContext = struct { visiting: *RequestCompletionPath, ops: *std.ArrayListUnmanaged(RequestCompletionOp), assumed: *bool, + memo: ?*CompletionMemo, ) Allocator.Error!?RequestCompletion { const gpa = self.allocator; + self.graph.countDiagnostic("completion_pair_entries"); const request_root = self.graph.rootNode(request_node); const produced_root = self.graph.rootNode(produced_node); + if (memo) |m| if (m.get(self.graph, .{ .request = request_root, .produced = produced_root })) |relation| { + self.graph.countDiagnostic("completion_memo_hits"); + return relation; + }; if (request_root == produced_root) return .unchanged; if (try self.nodeIsProvenUninhabited(request_root) and !try self.nodeIsProvenUninhabited(produced_root)) @@ -29380,10 +29392,12 @@ const BodyContext = struct { const pair = RequestCompletionPair{ .request = request_root, .produced = produced_root }; if (!try visiting.enter(pair)) { + self.graph.countDiagnostic("completion_cycle_assumptions"); assumed.* = true; return .unchanged; } errdefer visiting.leave(pair); + self.graph.countDiagnostic("completion_pair_expansions"); const start = ops.items.len; const relation: ?RequestCompletion = relation: { if ((try self.checkedPublicInspectableBacking(produced_root))) |backing| { @@ -37104,7 +37118,13 @@ const BodyContext = struct { node: NodeId, backing_access: UninhabitedBackingAccess, ) Allocator.Error!bool { - self.inhabitation_entered.clearRetainingCapacity(); + self.graph.countDiagnostic("inhabitation_queries"); + if (self.inhabitation_epoch != self.graph.structure_epoch) { + self.graph.countDiagnostic("inhabitation_invalidations"); + for (&self.node_uninhabited_memo) |*memo| memo.clearRetainingCapacity(); + self.inhabitation_epoch = self.graph.structure_epoch; + } + std.debug.assert(self.inhabitation_entered.count() == 0); var scan = NodeUninhabitedScan{ .body = self, .backing_access = backing_access }; return try NodeUninhabitedScan.Evaluation.runWith(self.allocator, &self.node_uninhabited_scratch, &scan, node); } @@ -66528,30 +66548,124 @@ test "completion path retains ancestors across distinct sibling visits" { path.leave(ancestor); } +/// Owns exactly the state used by the two graph analyses; no checked program +/// or backend is needed to exercise their identity and mutation contracts. +const CompletionAnalysisFixture = struct { + allocator: Allocator, + types: Type.Store, + name_store: names.NameStore, + graph: *InstGraph, + builder: Builder, + ctx: BodyContext, + diagnostics: solve.GraphDiagnostics, + + fn init(self: *@This(), allocator: Allocator) Allocator.Error!void { + self.allocator = allocator; + self.types = Type.Store.init(allocator); + errdefer self.types.deinit(); + self.name_store = names.NameStore.init(allocator); + errdefer self.name_store.deinit(); + self.graph = try InstGraph.create(allocator, &self.types, &self.name_store); + self.diagnostics = .{}; + self.graph.setDiagnostics(&self.diagnostics); + self.builder = undefined; + self.builder.app_module_identity = null; + self.builder.timing = null; + self.ctx = undefined; + self.ctx.allocator = allocator; + self.ctx.builder = &self.builder; + self.ctx.graph = self.graph; + self.ctx.inhabitation_visiting = .{}; + self.ctx.inhabitation_entered = .empty; + self.ctx.node_uninhabited_memo = .{ .empty, .empty }; + self.ctx.node_uninhabited_scratch = .{}; + self.ctx.inhabitation_epoch = 0; + self.ctx.completion_memo = .{}; + self.ctx.completion_scratch = .{}; + } + + fn deinit(self: *@This()) void { + self.ctx.completion_scratch.deinit(self.allocator); + self.ctx.completion_memo.deinit(self.allocator); + self.ctx.node_uninhabited_scratch.deinit(self.allocator); + for (&self.ctx.node_uninhabited_memo) |*memo| memo.deinit(self.allocator); + self.ctx.inhabitation_entered.deinit(self.allocator); + self.ctx.inhabitation_visiting.deinit(self.allocator); + self.graph.destroy(); + self.name_store.deinit(); + self.types.deinit(); + } + + fn expectRelation(self: *@This(), expected: BodyContext.RequestCompletion, request: NodeId, produced: NodeId) !void { + var path = BodyContext.RequestCompletionPath.init(self.allocator); + defer path.deinit(); + for (0..2) |_| { + try std.testing.expectEqual(expected, try self.ctx.requestCompletionRelation(request, produced, &path, &self.ctx.completion_memo)); + try std.testing.expectEqual(@as(usize, 0), path.map.count()); + } + // The uncached relation and a fresh inhabitation scope must agree. + for (&self.ctx.node_uninhabited_memo) |*memo| memo.clearRetainingCapacity(); + try std.testing.expectEqual(expected, try self.ctx.requestCompletionRelation(request, produced, &path, null)); + } + + fn expectUninhabited(self: *@This(), node: NodeId, access: BodyContext.UninhabitedBackingAccess, expected: bool) !void { + for (0..2) |_| try std.testing.expectEqual(expected, try self.ctx.nodeIsProvenUninhabitedInner(node, access)); + for (&self.ctx.node_uninhabited_memo) |*memo| memo.clearRetainingCapacity(); + try std.testing.expectEqual(expected, try self.ctx.nodeIsProvenUninhabitedInner(node, access)); + } +}; + +test "completion analysis shared subgraph work" { + var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const allocator = counting.allocator(); + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(allocator); + defer fixture.deinit(); + const graph = fixture.graph; + const ctx = &fixture.ctx; + const diagnostics = &fixture.diagnostics; + const shared = try graph.newNode(.{ .unresolved = InstVariable.placeholder() }); + var request = shared; + var produced = shared; + for (0..16) |_| { + request = try graph.newNode(.{ .box = request }); + produced = try graph.newNode(.{ .box = produced }); + } + const request_items = try graph.arena().alloc(NodeId, 32); + const produced_items = try graph.arena().alloc(NodeId, 32); + @memset(request_items, request); + @memset(produced_items, produced); + request = try graph.newNode(.{ .tuple = request_items }); + produced = try graph.newNode(.{ .tuple = produced_items }); + const allocations_before = counting.allocations; + for (0..128) |_| { + try std.testing.expect(!try ctx.resultCompletesRequest(request, produced)); + try std.testing.expect(!try ctx.nodeIsProvenUninhabited(request)); + } + try std.testing.expectEqual(@as(u64, 17), diagnostics.completion_pair_expansions); + try std.testing.expectEqual(@as(u64, 18), diagnostics.inhabitation_node_expansions); + try std.testing.expectEqual(@as(usize, 18), ctx.completion_memo.relations.count()); + try std.testing.expectEqual(@as(usize, 18), ctx.node_uninhabited_memo[0].count()); + try std.testing.expectEqual(@as(u64, 158), diagnostics.completion_memo_hits); + try std.testing.expect(counting.allocations - allocations_before <= 20); + + const allocations_warm = counting.allocations; + const work_warm = diagnostics.*; + for (0..256) |_| { + try std.testing.expect(!try ctx.resultCompletesRequest(request, produced)); + try std.testing.expect(!try ctx.nodeIsProvenUninhabited(request)); + } + try std.testing.expectEqual(allocations_warm, counting.allocations); + try std.testing.expectEqual(work_warm.completion_pair_expansions, diagnostics.completion_pair_expansions); + try std.testing.expectEqual(work_warm.inhabitation_node_expansions, diagnostics.inhabitation_node_expansions); +} + test "completion relation preserves recursive and shared DAG outcomes" { const allocator = std.testing.allocator; - var types = Type.Store.init(allocator); - defer types.deinit(); - var name_store = names.NameStore.init(allocator); - defer name_store.deinit(); - const graph = try InstGraph.create(allocator, &types, &name_store); - defer graph.destroy(); - var builder: Builder = undefined; - builder.app_module_identity = null; - builder.timing = null; - var ctx: BodyContext = undefined; - ctx.allocator = allocator; - ctx.builder = &builder; - ctx.graph = graph; - ctx.inhabitation_visiting = .{}; - defer ctx.inhabitation_visiting.deinit(allocator); - ctx.inhabitation_entered = .empty; - defer ctx.inhabitation_entered.deinit(allocator); - ctx.settled_node_uninhabited = .{ .empty, .empty }; - defer for (&ctx.settled_node_uninhabited) |*memo| memo.deinit(allocator); - ctx.node_uninhabited_scratch = .{}; - defer ctx.node_uninhabited_scratch.deinit(allocator); - + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(allocator); + defer fixture.deinit(); + const graph = fixture.graph; for ([_]BodyContext.RequestCompletion{ .unchanged, .completed, .mismatch }) |expected| { // Each side is a recursive list of pairs. Repeated tuple edges below // revisit the same cycle after a sibling traversal has left the path. @@ -66567,16 +66681,210 @@ test "completion relation preserves recursive and shared DAG outcomes" { .{ .primitive = .i64 }); produced_items[1] = try graph.newNode(.{ .primitive = if (expected == .mismatch) .str else .i64 }); + if (expected == .completed) { + request_items[1] = try graph.newNode(.{ .list = request_items[1] }); + produced_items[1] = try graph.newNode(.{ .list = produced_items[1] }); + } + const request_siblings = try graph.arena().alloc(NodeId, 64); const produced_siblings = try graph.arena().alloc(NodeId, 64); @memset(request_siblings, request_items[0]); @memset(produced_siblings, produced_items[0]); const request = try graph.newNode(.{ .tuple = request_siblings }); const produced = try graph.newNode(.{ .tuple = produced_siblings }); - var path = BodyContext.RequestCompletionPath.init(allocator); - defer path.deinit(); - try std.testing.expectEqual(expected, try ctx.requestCompletionRelation(request, produced, &path, null)); - try std.testing.expectEqual(@as(usize, 0), path.map.count()); + try fixture.expectRelation(expected, request, produced); + // Every enclosing relation observed a cycle and must remain absent. + try std.testing.expect(!fixture.ctx.completion_memo.relations.contains(.{ .request = request, .produced = produced })); + try std.testing.expect(!fixture.ctx.completion_memo.relations.contains(.{ .request = request_tuple, .produced = produced_tuple })); + try std.testing.expect(fixture.diagnostics.completion_cycle_assumptions > 0); + } +} + +test "completion analysis invalidates joins and keeps unrelated allocation and path compression reusable" { + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(std.testing.allocator); + defer fixture.deinit(); + const graph = fixture.graph; + const variable = try graph.newNode(.{ .unresolved = InstVariable.placeholder() }); + const request = try graph.newNode(.{ .box = variable }); + const produced = try graph.newNode(.{ .box = try graph.newNode(.{ .primitive = .i64 }) }); + try fixture.expectRelation(.mismatch, request, produced); + try fixture.expectUninhabited(request, .inspectable_only, false); + + const empty = try graph.newNode(.empty_tag_union); + try graph.unify(variable, empty); + try fixture.expectRelation(.completed, request, produced); + try fixture.expectUninhabited(request, .inspectable_only, true); + try std.testing.expect(fixture.diagnostics.completion_invalidations > 0); + try std.testing.expect(fixture.diagnostics.inhabitation_invalidations > 0); + + const epoch = graph.structure_epoch; + const work = fixture.diagnostics; + _ = try graph.newNode(.{ .primitive = .str }); + _ = graph.rootNode(variable); + _ = graph.rootNode(empty); + try std.testing.expect(try fixture.ctx.resultCompletesRequest(request, produced)); + try std.testing.expect(try fixture.ctx.nodeIsProvenUninhabited(request)); + try std.testing.expectEqual(epoch, graph.structure_epoch); + try std.testing.expectEqual(work.completion_pair_expansions, fixture.diagnostics.completion_pair_expansions); + try std.testing.expectEqual(work.inhabitation_node_expansions, fixture.diagnostics.inhabitation_node_expansions); +} + +test "completion analysis retires facts across lazy expansion and tag reordering" { + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(std.testing.allocator); + defer fixture.deinit(); + const graph = fixture.graph; + const scalar = try fixture.types.add(.{ .primitive = .i64 }); + const boxed = try fixture.types.add(.{ .box = scalar }); + const request = try graph.importMonoIndependent(boxed); + const produced = try graph.newNode(.{ .box = try graph.newNode(.{ .primitive = .i64 }) }); + const epoch = graph.structure_epoch; + var path = BodyContext.RequestCompletionPath.init(std.testing.allocator); + defer path.deinit(); + try std.testing.expectEqual(.unchanged, try fixture.ctx.requestCompletionRelation(request, produced, &path, &fixture.ctx.completion_memo)); + try std.testing.expect(graph.structure_epoch != epoch); + // This traversal expanded leaves. Its outer answer cannot be published + // as though the whole walk had observed the final epoch. + try std.testing.expect(!fixture.ctx.completion_memo.relations.contains(.{ .request = request, .produced = produced })); + try fixture.expectRelation(.unchanged, request, produced); + try std.testing.expect(fixture.ctx.completion_memo.relations.contains(.{ .request = request, .produced = produced })); + + const first = try fixture.name_store.internTagLabel("A"); + const last = try fixture.name_store.internTagLabel("Z"); + const empty = try graph.newNode(.empty_tag_union); + const tags = [_]InstTag{ + .{ .name = first, .checked_name = first, .payloads = &.{} }, + .{ .name = last, .checked_name = last, .payloads = &.{} }, + }; + const left = try graph.newNode(.{ .tag_union = .{ .tags = try graph.arena().dupe(InstTag, &.{ tags[1], tags[0] }), .ext = empty } }); + const right = try graph.newNode(.{ .tag_union = .{ .tags = try graph.arena().dupe(InstTag, &tags), .ext = empty } }); + try fixture.expectRelation(.mismatch, left, right); + const unsorted_epoch = graph.structure_epoch; + graph.sortTagHead(left); + try std.testing.expect(graph.structure_epoch != unsorted_epoch); + const sorted_epoch = graph.structure_epoch; + graph.sortTagHead(right); + graph.sortTagHead(left); + try std.testing.expectEqual(sorted_epoch, graph.structure_epoch); + try fixture.expectRelation(.unchanged, left, right); +} + +test "completion analysis separates backing visibility authority and changed backing evidence" { + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(std.testing.allocator); + defer fixture.deinit(); + const graph = fixture.graph; + const module = try fixture.name_store.internModuleIdentity(&@as([32]u8, @splat(0x94))); + const name = try fixture.name_store.internTypeName("Wrapped"); + const empty = try graph.newNode(.empty_tag_union); + const scalar = try graph.newNode(.{ .primitive = .i64 }); + inline for (.{ .inspectable, .runtime_layout_only }) |use| { + inline for (.{ .checked_public, .generated_private }) |authority| { + const wrapped_empty = try graph.newNode(try graph.namedContent(.{ + .named_type = .{ .module = .{}, .ty = @fromBackingInt(1), .ty_module = .{} }, + .def = .{ .module = module, .type_name = name }, + .kind = .nominal, + .builtin_owner = null, + .args = &.{}, + .backing = .{ .node = empty, .use = use, .authority = authority }, + })); + try fixture.expectUninhabited(wrapped_empty, .inspectable_only, use == .inspectable); + try fixture.expectUninhabited(wrapped_empty, .runtime_layout, true); + const wrapped_scalar = try graph.namedValueNodeWithBacking(wrapped_empty, scalar); + try fixture.expectRelation(if (use == .inspectable and authority == .checked_public) .completed else .mismatch, scalar, wrapped_scalar); + try fixture.expectUninhabited(wrapped_scalar, .runtime_layout, false); + // A changed evidence source has its own producer-assigned node; + // equal nominal identity does not reuse the old backing's answer. + try fixture.expectUninhabited(wrapped_empty, .runtime_layout, true); + } + } +} + +test "completion analysis preserves open empty rows and invalidates payload replacements" { + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(std.testing.allocator); + defer fixture.deinit(); + const graph = fixture.graph; + const empty = try graph.newNode(.empty_tag_union); + const open = try graph.newNode(.{ .unresolved = InstVariable.row(.empty_tag_union) }); + const open_row = try graph.newNode(.{ .tag_union = .{ .tags = &.{}, .ext = open } }); + try fixture.expectUninhabited(empty, .inspectable_only, true); + try fixture.expectUninhabited(open_row, .inspectable_only, false); + try graph.unify(open, empty); + try fixture.expectUninhabited(open_row, .inspectable_only, true); + + const name = try fixture.name_store.internTagLabel("Value"); + const row = try graph.newNode(.{ .tag_union = .{ + .tags = try graph.arena().dupe(InstTag, &.{.{ .name = name, .checked_name = name, .payloads = try graph.arena().dupe(NodeId, &.{empty}) }}), + .ext = empty, + } }); + try fixture.expectUninhabited(row, .inspectable_only, true); + const scalar = try graph.newNode(.{ .primitive = .i64 }); + try fixture.expectRelation(.completed, row, scalar); + try graph.replaceTagPayloadNode(row, name, 0, scalar); + try fixture.expectUninhabited(row, .inspectable_only, false); + try fixture.expectRelation(.mismatch, row, scalar); +} + +test "completion analysis does not publish a recursive inhabitation assumption" { + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(std.testing.allocator); + defer fixture.deinit(); + const graph = fixture.graph; + const components = try graph.arena().alloc(NodeId, 2); + const outer = try graph.newNode(.{ .tuple = components }); + const inner = try graph.newNode(.{ .box = outer }); + components[0] = inner; + components[1] = try graph.newNode(.empty_tag_union); + try std.testing.expect(try fixture.ctx.nodeIsProvenUninhabited(outer)); + // While outer was active, inner provisionally answered false. A later + // query of inner must discover the empty sibling rather than reuse it. + try std.testing.expect(!fixture.ctx.node_uninhabited_memo[0].contains(inner)); + try fixture.expectUninhabited(inner, .inspectable_only, true); + try std.testing.expect(fixture.diagnostics.inhabitation_cycle_assumptions > 0); +} + +test "completion analysis scratch and partial memos survive every allocation failure" { + const Probe = struct { + fn run(ctx: *BodyContext, inhabited: NodeId, request: NodeId, produced: NodeId) !bool { + try std.testing.expect(!try ctx.nodeIsProvenUninhabited(inhabited)); + return ctx.resultCompletesRequest(request, produced); + } + }; + var fail_offset: usize = 0; + while (true) : (fail_offset += 1) { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(failing.allocator()); + defer fixture.deinit(); + const graph = fixture.graph; + const empty = try graph.newNode(.empty_tag_union); + const scalar = try graph.newNode(.{ .primitive = .i64 }); + var request = empty; + var produced = scalar; + for (0..12) |_| { + // Lists keep the outer type inhabited, so completion must visit + // every child before finding the empty-to-scalar completion. + request = try graph.newNode(.{ .list = request }); + produced = try graph.newNode(.{ .list = produced }); + } + const inhabited = try graph.newNode(.{ .box = scalar }); + failing.fail_index = failing.alloc_index + fail_offset; + const result = Probe.run(&fixture.ctx, inhabited, request, produced) catch |err| { + try std.testing.expectEqual(error.OutOfMemory, err); + try std.testing.expectEqual(@as(usize, 0), fixture.ctx.completion_scratch.path.count()); + try std.testing.expectEqual(@as(usize, 0), fixture.ctx.completion_scratch.frames.items.len); + try std.testing.expectEqual(@as(usize, 0), fixture.ctx.completion_scratch.ops.items.len); + try std.testing.expectEqual(@as(usize, 0), fixture.ctx.inhabitation_entered.count()); + try std.testing.expectEqual(@as(usize, 0), fixture.ctx.inhabitation_visiting.count()); + failing.fail_index = std.math.maxInt(usize); + try std.testing.expect(try Probe.run(&fixture.ctx, inhabited, request, produced)); + continue; + }; + try std.testing.expect(result); + try std.testing.expect(fail_offset > 0); + break; } } @@ -68982,15 +69290,13 @@ fn methodOwnerFromType(types: *const Type.Store, ty: Type.TypeId) ?static_dispat const NodeUninhabitedScan = struct { body: *BodyContext, backing_access: BodyContext.UninhabitedBackingAccess, - /// Encounters whose answer later graph work can change: an unresolved - /// node, a named node's backing, or a node already on the path. A node - /// whose expansion saw none has a settled answer. - unsettled_hits: usize = 0, + /// Active-path assumptions cannot become reusable answers. + cycle_hits: usize = 0, const Evaluation = AnyAll.Evaluation(NodeId, NodeUninhabitedScan); - fn settled(self: *NodeUninhabitedScan) *collections.DenseMapUnmanaged(NodeId, bool) { - return &self.body.settled_node_uninhabited[@backingInt(self.backing_access)]; + fn memo(self: *NodeUninhabitedScan) *collections.DenseMapUnmanaged(NodeId, bool) { + return &self.body.node_uninhabited_memo[@backingInt(self.backing_access)]; } pub fn enter(self: *NodeUninhabitedScan, items: Evaluation.Items, node: NodeId) Allocator.Error!Evaluation.Expansion { @@ -69000,14 +69306,24 @@ const NodeUninhabitedScan = struct { if (body.inhabitation_visiting.bit_length <= root_index) { try body.inhabitation_visiting.resize(body.allocator, root_index + 1, false); } + body.graph.countDiagnostic("inhabitation_node_entries"); if (body.inhabitation_visiting.isSet(root_index)) { - self.unsettled_hits += 1; + body.graph.countDiagnostic("inhabitation_cycle_assumptions"); + self.cycle_hits += 1; return .{ .value = false }; } - if (self.settled().get(root)) |answer| return .{ .value = answer }; - // An unread leaf is exactly its finished type, whose answer is - // settled. - if (body.graph.leafType(root)) |ty| return .{ .value = try body.typeIsProvenUninhabitedWith(ty, self.backing_access) }; + if (self.memo().get(root)) |answer| { + body.graph.countDiagnostic("inhabitation_memo_hits"); + return .{ .value = answer }; + } + body.graph.countDiagnostic("inhabitation_node_expansions"); + // An unread leaf is exactly its immutable finished type. This query + // does not expand graph content or change the epoch. + if (body.graph.leafType(root)) |ty| { + const answer = try body.typeIsProvenUninhabitedWith(ty, self.backing_access); + try self.memo().put(body.allocator, root, answer); + return .{ .value = answer }; + } const expansion: Evaluation.Expansion = switch ((try body.graph.content(root))) { .redirect => |target| blk: { @@ -69017,7 +69333,6 @@ const NodeUninhabitedScan = struct { .leaf => unreachable, // graph.content reads a class root and expands its leaf, so it never returns a leaf .empty_tag_union => .{ .value = true }, .named => |named| blk: { - self.unsettled_hits += 1; const backing = named.backing orelse break :blk .{ .value = false }; if (backing.use != .inspectable and self.backing_access != .runtime_layout) break :blk .{ .value = false }; try items.add(backing.node); @@ -69045,10 +69360,7 @@ const NodeUninhabitedScan = struct { } break :blk .{ .group = .all }; }, - .unresolved => blk: { - self.unsettled_hits += 1; - break :blk .{ .value = false }; - }, + .unresolved => .{ .value = false }, .primitive, .list, .func, @@ -69059,8 +69371,10 @@ const NodeUninhabitedScan = struct { => .{ .value = false }, }; if (expansion == .group) { + try body.inhabitation_entered.put(body.allocator, root, self.cycle_hits); body.inhabitation_visiting.set(root_index); - try body.inhabitation_entered.put(body.allocator, root, self.unsettled_hits); + } else { + try self.memo().put(body.allocator, root, expansion.value); } return expansion; } @@ -69071,7 +69385,7 @@ const NodeUninhabitedScan = struct { body.inhabitation_visiting.unset(@backingInt(root)); const hits_before = (body.inhabitation_entered.fetchRemove(root) orelse return).value; const answer = result orelse return; - if (self.unsettled_hits == hits_before) try self.settled().put(body.allocator, root, answer); + if (self.cycle_hits == hits_before) try self.memo().put(body.allocator, root, answer); } }; diff --git a/src/postcheck/monotype/solve.zig b/src/postcheck/monotype/solve.zig index 5800b863967..ea0e801a6b8 100644 --- a/src/postcheck/monotype/solve.zig +++ b/src/postcheck/monotype/solve.zig @@ -1909,6 +1909,20 @@ pub const InterfaceConstraints = struct { /// `InstGraph.diagnostics` remains null unless detailed diagnostics were /// requested, so ordinary lowering does not count hot-path operations. pub const GraphDiagnostics = struct { + // Request-completion and inhabitation work, separate from allocation. + completion_queries: u64 = 0, + completion_pair_entries: u64 = 0, + completion_pair_expansions: u64 = 0, + completion_memo_hits: u64 = 0, + completion_cycle_assumptions: u64 = 0, + completion_invalidations: u64 = 0, + inhabitation_queries: u64 = 0, + inhabitation_node_entries: u64 = 0, + inhabitation_node_expansions: u64 = 0, + inhabitation_memo_hits: u64 = 0, + inhabitation_cycle_assumptions: u64 = 0, + inhabitation_invalidations: u64 = 0, + /// Node identities retained by argument-class snapshots. argument_class_members_snapshotted: u64 = 0, /// Visited-set slots initialized or touched by structural backing walks. @@ -2751,7 +2765,8 @@ pub const InstGraph = struct { _ = self.arena_impl.reset(.retain_capacity); } - fn countDiagnostic(self: *InstGraph, comptime field: []const u8) void { + /// Count graph work only when the owner requested diagnostics. + pub fn countDiagnostic(self: *InstGraph, comptime field: []const u8) void { if (self.diagnostics) |diagnostics| { @field(diagnostics, field) += 1; } @@ -7821,14 +7836,19 @@ pub const InstGraph = struct { } /// Normalize only a head that a row reader actually consumes. This state - /// changes neither type meaning nor snapshot dependencies, so recording it - /// does not invalidate relation stamps or observable graph snapshots. + /// changes neither type meaning nor snapshot dependencies. Reordering + /// retires order-sensitive analysis answers, but not unification stamps + /// or observable graph snapshots. pub fn sortTagHead(self: *InstGraph, root: NodeId) void { const row = &self.nodes.items[@backingInt(root)].tag_union; if (row.tags_sorted) return; const tags = row.tags; if (!std.sort.isSorted(InstTag, tags, self.name_store, instTagLessThan)) { std.mem.sortUnstable(InstTag, tags, self.name_store, instTagLessThan); + // Completion compares row entries in their stored order. Its + // answers must not survive a reordering, although type meaning, + // containment versions, and resolvedness are unchanged. + self.structure_epoch +%= 1; } for (tags, 0..) |tag, index| { if (index != 0) std.debug.assert(tags[index - 1].name != tag.name); From c97d39ade906ac118482d92507a9804019c92254 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 11:46:26 -0400 Subject: [PATCH 18/97] Reuse completed Lambda Solved compatibility traversals --- design.md | 32 ++ src/base/dev_diagnostics.zig | 3 + src/cli/test/parallel_cli_runner.zig | 88 +++++ .../test/cold_warm_runtime_program_test.zig | 30 ++ src/postcheck/lambda_solved/solve.zig | 309 +++++++++++++++++- test/cli/issue_12595_lambda_solved/Values.roc | 12 + test/cli/issue_12595_lambda_solved/main.roc | 38 +++ 7 files changed, 506 insertions(+), 6 deletions(-) create mode 100644 test/cli/issue_12595_lambda_solved/Values.roc create mode 100644 test/cli/issue_12595_lambda_solved/main.roc diff --git a/design.md b/design.md index 59231fa5bd6..38d3708ff1f 100644 --- a/design.md +++ b/design.md @@ -17469,6 +17469,38 @@ materializing shapes. A query must not expand a clone that the following unification can join directly; doing so would discard the proof and duplicate large payload graphs merely to check that they can join. +Compatibility traversal scratch belongs to one solver invocation. Deterministic +work counters distinguish compatibility queries and structural pairs from lazy +expansion, unification, member merging and final ordering. The developer-only +`ROC_LAMBDA_SOLVE_STATS` diagnostic reports one record per invocation, including +its required roots and functions; +pre-evaluation solving and normalization from stored values remain separate +invocations. No diagnostic state or solved facts cross those boundaries. + +The solver retains the structural pairs of its last completed, successful +compatibility traversal. They are reusable only at the type store's recorded +mutation epoch; any semantic write invalidates them, including lazy expansion, +while path compression preserves meaning. A query that expands leaves does +not publish facts. Pairs visited during a query are provisional until the +entire query succeeds, so a cycle cut followed by a conflicting sibling never +publishes a cyclic assumption as a completed fact. A successful traversal +replaces the previous fact set instead of accumulating every queried pair; +retained capacity is bounded by the largest traversal in this invocation. +Compatibility still only chooses which payloads can relate. Unification runs +every required payload relation and propagates callable members and erasure. + +The focused regression builds two recursive callable graphs, each a function +returning a chain of D singleton tuples back to itself, then relates two rows +whose W distinct tags all carry those graphs. `mergeTags` asks +`payloadsCompatible` about each tag before scheduling any payload unification. +Previously each `typesCompatible` call rebuilt its worklist and visited set and +processed the same D+1 structural pairs: W*(D+1) pairs in all. Retaining the +completed traversal reduces this to D+1 pairs and W-1 reuse hits, with D+1 +retained facts. At W=64 and D=64 the regression records 4,160 pairs before and +65 after, while both versions perform all 131 unification pairs. The graphs +carry overlapping lambda sets and a shared capture span; canonical member +ordering, capture identity and their union remain part of the regression. + A positional leaf may inherit a named type’s argument scope without reaching any of its bindings. Its expansion is still unobservable in that case: the solver checks the immutable Monotype graph for scoped argument occurrences, diff --git a/src/base/dev_diagnostics.zig b/src/base/dev_diagnostics.zig index d7aa4bb30bb..3124828acda 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -20,6 +20,8 @@ pub const Var = enum { lir_dump, /// Print every closed specialization key. spec_census, + /// Print deterministic Lambda Solved work once per required invocation. + lambda_solve_stats, /// Print every object cache lookup and offer. pack_trace, /// Print how many procedures a dev build served from cached packs. @@ -42,6 +44,7 @@ pub const Var = enum { return switch (self) { .lir_dump => "ROC_LIR_DUMP", .spec_census => "ROC_SPEC_CENSUS", + .lambda_solve_stats => "ROC_LAMBDA_SOLVE_STATS", .pack_trace => "ROC_PACK_TRACE", .pack_stats => "ROC_PACK_STATS", .ctfe_demand_trace => "ROC_CTFE_DEMAND_TRACE", diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 46de63e39eb..2c5ff7eba0f 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -457,6 +457,7 @@ const CustomCase = enum { ctfe_cache_mixing, literal_root_rejected_every_build, issue_11673_callable_cache, + issue_12595_lambda_solved, issue_11678_recursive_callback_cache, issue_11678_dev_cache_boxy_tables, issue_11710_shared_object_cache, @@ -3216,6 +3217,7 @@ const subcommand_cases = [_]CliCase{ .{ .id = 0, .suite = .subcommands, .name = "roc build native dev output assembled from its own procedure artifacts is identical", .timeout_ms = 600_000, .body = .{ .custom = .native_build_artifact_round_trip } }, .{ .id = 0, .suite = .subcommands, .name = "roc build native dev pack programs are deterministic and round-trip through artifacts", .timeout_ms = 600_000, .body = .{ .custom = .native_build_pack_objects } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11673: imported callable identity survives cold warm and sibling builds", .timeout_ms = 600_000, .body = .{ .custom = .issue_11673_callable_cache } }, + .{ .id = 0, .suite = .subcommands, .name = "issue 12595: lambda solving preserves test observations across cache states and workers", .backend = .dev, .body = .{ .custom = .issue_12595_lambda_solved } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11678: cached recursive callbacks retain method result rows", .timeout_ms = 600_000, .body = .{ .custom = .issue_11678_recursive_callback_cache } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11678: dev builds whose code reaches the Boxy runtime are identical whatever the dev cache holds, including another app's packs", .timeout_ms = 600_000, .body = .{ .custom = .issue_11678_dev_cache_boxy_tables } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11710: shared object cache serves a second app's dev build", .timeout_ms = 600_000, .body = .{ .custom = .issue_11710_shared_object_cache } }, @@ -5203,6 +5205,7 @@ fn runCustomCase( .native_build_artifact_round_trip => customNativeBuildArtifactRoundTrip(io, allocator, &env, &timer, timeout_ms), .native_build_pack_objects => customNativeBuildPackObjects(io, allocator, &env, &timer, timeout_ms), .issue_11673_callable_cache => customIssue11673CallableCache(io, allocator, &env, &timer, timeout_ms), + .issue_12595_lambda_solved => customIssue12595LambdaSolved(io, allocator, &env, &timer, timeout_ms), .issue_11678_recursive_callback_cache => customIssue11678RecursiveCallbackCache(io, allocator, &env, &timer, timeout_ms), .issue_11678_dev_cache_boxy_tables => customIssue11678DevCacheBoxyTables(io, allocator, &env, &timer, timeout_ms), .issue_11710_shared_object_cache => customIssue11710SharedObjectCache(io, allocator, &env, &timer, timeout_ms), @@ -9988,6 +9991,91 @@ fn customLiteralRootRejectedEveryBuild( return null; } +/// Required test roots retain their observations when values are evaluated +/// fresh, restored, or reused while only the app changes. No timings are asserted. +fn customIssue12595LambdaSolved( + io: std.Io, + allocator: Allocator, + env: *const CaseEnv, + timer: *harness.Timer, + timeout_ms: u64, +) ?TestResult { + var source: []const u8 = undefined; + var app: []const u8 = undefined; + for ([_][]const u8{ "Values.roc", "main.roc" }) |name| { + const from = std.fmt.allocPrint(allocator, "{s}/test/cli/issue_12595_lambda_solved/{s}", .{ project_root_path, name }) catch |err| + return customInfraFailure(allocator, timer, "fixture path: {}", .{err}); + const contents = std.Io.Dir.cwd().readFileAlloc(io, from, allocator, .limited(1024 * 1024)) catch |err| + return customInfraFailure(allocator, timer, "read fixture: {}", .{err}); + const to = std.fs.path.join(allocator, &.{ env.dirs.work_dir, name }) catch |err| + return customInfraFailure(allocator, timer, "staged path: {}", .{err}); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = to, .data = contents }) catch |err| + return customInfraFailure(allocator, timer, "stage fixture: {}", .{err}); + if (std.mem.eql(u8, name, "main.roc")) { + source = contents; + app = to; + } + } + var observations: ?[]const u8 = null; + var failure_observations: ?[]const u8 = null; + for ([_][]const u8{ "--jobs=1", "--jobs=4" }, 0..) |jobs, index| { + var isolated = CaseEnv{ .dirs = env.dirs, .env_map = env.env_map.clone(allocator) catch |err| + return customInfraFailure(allocator, timer, "clone environment: {}", .{err}) }; + defer isolated.env_map.deinit(); + const cache = std.fmt.allocPrint(allocator, "{s}/cache_{d}", .{ env.dirs.work_dir, index }) catch |err| + return customInfraFailure(allocator, timer, "cache path: {}", .{err}); + isolated.env_map.put("ROC_CACHE_DIR", cache) catch |err| + return customInfraFailure(allocator, timer, "isolate cache: {}", .{err}); + const edited = std.fmt.allocPrint(allocator, "{s}\nexpect run(First({{ call: Values.stored, offset: 4 }})) == 11\n", .{source}) catch |err| + return customInfraFailure(allocator, timer, "edit fixture: {}", .{err}); + const failing = std.fmt.allocPrint(allocator, "{s}\nexpect run(First({{ call: Values.stored, offset: 3 }})) == 999\n", .{source}) catch |err| + return customInfraFailure(allocator, timer, "failing fixture: {}", .{err}); + // Uncached, cold, warm, partial after a new root, warm edited, and + // a failure with uncached, partially cached and warm inputs. + for (0..8) |phase| { + const changed = phase == 3 or phase == 4; + const fails = phase >= 5; + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = app, .data = if (fails) failing else if (changed) edited else source }) catch |err| + return customInfraFailure(allocator, timer, "write fixture: {}", .{err}); + const args: []const []const u8 = if (phase == 0 or phase == 5) + &.{ "test", "--opt=dev", "--specialize=yes", "--no-color", "--no-cache", jobs } + else + &.{ "test", "--opt=dev", "--specialize=yes", "--no-color", jobs }; + const run = switch (captureRocRun(io, allocator, &isolated, timer, timeout_ms, .{ + .args = args, + .roc_file = app, + .exit = if (fails) .failure else .success, + })) { + .result => |run| run, + .failure => |failure| return failure, + }; + if (std.mem.count(u8, run.stderr, "[dbg] \"lambda solved stored closure\"") != 1) + return failureFromRun(allocator, timer, run, "stored observation was not replayed exactly once"); + if (fails and (std.mem.find(u8, run.stderr, "== 999") == null or std.mem.find(u8, run.stderr, "1 failed") == null)) + return failureFromRun(allocator, timer, run, "the failing expect did not retain its source and outcome"); + // The test summary reports elapsed time. Compare every diagnostic, + // observation and outcome line, leaving that timing line out. + var normalized: std.Io.Writer.Allocating = .init(allocator); + defer normalized.deinit(); + var lines = std.mem.splitScalar(u8, run.stderr, '\n'); + while (lines.next()) |line| { + if (std.mem.startsWith(u8, line, "Ran ") and std.mem.find(u8, line, " tests in ") != null) continue; + normalized.writer.print("{s}\n", .{line}) catch |err| + return customInfraFailure(allocator, timer, "normalize test output: {}", .{err}); + } + const expected = if (fails) &failure_observations else &observations; + if (expected.*) |previous| { + if (!std.mem.eql(u8, previous, normalized.written())) + return failureFromRun(allocator, timer, run, "cache state or worker count changed observations or diagnostic source mappings"); + } else expected.* = normalized.toOwnedSlice() catch |err| + return customInfraFailure(allocator, timer, "retain test output: {}", .{err}); + if (!fails and std.mem.find(u8, run.stdout, if (changed) "All (7) tests passed" else "All (6) tests passed") == null) + return failureFromRun(allocator, timer, run, "required tests did not all pass"); + } + } + return null; +} + // Both caller orders share one cache, so the second app also consumes packs // produced under a different root. Each app has an uncached execution oracle. fn customIssue11678RecursiveCallbackCache( diff --git a/src/compile/test/cold_warm_runtime_program_test.zig b/src/compile/test/cold_warm_runtime_program_test.zig index a3c4077cb86..6f6929eb73a 100644 --- a/src/compile/test/cold_warm_runtime_program_test.zig +++ b/src/compile/test/cold_warm_runtime_program_test.zig @@ -200,3 +200,33 @@ test "a function value a constant holds lowers the same whether evaluated or rea \\} ); } + +test "issue 12595 shared row callables lower identically from fresh and stored values" { + try expectColdEqualsWarm( + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\import pf.Echo + \\Payload : { call : (U64 -> U64), offset : U64 } + \\Message : [First(Payload), Second(Payload), Third(Payload)] + \\make : U64 -> (U64 -> U64) + \\make = |offset| |value| value + offset + \\stored = make(7) + \\run : Message -> U64 + \\run = |message| { + \\ payload = match message { + \\ First(value) => value + \\ Second(value) => value + \\ Third(value) => value + \\ } + \\ callback = payload.call + \\ callback(payload.offset) + \\} + \\main! = |args| { + \\ n = args.len() + \\ Echo.line!(Str.inspect(run(First({ call: stored, offset: n })))) + \\ Echo.line!(Str.inspect(run(Second({ call: make(n), offset: n })))) + \\ callback = Box.unbox(Box.box(make(n))) + \\ Echo.line!(Str.inspect(run(Third({ call: callback, offset: n })))) + \\ Ok({}) + \\} + ); +} diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index 9ffdd485630..330a4b6dcda 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -33,6 +33,7 @@ //! explicit `FnId` and symbol references. const std = @import("std"); +const diagnostics = @import("base").dev_diagnostics; const TypeDigestHasher = @import("base").TypeDigestHasher; const collections = @import("collections"); const can = @import("can"); @@ -183,6 +184,28 @@ const DeferredSpanPair = struct { rhs: Type.Span, }; +/// Invocation-local work, collected only by tests or developer diagnostics. +pub const WorkCounters = struct { + functions: u64 = 0, + expansions: u64 = 0, + clones: u64 = 0, + unify_pairs: u64 = 0, + active_cycles: u64 = 0, + compatibility_queries: u64 = 0, + compatibility_pairs: u64 = 0, + /// Revisited pairs within a query, from cycles or shared subgraphs. + compatibility_cycles: u64 = 0, + compatibility_hits: u64 = 0, + compatibility_invalidations: u64 = 0, + compatibility_peak_facts: u64 = 0, + /// Input member counts of rows offered to merging. + row_members: u64 = 0, + lambda_members: u64 = 0, + lambda_copies: u64 = 0, + /// Members offered to final ordering, including already ordered spans. + canonical_members: u64 = 0, +}; + /// Solve lambda-set relationships in a lifted Monotype program. pub fn run( allocator: Allocator, @@ -195,12 +218,34 @@ pub fn run( var solver = try Solver.init(allocator, &program); defer solver.deinit(); + var counters: WorkCounters = .{}; + if (diagnostics.isSet(.lambda_solve_stats)) solver.counters = &counters; try solver.solve(); + if (diagnostics.isSet(.lambda_solve_stats)) { + var buffer: [2048]u8 = undefined; + var out = std.Io.Writer.fixed(&buffer); + out.print("lambda-solve roots={d} literal_roots={d} mono_types={d} solved_types={d}", .{ + solver.lifted.roots.len, solver.lifted.literal_roots.len, + solver.lifted.types.types.len, program.types.varCount(), + }) catch unreachable; // the fixed counter record fits in 2048 bytes + inline for (@typeInfo(WorkCounters).@"struct".field_names) |field| { + out.print(" {s}={d}", .{ field, @field(counters, field) }) catch unreachable; // the fixed counter record fits in 2048 bytes + } + std.debug.print("{s}\n", .{out.buffered()}); + } return program; } const Solver = struct { + counters: ?*WorkCounters = null, + /// Scratch and the last completed successful compatibility traversal. + /// Replacing facts bounds retained state by one traversal, not all queries. + compatibility_work: std.ArrayList(UnifyPair) = .empty, + compatibility_visited: UnifyPairSet.Unmanaged = .empty, + compatibility_proven: UnifyPairSet.Unmanaged = .empty, + compatibility_epoch: u64 = 0, + allocator: Allocator, program: *Ast.Program, lifted: Lifted.ProgramView, @@ -334,6 +379,12 @@ const Solver = struct { mono_uninhabited_scratch: MonoUninhabitedScan.Eval.Scratch = .{}, mono_entry_stops: std.ArrayList(u32) = .empty, + fn countWork(self: *Solver, comptime field: std.meta.FieldEnum(WorkCounters), amount: usize) void { + if (comptime @import("builtin").is_test or diagnostics.available) { + if (self.counters) |counters| @field(counters, @tagName(field)) += amount; + } + } + const FunctionShape = struct { args: Type.Span, callable: Type.TypeVarId, @@ -467,6 +518,9 @@ const Solver = struct { } fn deinit(self: *Solver) void { + self.compatibility_work.deinit(self.allocator); + self.compatibility_visited.deinit(self.allocator); + self.compatibility_proven.deinit(self.allocator); self.tag_row_indexes.deinit(self.allocator); self.tag_row_index_entries.deinit(self.allocator); var set_indexes = self.lambda_set_indexes.valueIterator(); @@ -547,6 +601,7 @@ const Solver = struct { }); } + self.countWork(.functions, self.lifted.fns.len); for (self.lifted.fns, 0..) |fn_, index| { const fn_id: Lifted.FnId = @fromBackingInt(@intCast(@as(u32, @intCast(index)))); try self.solveFn(fn_id, fn_); @@ -654,6 +709,7 @@ const Solver = struct { /// already sorted. Sets sharing a start are prefixes of one another, so a /// set is never sorted in place. fn canonicalMemberSpan(self: *Solver, members: Type.Span, sorted: *std.ArrayList(Type.FnMember)) Allocator.Error!?Type.Span { + self.countWork(.canonical_members, members.count()); if (members.count() < 2) return null; const types = &self.program.types; const ascending = for (1..members.count()) |index| { @@ -1845,6 +1901,7 @@ const Solver = struct { /// to their existing vars, so a recursive Monotype materializes as the /// same cyclic graph an eager clone produced. fn expandMonoRoot(self: *Solver, root: Type.TypeVarId, leaf: MonoLeaf) Allocator.Error!Type.Content { + self.countWork(.expansions, 1); const sharing = self.leafSharing(leaf.id); const ctx: u32 = if (leaf.ctx != Type.no_leaf_context) leaf.ctx else blk: { if (sharing == .shared) break :blk try self.sharedLeafContext(); @@ -2191,10 +2248,20 @@ const Solver = struct { /// types with their own relations—is compatible. Preserve lazy identity /// proofs before materializing shapes that require a structural comparison. fn typesCompatible(self: *Solver, a: Type.TypeVarId, b: Type.TypeVarId) Allocator.Error!bool { - var work = std.ArrayList(UnifyPair).empty; - defer work.deinit(self.allocator); - var visited = UnifyPairSet.init(self.allocator); - defer visited.deinit(); + self.countWork(.compatibility_queries, 1); + const root_a = self.program.types.rootCompressed(a); + const root_b = self.program.types.rootCompressed(b); + if (root_a == root_b) return true; + self.invalidateCompatibilityFacts(); + if (self.compatibility_proven.contains(UnifyPair.init(root_a, root_b))) { + self.countWork(.compatibility_hits, 1); + return true; + } + const epoch = self.program.types.mutation_epoch; + const work = &self.compatibility_work; + work.clearRetainingCapacity(); + const visited = &self.compatibility_visited; + visited.clearRetainingCapacity(); try work.append(self.allocator, .{ .first = a, .second = b }); while (work.pop()) |pair| { const left = self.program.types.rootCompressed(pair.first); @@ -2208,7 +2275,17 @@ const Solver = struct { if (raw_left == .mono and raw_right == .mono and raw_left.mono.id == raw_right.mono.id) continue; if (try self.canLinkUntouchedLeafToInstance(left, raw_left, pair.second, right)) continue; if (try self.canLinkUntouchedLeafToInstance(right, raw_right, pair.first, left)) continue; - if ((try visited.getOrPut(UnifyPair.init(left, right))).found_existing) continue; + self.invalidateCompatibilityFacts(); + const key = UnifyPair.init(left, right); + if ((try visited.getOrPut(self.allocator, key)).found_existing) { + self.countWork(.compatibility_cycles, 1); + continue; + } + if (self.compatibility_proven.contains(key)) { + self.countWork(.compatibility_hits, 1); + continue; + } + self.countWork(.compatibility_pairs, 1); const left_content = try self.resolvedContentAt(left); const right_content = try self.resolvedContentAt(right); const left_tag = std.meta.activeTag(left_content); @@ -2271,9 +2348,26 @@ const Solver = struct { }, } } + // All visited pairs now hold, including mutually recursive ones. + // A failed or graph-mutating walk never publishes assumptions. + if (epoch == self.program.types.mutation_epoch) { + std.mem.swap(UnifyPairSet.Unmanaged, &self.compatibility_proven, visited); + self.compatibility_epoch = epoch; + if (comptime @import("builtin").is_test or diagnostics.available) { + if (self.counters) |counters| counters.compatibility_peak_facts = @max(counters.compatibility_peak_facts, self.compatibility_proven.count()); + } + } return true; } + fn invalidateCompatibilityFacts(self: *Solver) void { + const epoch = self.program.types.mutation_epoch; + if (self.compatibility_epoch == epoch) return; + if (self.compatibility_proven.count() != 0) self.countWork(.compatibility_invalidations, 1); + self.compatibility_proven.clearRetainingCapacity(); + self.compatibility_epoch = epoch; + } + fn namedBacking(self: *Solver, ty: Type.TypeVarId) Allocator.Error!?Type.TypeVarId { const content = try self.resolvedContent(ty); if (std.meta.activeTag(content) != .named) return null; @@ -2563,6 +2657,7 @@ const Solver = struct { rhs: Type.TypeVarId, structural_isolated: bool, ) Allocator.Error!void { + self.countWork(.unify_pairs, 1); var a = self.program.types.rootCompressed(lhs); var b = self.program.types.rootCompressed(rhs); if (a == b) return; @@ -2601,7 +2696,10 @@ const Solver = struct { if (right_tag == .forall) Common.invariant("generalized Lambda Solved type reached local unification without instantiation"); const pair = UnifyPair.init(a, b); - if (!try self.active_unifications.begin(self.allocator, pair)) return; + if (!try self.active_unifications.begin(self.allocator, pair)) { + self.countWork(.active_cycles, 1); + return; + } errdefer self.active_unifications.retire(pair); // Reserve the finish frame before pushing any children so it pops last @@ -3687,6 +3785,7 @@ const Solver = struct { rhs: Type.Span, payload_pairs: *std.ArrayList(DeferredSpanPair), ) Allocator.Error!Type.Span { + self.countWork(.row_members, lhs.count() + rhs.count()); const right_index = try self.tagRowIndex(rhs); const right_paired = &self.merge_right_paired; try right_paired.resize(self.allocator, rhs.count(), false); @@ -3755,6 +3854,7 @@ const Solver = struct { const members = &self.merge_members; members.clearRetainingCapacity(); + self.countWork(.lambda_members, other.count()); for (0..other.count()) |i| { const other_member = self.program.types.memberItem(other, i); if (self.lambdaSetPosition(kept_index, other_member.lambda, kept)) |position| { @@ -3778,6 +3878,7 @@ const Solver = struct { _ = try self.program.types.addMembers(members.items); return .{ .start = kept.start, .len = @intCast(kept.count() + added_count) }; } + self.countWork(.lambda_copies, kept.count()); const added_count = members.items.len; try members.ensureUnusedCapacity(self.allocator, kept.count()); members.items.len += kept.count(); @@ -4637,6 +4738,7 @@ const TypeCloner = struct { /// holds the type's content and three lists, so it is never copied. /// The frame's build owns its lists before anything can fail. fn beginClone(self: *TypeCloner, frame: *CloneFrame, ty: MonoType.TypeId) Allocator.Error!void { + self.solver.countWork(.clones, 1); frame.* = .{ .ty = ty, .reserved = undefined, @@ -4909,6 +5011,7 @@ fn solvedTypeDigestTestSolver( name_store: *const names.NameStore, ) Solver { var solver: Solver = undefined; + solver.counters = null; solver.allocator = allocator; solver.mono_uninhabited = collections.DenseMap(MonoType.TypeId, bool).init(allocator); defer solver.mono_uninhabited.deinit(); @@ -5044,6 +5147,7 @@ test "lambda solved erased callable digest includes record field default identit var lifted: Lifted.ProgramView = undefined; lifted.names = &name_store; var solver: Solver = undefined; + solver.counters = null; solver.allocator = gpa; solver.mono_uninhabited = collections.DenseMap(MonoType.TypeId, bool).init(gpa); defer solver.mono_uninhabited.deinit(); @@ -5104,6 +5208,7 @@ test "inspectable backing unification isolates the structural type variable once const vars_before_unify = program.types.varCount(); var solver: Solver = undefined; + solver.counters = null; solver.allocator = allocator; solver.mono_uninhabited = collections.DenseMap(MonoType.TypeId, bool).init(allocator); defer solver.mono_uninhabited.deinit(); @@ -5170,6 +5275,7 @@ test "inspectable backing unification never redirects an owned backing to its no const backing_representative = try program.types.add(.{ .primitive = .u64 }); var solver: Solver = undefined; + solver.counters = null; solver.allocator = allocator; solver.mono_uninhabited = collections.DenseMap(MonoType.TypeId, bool).init(allocator); defer solver.mono_uninhabited.deinit(); @@ -5624,6 +5730,197 @@ fn emptyLiftedProgramForTest(allocator: Allocator) Lifted.Program { ); } +test "issue 12595 shared recursive row payload compatibility work" { + // Vary row width independently from the depth of its shared payload. + // Each graph has `depth` tuples ending in a recursive callable. Every + // tag relates those same two graphs before mergeTags schedules unification. + for ([_]usize{ 1, 16, 64 }) |width| { + for ([_]usize{ 1, 16, 64 }) |depth| { + var allocations = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const allocator = allocations.allocator(); + var lifted = emptyLiftedProgramForTest(allocator); + var left_tags: std.ArrayList(Type.Tag) = .empty; + defer left_tags.deinit(allocator); + var right_tags: std.ArrayList(Type.Tag) = .empty; + defer right_tags.deinit(allocator); + for (0..width) |index| { + var buf: [32]u8 = undefined; + const name = try lifted.names.internTagLabel(try std.fmt.bufPrint(&buf, "Tag{d:0>4}", .{index})); + try left_tags.append(allocator, .{ .name = name, .checked_name = name, .payloads = .empty() }); + } + var program = Ast.Program.init(allocator, lifted); + defer program.deinit(); + var solver = try Solver.init(allocator, &program); + defer solver.deinit(); + var counts: WorkCounters = .{}; + solver.counters = &counts; + const types = &program.types; + const value = try types.add(.{ .primitive = .u64 }); + const captures = try types.addCaptures(&.{.{ .local = @fromBackingInt(0), .symbol = @fromBackingInt(0), .binder = null, .ty = value }}); + const common: Type.FnMember = .{ .lambda = @fromBackingInt(3), .captures = captures }; + const left_callable = try types.add(.{ .lambda_set = try types.addMembers(&.{ common, .{ .lambda = @fromBackingInt(2), .captures = .empty() } }) }); + const right_callable = try types.add(.{ .lambda_set = try types.addMembers(&.{ .{ .lambda = @fromBackingInt(1), .captures = .empty() }, common }) }); + const left_fn = try types.add(.unbound); + const right_fn = try types.add(.unbound); + var left = left_fn; + var right = right_fn; + for (0..depth) |_| { + left = try types.add(.{ .tuple = try types.addSpan(&.{left}) }); + right = try types.add(.{ .tuple = try types.addSpan(&.{right}) }); + } + try types.set(left_fn, .{ .func = .{ .args = .empty(), .callable = left_callable, .ret = left } }); + try types.set(right_fn, .{ .func = .{ .args = .empty(), .callable = right_callable, .ret = right } }); + const left_payload = try types.addSpan(&.{left}); + const right_payload = try types.addSpan(&.{right}); + for (left_tags.items) |*tag| { + tag.payloads = left_payload; + var right_tag = tag.*; + right_tag.payloads = right_payload; + try right_tags.append(allocator, right_tag); + } + const left_row = try types.add(.{ .tag_union = try types.addTags(left_tags.items) }); + const right_row = try types.add(.{ .tag_union = try types.addTags(right_tags.items) }); + const before = allocations.alloc_index; + try solver.unify(left_row, right_row); + const used = allocations.alloc_index - before; + try solver.canonicalizeCallableMemberOrder(); + try std.testing.expectEqual(types.root(left_row), types.root(right_row)); + try std.testing.expectEqual(width, types.rootContent(left_row).tag_union.count()); + try std.testing.expectEqual(types.root(left_fn), types.root(right_fn)); + const members = types.view().memberSpan(types.rootContent(left_callable).lambda_set); + try std.testing.expectEqual(@as(usize, 3), members.len); + for (members, 1..) |member, symbol| try std.testing.expectEqual(symbol, @backingInt(member.lambda)); + try std.testing.expectEqual(captures, members[2].captures); + if (diagnostics.isSet(.lambda_solve_stats)) std.debug.print("12595 width={d} depth={d} queries={d} pairs={d} cycles={d} unify={d} allocations={d}\n", .{ + width, depth, counts.compatibility_queries, counts.compatibility_pairs, counts.compatibility_cycles, counts.unify_pairs, used, + }); + try std.testing.expectEqual(width, counts.compatibility_queries); + try std.testing.expectEqual(depth + 1, counts.compatibility_pairs); + try std.testing.expectEqual(width - 1, counts.compatibility_hits); + try std.testing.expectEqual(@as(u64, 1), counts.compatibility_cycles); + try std.testing.expectEqual(depth + 1, counts.compatibility_peak_facts); + } + } +} + +test "issue 12595 compatibility facts invalidate on mutation but not compression" { + var allocations = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const allocator = allocations.allocator(); + var program = Ast.Program.init(allocator, emptyLiftedProgramForTest(allocator)); + defer program.deinit(); + var solver = try Solver.init(allocator, &program); + defer solver.deinit(); + var counts: WorkCounters = .{}; + solver.counters = &counts; + const types = &program.types; + const left = try types.add(.{ .primitive = .u64 }); + const right = try types.add(.unbound); + const a = try types.add(.{ .list = left }); + const b = try types.add(.{ .list = right }); + try std.testing.expect(try solver.typesCompatible(a, b)); + const pairs = counts.compatibility_pairs; + const before = allocations.alloc_index; + for (0..100) |_| try std.testing.expect(try solver.typesCompatible(b, a)); + try std.testing.expectEqual(before, allocations.alloc_index); + try std.testing.expectEqual(pairs, counts.compatibility_pairs); + + // Allocating fresh nodes and compressing links preserve old meanings. + const alias = try types.add(.{ .link = a }); + const alias2 = try types.add(.{ .link = alias }); + _ = types.rootCompressed(alias2); + try std.testing.expect(try solver.typesCompatible(alias2, b)); + try std.testing.expectEqual(@as(u64, 0), counts.compatibility_invalidations); + + // A previously unconstrained payload can become incompatible. + try types.set(right, .{ .primitive = .str }); + try std.testing.expect(!try solver.typesCompatible(a, b)); + try std.testing.expectEqual(@as(u64, 1), counts.compatibility_invalidations); + try std.testing.expectEqual(@as(usize, 0), solver.compatibility_proven.count()); + try types.set(right, .{ .primitive = .u64 }); + try std.testing.expect(try solver.typesCompatible(a, b)); + const string = try types.add(.{ .primitive = .str }); + types.setLink(right, string); + try std.testing.expect(!try solver.typesCompatible(a, b)); + try std.testing.expectEqual(@as(u64, 2), counts.compatibility_invalidations); +} + +test "issue 12595 failed cyclic compatibility publishes no assumptions" { + const allocator = std.testing.allocator; + var program = Ast.Program.init(allocator, emptyLiftedProgramForTest(allocator)); + defer program.deinit(); + var solver = try Solver.init(allocator, &program); + defer solver.deinit(); + var counts: WorkCounters = .{}; + solver.counters = &counts; + const types = &program.types; + const number = try types.add(.{ .primitive = .u64 }); + const string = try types.add(.{ .primitive = .str }); + const a = try types.add(.unbound); + const b = try types.add(.unbound); + const a_list = try types.add(.{ .list = a }); + const b_list = try types.add(.{ .list = b }); + try types.set(a, .{ .tuple = try types.addSpan(&.{ number, a_list }) }); + try types.set(b, .{ .tuple = try types.addSpan(&.{ string, b_list }) }); + // The LIFO walk cuts the cycle before finding the conflicting sibling. + try std.testing.expect(!try solver.typesCompatible(a, b)); + try std.testing.expectEqual(@as(u64, 1), counts.compatibility_cycles); + try std.testing.expectEqual(@as(usize, 0), solver.compatibility_proven.count()); + try std.testing.expect(!try solver.typesCompatible(a_list, b_list)); + try std.testing.expectEqual(@as(u64, 0), counts.compatibility_hits); +} + +test "issue 12595 compatibility that expands lazy types publishes no facts" { + const allocator = std.testing.allocator; + var lifted = emptyLiftedProgramForTest(allocator); + const mono_number = try lifted.types.add(.{ .primitive = .u64 }); + const mono_list = try lifted.types.add(.{ .list = mono_number }); + var program = Ast.Program.init(allocator, lifted); + defer program.deinit(); + var solver = try Solver.init(allocator, &program); + defer solver.deinit(); + var counts: WorkCounters = .{}; + solver.counters = &counts; + const number = try program.types.add(.{ .primitive = .u64 }); + const materialized = try program.types.add(.{ .list = number }); + const lazy = try solver.lowerTypeFresh(mono_list); + try std.testing.expect(try solver.typesCompatible(lazy, materialized)); + try std.testing.expect(counts.expansions > 0); + try std.testing.expectEqual(@as(usize, 0), solver.compatibility_proven.count()); + try std.testing.expect(try solver.typesCompatible(lazy, materialized)); + try std.testing.expect(solver.compatibility_proven.count() > 0); +} + +test "issue 12595 completed compatibility replaces facts and still propagates erasure" { + const allocator = std.testing.allocator; + var program = Ast.Program.init(allocator, emptyLiftedProgramForTest(allocator)); + defer program.deinit(); + var solver = try Solver.init(allocator, &program); + defer solver.deinit(); + const types = &program.types; + const number = try types.add(.{ .primitive = .u64 }); + // Disjoint queries in one epoch cannot accumulate retained facts. + for (0..100) |_| { + const a = try types.add(.{ .list = number }); + const b = try types.add(.{ .list = number }); + try std.testing.expect(try solver.typesCompatible(a, b)); + try std.testing.expectEqual(@as(usize, 1), solver.compatibility_proven.count()); + } + const captures = try types.addCaptures(&.{.{ .local = @fromBackingInt(0), .symbol = @fromBackingInt(0), .binder = null, .ty = number }}); + const callable = try types.add(.{ .lambda_set = try types.addMembers(&.{.{ .lambda = @fromBackingInt(1), .captures = captures }}) }); + const empty = try types.add(.{ .lambda_set = .empty() }); + const a = try types.add(.{ .func = .{ .args = .empty(), .callable = callable, .ret = number } }); + const b = try types.add(.{ .func = .{ .args = .empty(), .callable = empty, .ret = number } }); + try std.testing.expect(try solver.typesCompatible(a, b)); + try std.testing.expect(try solver.typesCompatible(a, b)); + try solver.markErasedCallablesReachedByType(b); + try std.testing.expect(try solver.typesCompatible(a, b)); + try solver.unify(a, b); + const erased = types.rootContent(callable).erased; + try std.testing.expectEqual(@as(usize, 1), erased.members.count()); + try std.testing.expectEqual(captures, types.memberItem(erased.members, 0).captures); + try std.testing.expectEqual(types.root(callable), types.root(empty)); +} + test "lambda solved traverses deep sequential and nested expressions without native recursion" { const allocator = std.testing.allocator; var lifted = emptyLiftedProgramForTest(allocator); diff --git a/test/cli/issue_12595_lambda_solved/Values.roc b/test/cli/issue_12595_lambda_solved/Values.roc new file mode 100644 index 00000000000..71e0ccc8959 --- /dev/null +++ b/test/cli/issue_12595_lambda_solved/Values.roc @@ -0,0 +1,12 @@ +# A stored capture-bearing closure forces fresh/stored normalization coverage. +Values := [].{ + make : U64 -> (U64 -> U64) + make = |offset| |value| value + offset + + stored = { + dbg "lambda solved stored closure" + make(7) + } +} + +expect Values.stored(3) == 10 diff --git a/test/cli/issue_12595_lambda_solved/main.roc b/test/cli/issue_12595_lambda_solved/main.roc new file mode 100644 index 00000000000..050d0e6c79e --- /dev/null +++ b/test/cli/issue_12595_lambda_solved/main.roc @@ -0,0 +1,38 @@ +import Values + +Payload : { call : (U64 -> U64), offset : U64 } + +Message : [First(Payload), Second(Payload), Third(Payload)] + +run : Message -> U64 +run = |message| { + payload = match message { + First(value) => value + Second(value) => value + Third(value) => value + } + callback = payload.call + callback(payload.offset) +} + +Chain := [Done(U64), Next({} -> Chain)].{ + make : U64 -> Chain + make = |n| if (n == 0) Done(n) else Next(|{}| make(n - 1)) + finish : Chain -> U64 + finish = |chain| match chain { + Done(value) => value + Next(next) => finish(next({})) + } +} + +count : U64, (U64 -> U64) -> U64 +count = |n, callback| if (n == 0) callback(0) else count(n - 1, callback) + +expect run(First({ call: Values.stored, offset: 3 })) == 10 +expect run(Second({ call: Values.make(2), offset: 4 })) == 6 +expect run(Third({ call: |n| n * 2, offset: 5 })) == 10 +expect count(4, Values.stored) == 7 and Chain.finish(Chain.make(4)) == 0 +expect { + callback = Box.unbox(Box.box(Values.make(9))) + callback(1) == 10 +} From 7d3ef01e97f53b6d4eb9a3a06680d457cdc68f61 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 11:50:25 -0400 Subject: [PATCH 19/97] Separate anonymous memory from resident file pages in cache benchmarks --- test/cli/cache_work_bench.py | 56 +++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/test/cli/cache_work_bench.py b/test/cli/cache_work_bench.py index 59fb5b3d244..4633fd4c3b5 100644 --- a/test/cli/cache_work_bench.py +++ b/test/cli/cache_work_bench.py @@ -5,6 +5,11 @@ The URL platform is seeded locally; no download or external project is used. Each measured command is roc build --opt=dev. Logs retain phase timings and cache counters. Optional strace runs are separate from wall-time measurements. +Use --sample-memory in separate profiling runs to sample anonymous versus total +resident memory; its wall times include sampling overhead. RSS includes resident +executable pages and can differ even for byte-identical binaries depending on +file-cache state. Stage comparison binaries identically and inspect anonymous +memory before attributing an RSS difference to compiler allocations. """ import argparse import os @@ -13,9 +18,50 @@ import shutil import statistics import subprocess +import tempfile import time +def run_memory_profile(cmd, env): + """Sample the compiler child of /usr/bin/time; retain its exact max RSS too. + + Component peaks are sampled independently, so they must not be added + together. Short-lived peaks can be missed between samples. + """ + peak_rss = peak_anon = nonanon_at_peak = samples = 0 + with tempfile.TemporaryFile(mode="w+t") as stdout, tempfile.TemporaryFile(mode="w+t") as stderr: + with subprocess.Popen(cmd, env=env, stdout=stdout, stderr=stderr, text=True) as process: + children = Path(f"/proc/{process.pid}/task/{process.pid}/children") + compiler = None + while process.poll() is None: + try: + if compiler is None: + pids = children.read_text().split() + if pids: + compiler = Path(f"/proc/{pids[0]}/smaps_rollup") + if compiler is not None: + fields = dict(re.findall(r"^(\w+):\s+(\d+) kB$", compiler.read_text(), re.MULTILINE)) + rss, anon = int(fields["Rss"]), int(fields["Anonymous"]) + samples += 1 + peak_anon = max(peak_anon, anon) + if rss > peak_rss: + peak_rss, nonanon_at_peak = rss, rss - anon + except (FileNotFoundError, ProcessLookupError): + pass # The child can exit between discovering its PID and reading it. + time.sleep(0.001) + stdout.seek(0) + stderr.seek(0) + result = subprocess.CompletedProcess(cmd, process.returncode, stdout.read(), stderr.read()) + if not samples: + raise RuntimeError("No compiler memory samples collected") + return result, { + "memory_samples": samples, + "sampled_peak_rss_kib": peak_rss, + "sampled_peak_anon_kib": peak_anon, + "nonanon_at_sampled_peak_kib": nonanon_at_peak, + } + + def main(): p = argparse.ArgumentParser(description=__doc__) p.add_argument("roc", type=Path) @@ -26,6 +72,7 @@ def main(): p.add_argument("--functions", type=int, default=4) p.add_argument("--repeats", type=int, default=3) p.add_argument("--trace", action="store_true") + p.add_argument("--sample-memory", action="store_true", help="Profile RSS components separately; wall times include sampling overhead") a = p.parse_args() roc = a.roc.resolve() work = a.work.resolve() @@ -76,7 +123,12 @@ def main(): f.unlink() cmd = [str(roc), "build", "--opt=dev", "--jobs=1", "--timings", str(source / "main.roc"), f"--output={run_dir / 'app'}"] start = time.perf_counter_ns() - r = subprocess.run(["/usr/bin/time", "-f", "%M", "-o", str(run_dir / "rss"), *cmd], env=env, capture_output=True, text=True) + measured_cmd = ["/usr/bin/time", "-f", "%M", "-o", str(run_dir / "rss"), *cmd] + memory = {} + if a.sample_memory: + r, memory = run_memory_profile(measured_cmd, env) + else: + r = subprocess.run(measured_cmd, env=env, capture_output=True, text=True) elapsed = (time.perf_counter_ns() - start) / 1e6 (run_dir / f"{phase}.log").write_text(r.stdout + r.stderr) if r.returncode: @@ -100,6 +152,8 @@ def main(): results.setdefault(phase, []).append((elapsed, rss, publication / 1e6)) files = [f for f in cache.rglob("*") if f.suffix in (".rpi", ".rpa", ".rpm", ".ris")] print(a.label, repeat, phase, f"wall_ms={elapsed:.3f} rss_kib={rss} publication_ms={publication / 1e6:.3f} records={len(files)} bytes={sum(f.stat().st_size for f in files)}", stats, flush=True) + if a.sample_memory: + print(a.label, repeat, phase, "memory_profile", memory, flush=True) if a.trace and repeat == 0: (source / "main.roc").write_text(app.replace("+ 10", "+ 12")) for path, contents in module_sources.items(): From d60c079cf426fde6d577c4e3b392ac34125db2cd Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 11:55:07 -0400 Subject: [PATCH 20/97] Adapt merged cache fixtures and satisfy source checks --- src/cli/pack_store.zig | 18 +++-- src/cli/test/parallel_cli_runner.zig | 2 +- src/compile/artifact_store.zig | 38 ++++++--- src/compile/cache_cleanup.zig | 1 + src/compile/cache_index.zig | 14 ++++ src/compile/cache_maintenance.zig | 24 ++++-- src/compile/cache_manager.zig | 15 +++- src/compile/coordinator.zig | 18 +++-- src/compile/mod.zig | 1 - src/compile/test/cache_test.zig | 7 +- .../test/released_solver_types_test.zig | 79 +++++++++++++++---- 11 files changed, 157 insertions(+), 60 deletions(-) diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index d252bfcc5d4..d98d9a17be3 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -61,7 +61,7 @@ pub const Store = struct { var hash = std.crypto.hash.sha2.Sha256.init(.{}); hash.update(switch (family) { .local_pack, .package_pack, .local_discovery, .package_discovery => self.procedure_root, - else => self.root, + .checked, .canonicalized, .placement, .literals, .pairing, .executable, .test_result, .wasm_host, .glue_plugin, .optimized_pack, .program_output, .scratch => self.root, }); hash.update(&module); hash.final(&result.group); @@ -154,8 +154,10 @@ pub const Store = struct { var arena = base.SingleThreadArena.init(self.allocator); const bytes = artifact.read(arena.allocator()) catch |err| { arena.deinit(); - if (err == error.OutOfMemory) return error.OutOfMemory; - continue; + switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => continue, + } }; packs.bytes_loaded +|= bytes.len; if (files == .discovery) { @@ -718,11 +720,11 @@ pub const LoadedPacks = struct { var arena = base.SingleThreadArena.init(allocator); const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, arena.allocator(), .limited(1024 * 1024 * 1024)) catch |err| { arena.deinit(); - if (err == error.FileNotFound and input == .cache_offers) continue; - return switch (err) { - error.OutOfMemory => error.OutOfMemory, - else => error.PackDirectoryUnreadable, - }; + switch (err) { + error.FileNotFound => if (input == .cache_offers) continue else return error.PackDirectoryUnreadable, + error.OutOfMemory => return error.OutOfMemory, + else => return error.PackDirectoryUnreadable, + } }; self.bytes_loaded +|= bytes.len; switch (files) { diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 4ec6719778e..116a1a6ed55 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -8167,7 +8167,7 @@ const PackStoreFiles = struct { if (entry.state != .published) continue; switch (entry.family) { .local_pack, .package_pack, .optimized_pack => {}, - else => continue, + .checked, .canonicalized, .placement, .literals, .pairing, .executable, .test_result, .wasm_host, .glue_plugin, .local_discovery, .package_discovery, .program_output, .scratch => continue, } const path = try allocator.dupe(u8, &std.fmt.bytesToHex(entry.key, .lower)); result.paths.append(allocator, path) catch |err| { diff --git a/src/compile/artifact_store.zig b/src/compile/artifact_store.zig index f705fbd5437..5ec3e798d91 100644 --- a/src/compile/artifact_store.zig +++ b/src/compile/artifact_store.zig @@ -10,9 +10,13 @@ const Allocator = std.mem.Allocator; const put = catalog.put; const get = catalog.get; +/// Storage-format namespace shared by all compiler compatibility identities. pub const format_directory = "artifacts-v1"; +/// Fixed number of independently coordinated catalogs. pub const shard_count = 16; +/// One minute in the timestamp units used by retention. pub const minute_ns: i64 = 60 * std.time.ns_per_s; +/// One day in the timestamp units used by retention. pub const day_ns: i64 = 24 * 60 * minute_ns; /// Exhaustive retention policy for compiler-owned artifacts. Sources materialized @@ -58,7 +62,7 @@ pub const Family = enum(u8) { pub fn isPack(self: Family) bool { return switch (self) { .local_pack, .package_pack, .optimized_pack, .local_discovery, .package_discovery, .program_output => true, - else => false, + .checked, .canonicalized, .placement, .literals, .pairing, .executable, .test_result, .wasm_host, .glue_plugin, .scratch => false, }; } @@ -67,6 +71,7 @@ pub const Family = enum(u8) { } }; +/// Explicit semantic identity, independent of physical payload placement. pub const Identity = struct { namespace: [32]u8, family: Family, @@ -89,16 +94,20 @@ pub const Identity = struct { } }; +/// Recoverable storage, coordination, and allocation failures. pub const Error = catalog.Error || error{ Busy, Missing, Changed, InvalidArtifact, PathTooLong, OutOfMemory }; +/// Hash explicit identity components with SHA-256. pub fn hashBytes(bytes: []const u8) [32]u8 { var result: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &result, .{}); return result; } +/// Publication and reclamation phases of an owned physical slot. pub const State = enum(u8) { staging, published, retiring }; +/// Persisted ownership, retention, and accounting for one payload generation. pub const Metadata = struct { state: State, family: Family, @@ -161,6 +170,7 @@ pub const Metadata = struct { } }; +/// Shared artifact namespace; operations acquire individual shard ownership. pub const Store = struct { allocator: Allocator, io: Io, @@ -290,12 +300,12 @@ pub const Store = struct { const stem = try slotStem(&stem_buffer, metadata.slot); var lease_buffer: [140]u8 = undefined; const relative_lease = std.fmt.bufPrint(&lease_buffer, "{s}.lease", .{stem}) catch return error.PathTooLong; - const lease = leaseFile(shard.dir, self.io, relative_lease, true, false) catch |err| { - if (err == error.Missing or err == error.Busy) { + const lease = leaseFile(shard.dir, self.io, relative_lease, true, false) catch |err| switch (err) { + error.Missing, error.Busy => { self.allocator.free(path); return null; - } - return err; + }, + else => return err, }; errdefer lease.close(self.io); const file = Dir.cwd().openFile(self.io, path, .{}) catch |err| { @@ -397,12 +407,10 @@ fn leaseFile(dir: Dir, io: Io, path: []const u8, shared: bool, create: bool) Err var status: w.IO_STATUS_BLOCK = undefined; var handle: w.HANDLE = undefined; const result = w.ntdll.NtCreateFile(&handle, .{ .STANDARD = .{ .SYNCHRONIZE = true }, .GENERIC = .{ .READ = true, .WRITE = !shared } }, &.{ .RootDirectory = dir.handle, .ObjectName = @constCast(&w.UNICODE_STRING.init(wide[0..path.len])) }, &status, null, .{ .NORMAL = true }, if (shared) .{ .READ = true } else .{}, if (create) .OPEN_IF else .OPEN, .{ .NON_DIRECTORY_FILE = true, .IO = .SYNCHRONOUS_NONALERT }, null, 0); - return switch (result) { - .SUCCESS => .{ .handle = handle, .flags = .{ .nonblocking = false } }, - .SHARING_VIOLATION => error.Busy, - .OBJECT_NAME_NOT_FOUND, .OBJECT_PATH_NOT_FOUND => error.Missing, - else => error.IoFailure, - }; + if (result == .SUCCESS) return .{ .handle = handle, .flags = .{ .nonblocking = false } }; + if (result == .SHARING_VIOLATION) return error.Busy; + if (result == .OBJECT_NAME_NOT_FOUND or result == .OBJECT_PATH_NOT_FOUND) return error.Missing; + return error.IoFailure; } else { return (if (create) dir.createFile(io, path, .{ .read = true, .truncate = false, .lock = .exclusive, .lock_nonblocking = true }) @@ -415,6 +423,7 @@ fn leaseFile(dir: Dir, io: Io, path: []const u8, shared: bool, create: bool) Err } } +/// A catalog and its held kernel coordination lock. pub const Shard = struct { io: Io, dir: Dir, @@ -498,6 +507,7 @@ pub const Shard = struct { } }; +/// Registered unpublished payload whose exclusive lease pins its slot. pub const Staging = struct { store: *const Store, key: catalog.Key, @@ -549,6 +559,7 @@ pub const Staging = struct { } }; +/// Acquired payload file and shared lease, both owned until deinit. pub const Artifact = struct { store: *const Store, file: Io.File, @@ -729,7 +740,7 @@ fn drainScratch(shard: *Shard, stem: []const u8, generation: u64) Error!bool { error.FileNotFound => {}, else => return error.IoFailure, }; - relative = if (std.mem.lastIndexOfScalar(u8, relative, '/')) |index| relative[0..index] else ""; + relative = if (std.mem.findScalarLast(u8, relative, '/')) |index| relative[0..index] else ""; } } put(u64, &cursor, 8, generation); @@ -743,6 +754,7 @@ fn drainScratch(shard: *Shard, stem: []const u8, generation: u64) Error!bool { return false; } +/// Encode a physical slot in a fixed-depth directory fanout. pub fn slotStem(buffer: *[128]u8, slot: u64) Error![]const u8 { var bytes: [8]u8 = undefined; std.mem.writeInt(u64, &bytes, slot, .big); @@ -751,12 +763,14 @@ pub fn slotStem(buffer: *[128]u8, slot: u64) Error![]const u8 { }) catch return error.PathTooLong; } +/// Order allocation generations lexicographically for resumable traversal. pub fn storageKey(generation: u64) catalog.Key { var key: catalog.Key = @splat(0); std.mem.writeInt(u64, key[0..8], generation, .big); return key; } +/// Advance a fixed-width ordered cursor; false means the key space ended. pub fn advanceKey(key: *catalog.Key) bool { var index: usize = key.len; while (index != 0) { diff --git a/src/compile/cache_cleanup.zig b/src/compile/cache_cleanup.zig index 59d05b532cd..2fd089b8c85 100644 --- a/src/compile/cache_cleanup.zig +++ b/src/compile/cache_cleanup.zig @@ -28,6 +28,7 @@ pub const Demand = union(enum) { var active: std.atomic.Value(?*Session) = .init(null); +/// Command-owned maintenance admission state, registered at coordinator safe points. pub const Session = struct { store: artifacts.Store, limits: Limits, diff --git a/src/compile/cache_index.zig b/src/compile/cache_index.zig index d42dfd43dbf..590f0f7a719 100644 --- a/src/compile/cache_index.zig +++ b/src/compile/cache_index.zig @@ -10,18 +10,26 @@ const std = @import("std"); const Io = std.Io; const Dir = Io.Dir; +/// Bytes per catalog record, including its checksum. pub const record_size = 512; +/// Fixed-width key ordered lexicographically by the crit-bit indexes. pub const Key = [64]u8; +/// Opaque fixed-width payload stored in a leaf. pub const Value = [384]u8; +/// Maximum branch depth for any lookup. pub const key_bits = @bitSizeOf(Key); +/// Maximum number of changed records in a redo transaction. pub const max_changes = 24; +/// Physical record number; zero denotes a missing node. pub const RecordId = u64; +/// Independent indexes sharing one record allocator and transaction. pub const Tree = enum(u2) { artifacts, recency, owners, spare }; const Record = [record_size]u8; const magic = "ROCIDX01"; const transaction_magic = "ROCTXN01"; const redo_size = 24 + record_size + max_changes * (8 + record_size); +/// Storage failures and invalid on-disk catalog data. pub const Error = error{ IoFailure, CorruptCatalog, CapacityExceeded }; /// Observable work, including recovery. Tests assert bounds on actual I/O. @@ -32,6 +40,7 @@ pub const Counters = struct { written_bytes: usize = 0, }; +/// Index roots, allocation state, and store accounting persisted in record zero. pub const Header = struct { roots: [4]RecordId = @splat(0), next: RecordId = 1, @@ -67,6 +76,7 @@ pub const Header = struct { } }; +/// Decoded branch, leaf, or free-list record. pub const Node = struct { pub const Kind = enum(u8) { free, leaf, branch }; kind: Kind = .free, @@ -103,6 +113,7 @@ pub const Node = struct { } }; +/// Open catalog with bounded recovery and positional record access. pub const Index = struct { dir: Dir, io: Io, @@ -192,6 +203,7 @@ pub const Index = struct { } }; +/// A bounded set of node changes committed through one redo record. pub const Transaction = struct { pub const Entry = struct { id: RecordId, key: Key, value: Value }; index: *Index, @@ -439,10 +451,12 @@ fn firstDifference(a: Key, b: Key) u16 { unreachable; // putValue handles equal keys before finding their first differing bit } +/// Encode an integer at a fixed byte offset in little-endian order. pub fn put(comptime T: type, bytes: []u8, offset: usize, value: T) void { std.mem.writeInt(T, bytes[offset..][0..@sizeOf(T)], value, .little); } +/// Decode an integer from a fixed byte offset in little-endian order. pub fn get(comptime T: type, bytes: []const u8, offset: usize) T { return std.mem.readInt(T, bytes[offset..][0..@sizeOf(T)], .little); } diff --git a/src/compile/cache_maintenance.zig b/src/compile/cache_maintenance.zig index 18b71eb589d..0e47678f134 100644 --- a/src/compile/cache_maintenance.zig +++ b/src/compile/cache_maintenance.zig @@ -9,7 +9,9 @@ const Dir = Io.Dir; const put = catalog.put; const get = catalog.get; +/// Current pack format and eventual payload-size target. pub const Limits = struct { object_pack_format_version: u32, max_size_bytes: u64 }; +/// Completed work and actual catalog I/O for one maintenance admission. pub const Stats = struct { units: usize = 0, records: usize = 0, @@ -19,6 +21,7 @@ pub const Stats = struct { io: catalog.Counters = .{}, }; +/// Shared cadence and operation-count budgets for maintenance grants. pub const Policy = struct { pub const interval: i64 = 5 * artifacts.minute_ns; pub const overdue: i64 = artifacts.minute_ns; @@ -27,10 +30,12 @@ pub const Policy = struct { pub const pressure_units: usize = 8; }; +/// Capacity offered by the caller, subject to the shared-root grant policy. pub const Admission = enum { optional, compulsory }; const Phase = enum(u8) { boundaries, age, candidates, evict }; const state_size = 2048; +/// Fixed-size resumable traversal hints, revalidated before reclamation. pub const Checkpoint = struct { due: i64 = 0, next_grant: i64 = 0, @@ -168,10 +173,13 @@ fn step(store: *const artifacts.Store, limits: Limits, now: i64, checkpoint: *Ch if (checkpoint.phase == .evict) return evict(store, limits, now, checkpoint, stats); const number: u4 = @intCast(checkpoint.shard); var shard = artifacts.Shard.open(store, number, false, &stats.io) catch |err| { - if (err != error.Missing and err != error.Busy) stats.errors += 1; - if (err == error.Missing) { - checkpoint.bytes[number] = 0; - checkpoint.boundaries[number] = 0; + switch (err) { + error.Missing => { + checkpoint.bytes[number] = 0; + checkpoint.boundaries[number] = 0; + }, + error.Busy => {}, + else => stats.errors += 1, } checkpoint.candidates[number] = null; checkpoint.nextShard(switch (checkpoint.phase) { @@ -256,7 +264,11 @@ fn evict(store: *const artifacts.Store, limits: Limits, now: i64, checkpoint: *C }; var shard = artifacts.Shard.open(store, number, false, &stats.io) catch |err| { checkpoint.candidates[number] = null; - if (err == error.Missing) checkpoint.bytes[number] = 0 else if (err != error.Busy) stats.errors += 1; + switch (err) { + error.Missing => checkpoint.bytes[number] = 0, + error.Busy => {}, + else => stats.errors += 1, + } return; }; defer shard.close(); @@ -297,7 +309,7 @@ const Fixture = struct { tmp: std.testing.TmpDir, store: artifacts.Store, - fn init() !Fixture { + fn init() (std.Io.Dir.RealPathFileAllocError || artifacts.Error)!Fixture { var tmp = std.testing.tmpDir(.{}); errdefer tmp.cleanup(); const root = try tmp.dir.realPathFileAlloc(std.testing.io, ".", std.testing.allocator); diff --git a/src/compile/cache_manager.zig b/src/compile/cache_manager.zig index 8483b43a4bd..e0de6ca977e 100644 --- a/src/compile/cache_manager.zig +++ b/src/compile/cache_manager.zig @@ -161,7 +161,10 @@ pub const CacheManager = struct { }; defer store.deinit(); var stage = store.reserve(identity(family, key, scope), size, store.now()) catch |err| { - if (err == error.Busy) return; + switch (err) { + error.Busy => return, + else => {}, + } self.verboseLog(allocator, "Failed to reserve {s} cache entry for {s}: {}\n", .{ @tagName(kind), name, err }); self.recordStoreFailureFor(kind); return; @@ -177,7 +180,10 @@ pub const CacheManager = struct { return; }; stage.publish() catch |err| { - if (err == error.Busy) return; + switch (err) { + error.Busy => return, + else => {}, + } self.verboseLog(allocator, "Failed to publish {s} cache entry for {s}: {}\n", .{ @tagName(kind), name, err }); self.recordStoreFailureFor(kind); return; @@ -206,7 +212,10 @@ pub const CacheManager = struct { var stage = store.reserve(identity(.checked, key, scope), size, store.now()) catch |err| { store.deinit(); self.allocator.destroy(store); - if (err != error.Busy) self.recordStoreFailure(); + switch (err) { + error.Busy => {}, + else => self.recordStoreFailure(), + } return null; }; self.roc_ctx.writeFileStream(stage.path, stream) catch |err| { diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index ca7994a0d20..e838d31beea 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -99,8 +99,8 @@ const CheckedModuleCacheRunError = CoordinatorError || eval.BuiltinModules.InitE LowLevelOperationsNotFound, TestUnexpectedResult, }; -const OverwriteFilesUnderDirError = @import("artifact_store.zig").Error || Allocator.Error || std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || std.Io.Dir.WriteFileError; -const CorruptCheckedModuleCacheError = @import("artifact_store.zig").Error || Allocator.Error || std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || std.Io.Dir.ReadFileAllocError || std.Io.Dir.WriteFileError || error{FileNotFound}; +const OverwriteFilesUnderDirError = @import("artifact_store.zig").Error || Allocator.Error || std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || CoreCtx.WriteError; +const CorruptCheckedModuleCacheError = @import("artifact_store.zig").Error || Allocator.Error || std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || CoreCtx.ReadError || CoreCtx.WriteError || error{FileNotFound}; const TypeCheckedResult = messages.TypeCheckedResult; const DiscoveredLocalImport = messages.DiscoveredLocalImport; const DiscoveredExternalImport = messages.DiscoveredExternalImport; @@ -9966,7 +9966,8 @@ fn overwriteFilesUnderDir(allocator: Allocator, root: []const u8, scope: []const for (paths) |path| allocator.free(path); allocator.free(paths); } - for (paths) |path| try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = path, .data = contents }); + const filesystem = CoreCtx.os(allocator, allocator, std.testing.io); + for (paths) |path| try filesystem.writeFile(path, contents); return paths.len; } @@ -9978,11 +9979,12 @@ fn truncateFilesUnderDir(allocator: Allocator, root: []const u8, scope: []const allocator.free(paths); } var truncated: usize = 0; + const filesystem = CoreCtx.os(allocator, allocator, std.testing.io); for (paths) |path| { - const contents = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, path, allocator, .unlimited); + const contents = try filesystem.readFile(path, allocator); defer allocator.free(contents); if (contents.len == 0) continue; - try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = path, .data = contents[0 .. contents.len - 1] }); + try filesystem.writeFile(path, contents[0 .. contents.len - 1]); truncated += 1; } return truncated; @@ -9998,7 +10000,7 @@ fn corruptCheckedModuleEnvIdentBytesLens(allocator: Allocator, root: []const u8, @offsetOf(collections.SafeList(u8).Serialized, "len"); const io = std.testing.io; - const dir = std.Io.Dir.cwd(); + const filesystem = CoreCtx.os(allocator, allocator, io); const paths = try testCachePaths(allocator, root, checked_module_cache_dir, .checked); defer { for (paths) |path| allocator.free(path); @@ -10006,12 +10008,12 @@ fn corruptCheckedModuleEnvIdentBytesLens(allocator: Allocator, root: []const u8, } var corrupted: usize = 0; for (paths) |path| { - const bytes = try dir.readFileAlloc(io, path, allocator, .limited(@import("cache_config.zig").Constants.MAX_CACHE_SIZE)); + const bytes = try filesystem.readFile(path, allocator); defer allocator.free(bytes); if (bytes.len < env_ident_bytes_len_offset + @sizeOf(u64)) continue; std.mem.writeInt(u64, bytes[env_ident_bytes_len_offset..][0..8], std.math.maxInt(u64), .little); - try dir.writeFile(io, .{ .sub_path = path, .data = bytes }); + try filesystem.writeFile(path, bytes); corrupted += 1; } diff --git a/src/compile/mod.zig b/src/compile/mod.zig index fff9d750a4d..fa7db56d6a9 100644 --- a/src/compile/mod.zig +++ b/src/compile/mod.zig @@ -1,7 +1,6 @@ //! Compilation-related types and functionality, such as cache management and package building. const std = @import("std"); -const Allocator = std.mem.Allocator; const threading_mod = @import("threading.zig"); pub const package = @import("compile_package.zig"); diff --git a/src/compile/test/cache_test.zig b/src/compile/test/cache_test.zig index 30e0ceeeab1..75c2f205b90 100644 --- a/src/compile/test/cache_test.zig +++ b/src/compile/test/cache_test.zig @@ -172,9 +172,6 @@ test "issue 11961: streamed cache publication preserves the old entry on write f } try writer.writeAll("entry"); } - fn rejectRename(_: ?*anyopaque, _: std.Io, _: []const u8, _: []const u8) CoreCtx.RenameError!void { - return error.AccessDenied; - } }; const gpa = testing.allocator; const io = testing.io; @@ -209,14 +206,14 @@ const FailingCacheFilesystem = struct { base: CoreCtx, stage: enum { directory, write, publish }, - fn writeFile(ctx: ?*anyopaque, io: std.Io, path: []const u8, bytes: []const u8) CoreCtx.WriteError!void { + fn writeFile(ctx: ?*anyopaque, _: std.Io, path: []const u8, bytes: []const u8) CoreCtx.WriteError!void { const self: *@This() = @ptrCast(@alignCast(ctx.?)); if (self.stage == .write) return error.FileTooBig; try self.base.writeFile(path, bytes); if (self.stage == .publish) { const blocked = std.fmt.allocPrint(self.capture.allocator, "{s}.data", .{path[0 .. path.len - ".pending".len]}) catch return error.IoError; defer self.capture.allocator.free(blocked); - std.Io.Dir.cwd().createDirPath(io, blocked) catch return error.IoError; + self.base.makePath(blocked) catch return error.IoError; } } diff --git a/src/compile/test/released_solver_types_test.zig b/src/compile/test/released_solver_types_test.zig index 69fa2b0f96b..8a4db0c00d8 100644 --- a/src/compile/test/released_solver_types_test.zig +++ b/src/compile/test/released_solver_types_test.zig @@ -9,6 +9,7 @@ const collections = @import("collections"); const eval = @import("eval"); const roc_target = @import("roc_target"); +const artifacts = @import("../artifact_store.zig"); const CacheManager = @import("../cache_manager.zig").CacheManager; const Coordinator = @import("../coordinator.zig").Coordinator; const CoordinatorError = @import("../coordinator.zig").CoordinatorError; @@ -41,14 +42,21 @@ const CompileResult = struct { const StagingObserver = struct { filesystem: CoreCtx, - greeting_path: ?[]const u8 = null, + store: ?*artifacts.Store = null, + greeting: ?*ModuleState = null, + key: artifacts.catalog.Key = @splat(0), attempts: usize = 0, fail: bool, fn write(raw: ?*anyopaque, _: std.Io, path: []const u8, stream: CoreCtx.FileStream) CoreCtx.WriteError!void { const self: *@This() = @ptrCast(@alignCast(raw.?)); - if (self.greeting_path) |prefix| { - if (std.mem.startsWith(u8, path, prefix) and std.mem.endsWith(u8, path, ".part.tmp")) { + if (self.greeting) |mod| { + // The environment is streamed before its solver types are released; + // the final entry is streamed afterward. Identify its reservation + // through the catalog, independently of physical slot assignment. + if (!mod.solver_types_released and + (registeredPending(self.store.?, path, self.key) catch return error.IoError)) + { self.attempts += 1; if (self.fail) { // Leave a partial file, just as an interrupted disk write can. @@ -61,7 +69,7 @@ const StagingObserver = struct { } }; -const CompileError = eval.BuiltinModules.InitError || std.Thread.SpawnError || Coordinator.AppDiscoveryError || CoordinatorError || error{ +const CompileError = artifacts.Error || eval.BuiltinModules.InitError || std.Thread.SpawnError || Coordinator.AppDiscoveryError || CoordinatorError || error{ MissingGreetingModule, NoHomeDirectory, WriteFailed, @@ -118,9 +126,12 @@ fn compileReleasingSolverTypes( const key = mod.checkedArtifact().?.key.bytes; const entries_dir = try cache_manager.config.getCheckedArtifactCacheDir(gpa); defer gpa.free(entries_dir); - const entry_path = try cache_manager.computeCacheFilePath(key, entries_dir); - defer gpa.free(entry_path); - observer.greeting_path = entry_path; + var store = try cache_manager.openStore(gpa); + defer store.deinit(); + const identity = CacheManager.identity(.checked, key, entries_dir); + observer.store = &store; + observer.greeting = mod; + observer.key = identity.indexedKey(); const awaited_evaluation = mod.pending_evaluation != null; try coord.finishCheckedProgram(.none); @@ -149,13 +160,18 @@ fn compileReleasingSolverTypes( if (value == .str and std.mem.eql(u8, artifact.const_store.strBytes(value.str), "Hello, World!")) value_completed = true; } } + const published = if (cache_dir != null) try store.acquire(identity, store.now()) else null; + defer if (published) |value| { + var acquired = value; + acquired.deinit(); + }; return .{ .reports = try gpa.dupe(u8, rendered.written()), .greeting_key = key, .greeting_cached = artifact.serialized_backing != null, .greeting_checked = mod.type_check_ns != 0, .greeting_released = mod.solver_types_released, - .greeting_published = cache_dir != null and roc_ctx.fileExists(entry_path), + .greeting_published = published != null, .greeting_staged = observer.attempts, .greeting_awaited_evaluation = awaited_evaluation, .greeting_value_completed = value_completed, @@ -169,15 +185,46 @@ fn compileReleasingSolverTypes( }; } -/// Errors of walking a directory tree. -const WalkError = std.mem.Allocator.Error || @typeInfo(@typeInfo(@TypeOf(std.Io.Dir.Walker.next)).@"fn".return_type.?).error_union.error_set; +/// Test-only inventory: every unfinished payload must have a registered owner. +fn registeredPending(store: *const artifacts.Store, path: []const u8, key: ?artifacts.catalog.Key) artifacts.Error!bool { + for (0..artifacts.shard_count) |number| { + var counters = artifacts.catalog.Counters{}; + var shard = artifacts.Shard.open(store, @intCast(number), false, &counters) catch |err| switch (err) { + error.Missing => continue, + else => return err, + }; + defer shard.close(); + var tx = shard.index.transaction(); + var cursor: artifacts.catalog.Key = @splat(0); + while (try tx.lowerBound(.owners, cursor)) |entry| { + const metadata = try artifacts.Metadata.decode(entry.value); + if (key == null or std.mem.eql(u8, &metadata.key, &key.?)) { + const pending = try store.payloadPath(@intCast(number), metadata.slot, ".pending"); + defer store.allocator.free(pending); + if (std.mem.eql(u8, path, pending)) return true; + } + cursor = entry.key; + if (!artifacts.advanceKey(&cursor)) break; + } + } + return false; +} -fn countStagingFiles(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) WalkError!usize { +/// Aborted writes may remain for bounded maintenance, but cannot be unregistered. +fn countUnregisteredStagingFiles(gpa: std.mem.Allocator, io: std.Io, dir: std.Io.Dir) (artifacts.Error || std.Io.Dir.RealPathFileAllocError || std.Io.Dir.SelectiveWalker.Error)!usize { + const root = try dir.realPathFileAlloc(io, ".", gpa); + defer gpa.free(root); + var store = try artifacts.Store.init(gpa, io, root); + defer store.deinit(); var walker = try dir.walk(gpa); defer walker.deinit(); var count: usize = 0; while (try walker.next(io)) |entry| { - if (entry.kind == .file and std.mem.endsWith(u8, entry.basename, ".tmp")) count += 1; + if (entry.kind == .file and std.mem.endsWith(u8, entry.basename, ".pending")) { + const path = try std.fs.path.join(gpa, &.{ root, entry.path }); + defer gpa.free(path); + if (!try registeredPending(&store, path, null)) count += 1; + } } return count; } @@ -267,7 +314,7 @@ test "checked cache entries published after releasing solver types load on the n var cache = try tmp_dir.dir.openDir(io, "cache", .{ .iterate = true }); defer cache.close(io); - try std.testing.expectEqual(@as(usize, 0), try countStagingFiles(gpa, io, cache)); + try std.testing.expectEqual(@as(usize, 0), try countUnregisteredStagingFiles(gpa, io, cache)); const second = try compileReleasingSolverTypes(gpa, cache_dir, app_path, false); defer second.deinit(gpa); @@ -277,7 +324,7 @@ test "checked cache entries published after releasing solver types load on the n } try std.testing.expect(!second.has_user_errors); try std.testing.expectEqual(@as(u64, 0), second.store_failures); - try std.testing.expectEqual(@as(usize, 0), try countStagingFiles(gpa, io, cache)); + try std.testing.expectEqual(@as(usize, 0), try countUnregisteredStagingFiles(gpa, io, cache)); } const warning_source = @@ -392,7 +439,7 @@ test "issue 12570: later evaluation diagnostics discard the staged warning-beari try std.testing.expectEqual(@as(usize, 1), run.greeting_staged); try std.testing.expect(!run.greeting_published); try std.testing.expect(std.mem.find(u8, run.reports, "late evaluation failure") != null); - try std.testing.expectEqual(@as(usize, 0), try countStagingFiles(gpa, io, cache)); + try std.testing.expectEqual(@as(usize, 0), try countUnregisteredStagingFiles(gpa, io, cache)); } } @@ -417,7 +464,7 @@ test "issue 12570: staging write failure releases solver types without publishin try std.testing.expectEqual(@as(u64, 1), failed.store_failures); var cache = try tmp.dir.openDir(io, "cache", .{ .iterate = true }); defer cache.close(io); - try std.testing.expectEqual(@as(usize, 0), try countStagingFiles(gpa, io, cache)); + try std.testing.expectEqual(@as(usize, 0), try countUnregisteredStagingFiles(gpa, io, cache)); const retry = try compileReleasingSolverTypes(gpa, cache_dir, app_path, false); defer retry.deinit(gpa); try std.testing.expect(retry.greeting_checked); From 8454772daa2342f2b7b14a81089596929dbbdb59 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 13:07:06 -0400 Subject: [PATCH 21/97] Reuse importer inference across unchanged checking dependencies --- design.md | 74 +++++ src/canonicalize/ModuleEnv.zig | 82 +++++ src/check/Check.zig | 124 ++++++-- src/check/callable_checking.zig | 105 +++++++ src/check/checked_artifact.zig | 37 +++ src/check/checking_dependencies.zig | 172 ++++++++++ src/check/checking_interface.zig | 453 +++++++++++++++++++++++++++ src/check/inference_output.zig | 96 ++++++ src/check/mod.zig | 8 + src/check/test/hoist_roots_test.zig | 28 ++ src/check/test/issue_11214_test.zig | 4 +- src/cli/test/parallel_cli_runner.zig | 50 +++ src/compile/cache_cleanup.zig | 1 + src/compile/cache_config.zig | 34 +- src/compile/cache_versioning.zig | 12 +- src/compile/compile_build.zig | 6 + src/compile/compile_package.zig | 25 +- src/compile/coordinator.zig | 303 +++++++++++++++++- src/compile/inference_cache.zig | 145 +++++++++ src/compile/messages.zig | 3 + src/compile/mod.zig | 1 + 21 files changed, 1725 insertions(+), 38 deletions(-) create mode 100644 src/check/callable_checking.zig create mode 100644 src/check/checking_dependencies.zig create mode 100644 src/check/checking_interface.zig create mode 100644 src/check/inference_output.zig create mode 100644 src/compile/inference_cache.zig diff --git a/design.md b/design.md index 59231fa5bd6..89ec1ce3bf9 100644 --- a/design.md +++ b/design.md @@ -4967,6 +4967,80 @@ data emitter. The checked cache must not store native pointer-width padding, static refcount words, allocation headers, or any other runtime `RocStr` layout bytes. +### Checking dependencies and inference reuse + +A checking dependency is distinct from a checked module's implementation +identity. The latter continues to include the complete source and imported +implementations. It names evaluated constants, procedure templates, lowering +inputs and test results. Equality of checking dependencies authorizes reuse of +inference only; it never authorizes reuse of those implementation results. + +Checking publishes its import-facing inputs while the solved source store is +still available. These inputs include exposed names and their value/type +classification, declaration and method bindings, complete copied schemes +(including descriptor flags and generated-codec requirements), nominal backing +templates, declaration annotations and their argument-position semantics, +generated-method and hosted-declaration classifications, invoking-callable +facts, diagnostic hosted origins, and the facts that govern stored-constant +eligibility. The ordered import, type-owner and method-lookup scope is part of +the dependency. An implementation edit can change one of these facts without +changing a printed type signature. + +Module ownership is a parameter of reusable inference. A checking dependency +records the ordered owner bindings and their equality relationships, separately +from the current owners' deep content hashes. Two distinct current owners never +become one owner merely because they publish equal checking interfaces. +Recursive type graphs retain identity sharing and cycles. Import cycles remain +the explicitly rejected edges described under Module Completion Boundary. + +The inference-result cache stores the solved source environment together with +the checker-selected publication inputs, before evaluation. Its key includes +the module's own source inputs, validation and resolution context, and the +checking dependencies it consumed. Serialized references retain their explicit +owner and declaration bindings; admission must establish those bindings against +the current dependency publications. Equality of a signature, a name or an +incidental node index is never a license to substitute a declaration. Source +regions that are retained as checking input participate in admission as well. + +The first format seals the producer's declaration-reference layout as part of +its interface: every reachable source declaration/binding/annotation index is +encoded with the facts it names. A changed layout misses, even if its printed +types are identical. Admission therefore proves the retained foreign indices +still name the same published checking facts; it never searches for a matching +name or guesses an index correspondence. Only the dense module-identity table +is rebound, using the ordered owner parameters and their equality partition. +Source indices and solved graphs are not rewritten. The import-facing type +projection uses the ordinary import graph copier, preserving sharing, cycles, +descriptor flags, nominal templates and dispatch metadata; names are encoded +as text and owner identities as parameters. Section lengths, source ordering +and sorted exposures make the encoding independent of hash-map iteration. + +A hit binds the inference result to current owners and publishes a new checked +artifact against current imported artifacts. Publication consumes the stored +root selections and scheme evidence; it neither reruns +inference nor reconstructs these facts. No finalized artifact with old imported +keys is installed under a new key. Evaluation and ordinary checked-artifact +caching then use the new full implementation identity. + +Inference cache entries have their own versioned content-addressed admission +contract in the existing cache infrastructure. Missing, incompatible or corrupt +entries are cache misses. Dependency diagnostics are produced by each current +dependency's ordinary compilation. An inference result with checking reports +is not admitted unless those reports and their current provenance are explicitly +part of the stored result. The first format stores only diagnostic-free +inference, and always reports evaluation diagnostics from current publication. +Work counters distinguish inference, interface +publication, reference binding and artifact publication, so interface creation +cannot hide a second checking pass. + +Stored-constant eligibility of a foreign callable is a producer fact. Checking +scans each module-global definition's own body once, records the exact local +callee edges and current imported eligibility facts, and propagates rejection +backwards through those edges to a fixed point. A recursive component with no +rejected body or outgoing dependency is eligible. Importers consume the +published result by definition identity; they never reopen the foreign body or +interpret a foreign import index in their own import environment. + ### Cache Admission Versioning A serialization root is a `Serialized` type whose bytes are admitted from outside diff --git a/src/canonicalize/ModuleEnv.zig b/src/canonicalize/ModuleEnv.zig index ea46fc2ef8e..78a49a7bfe1 100644 --- a/src/canonicalize/ModuleEnv.zig +++ b/src/canonicalize/ModuleEnv.zig @@ -888,6 +888,49 @@ pub const BindingScheme = extern struct { pub const SafeList = collections.SafeList(@This()); }; +/// Import-facing eligibility of a module-global callable for stored constants. +/// Checking publishes one row per global definition, ordered by definition id. +/// The body and its transitive callee dependencies are owned by that checker. +pub const CallableCheckingFact = extern struct { + def: CIR.Def.Idx, + allows_stored_const: u32, + + pub const SafeList = collections.SafeList(@This()); +}; + +/// Checking's import-facing identity and the current content identities that +/// bind its owner parameters. The digest never substitutes for an implementation +/// identity. Canonicalized environments have an unpublished dependency. +pub const CheckingDependency = struct { + digest: [32]u8 = @splat(0), + owners: collections.SafeList(base.ModuleIdentity.Hash) = .{}, + published: bool = false, + + pub fn deinit(self: *CheckingDependency, allocator: Allocator) void { + self.owners.deinit(allocator); + } + + pub const Serialized = extern struct { + digest: [32]u8, + owners: collections.SafeList(base.ModuleIdentity.Hash).Serialized, + published: u64, + + pub fn serialize(self: *@This(), value: *const CheckingDependency, allocator: Allocator, writer: *CompactWriter) Allocator.Error!void { + self.digest = value.digest; + self.published = @intFromBool(value.published); + try self.owners.serialize(&value.owners, allocator, writer); + } + + pub fn deserializeInto(self: *const @This(), address: usize) CheckingDependency { + return .{ .digest = self.digest, .owners = self.owners.deserializeInto(address), .published = self.published != 0 }; + } + + pub fn deserializeWithCopy(self: *const @This(), address: usize, allocator: Allocator) Allocator.Error!CheckingDependency { + return .{ .digest = self.digest, .owners = try self.owners.deserializeWithCopy(address, allocator), .published = self.published != 0 }; + } + }; +}; + /// One generated-codec dispatch relation that remains part of a binding's /// scheme across checked-module boundaries. `scheme_root` preserves alias /// identity when a cached checked environment is rechecked, and @@ -1026,6 +1069,10 @@ top_level_demand_dependencies_ready: bool, /// Demand"): functions whose bodies can, and definitions holding values. /// Checking produces it with the strict-demand relation; importers read it. invoking_defs: collections.SafeList(CIR.Def.Idx), +/// Exact stored-constant eligibility of each global callable, published after +/// checking settles its body. Importers consume this instead of scanning CIR. +callable_checking_facts: CallableCheckingFact.SafeList, +checking_dependency: CheckingDependency, /// True only after `check.TypedCIR.prepareRuntimeEnv` has prepared this env for /// checked-artifact consumption. Serialized user modules intentionally do not @@ -1451,6 +1498,8 @@ pub fn initCIRFields(self: *Self, module_name: []const u8) Allocator.Error!void self.top_level_demand_dependencies = .{}; self.top_level_demand_dependencies_ready = false; self.invoking_defs = .{}; + self.callable_checking_facts = .{}; + self.checking_dependency = .{}; self.runtime_prepared = false; } @@ -1502,6 +1551,8 @@ pub fn init(gpa: std.mem.Allocator, source: []const u8) std.mem.Allocator.Error! .top_level_demand_dependencies = .{}, .top_level_demand_dependencies_ready = false, .invoking_defs = .{}, + .callable_checking_facts = .{}, + .checking_dependency = .{}, .runtime_prepared = false, .idents = idents, .import_mapping = types_mod.import_mapping.ImportMapping.init(gpa), @@ -1565,6 +1616,8 @@ pub fn deinit(self: *Self) void { self.inspect_override_instances.deinit(self.gpa); self.top_level_demand_dependencies.deinit(self.gpa); self.invoking_defs.deinit(self.gpa); + self.callable_checking_facts.deinit(self.gpa); + self.checking_dependency.deinit(self.gpa); // diagnostics are stored in the NodeStore, no need to free separately self.store.deinit(); } @@ -1611,6 +1664,25 @@ pub fn defInvokes(self: *const Self, def_idx: CIR.Def.Idx) bool { return false; } +/// Import-facing stored-constant eligibility. Every global definition has a +/// producer row, including rejected definitions; absence is a compiler bug. +pub fn callableAllowsStoredConst(self: *const Self, def_idx: CIR.Def.Idx) bool { + const facts = self.callable_checking_facts.items.items; + var low: usize = 0; + var high = facts.len; + while (low < high) { + const mid = low + (high - low) / 2; + if (@backingInt(facts[mid].def) < @backingInt(def_idx)) { + low = mid + 1; + } else if (facts[mid].def != def_idx) { + high = mid; + } else { + return facts[mid].allows_stored_const != 0; + } + } + base.invariant("checked global callable has no producer-owned stored-constant eligibility", .{}); +} + /// Return the producer-authored low-level implementation for `def_idx`. pub fn providedLowLevelForDef(self: *const Self, def_idx: CIR.Def.Idx) ?base.LowLevel { const entries = self.provided_low_level_defs.items.items; @@ -3961,6 +4033,8 @@ pub const Serialized = extern struct { store: NodeStore.Serialized, top_level_demand_dependencies: DependencyGraph.Dependency.SafeList.Serialized, invoking_defs: collections.SafeList(CIR.Def.Idx).Serialized, + callable_checking_facts: CallableCheckingFact.SafeList.Serialized, + checking_dependency: CheckingDependency.Serialized, top_level_demand_dependencies_ready: bool, runtime_prepared: bool, runtime_prepared_padding: [6]u8, @@ -4064,6 +4138,8 @@ pub const Serialized = extern struct { writer, ); try self.invoking_defs.serialize(&env.invoking_defs, allocator, writer); + try self.callable_checking_facts.serialize(&env.callable_checking_facts, allocator, writer); + try self.checking_dependency.serialize(&env.checking_dependency, allocator, writer); // Set gpa and module_name to zeros; // these are runtime-only and will be set during deserialization. @@ -4163,6 +4239,8 @@ pub const Serialized = extern struct { .top_level_demand_dependencies = self.top_level_demand_dependencies.deserializeInto(base_addr), .top_level_demand_dependencies_ready = self.top_level_demand_dependencies_ready, .invoking_defs = self.invoking_defs.deserializeInto(base_addr), + .callable_checking_facts = self.callable_checking_facts.deserializeInto(base_addr), + .checking_dependency = self.checking_dependency.deserializeInto(base_addr), .runtime_prepared = self.runtime_prepared, .idents = self.idents, .import_mapping = types_mod.import_mapping.ImportMapping.init(gpa), @@ -4246,6 +4324,8 @@ pub const Serialized = extern struct { .top_level_demand_dependencies = self.top_level_demand_dependencies.deserializeInto(base_addr), .top_level_demand_dependencies_ready = self.top_level_demand_dependencies_ready, .invoking_defs = self.invoking_defs.deserializeInto(base_addr), + .callable_checking_facts = self.callable_checking_facts.deserializeInto(base_addr), + .checking_dependency = self.checking_dependency.deserializeInto(base_addr), .runtime_prepared = self.runtime_prepared, .idents = self.idents, .import_mapping = types_mod.import_mapping.ImportMapping.init(gpa), @@ -4341,6 +4421,8 @@ pub const Serialized = extern struct { .top_level_demand_dependencies = try self.top_level_demand_dependencies.deserializeWithCopy(base_addr, gpa), .top_level_demand_dependencies_ready = self.top_level_demand_dependencies_ready, .invoking_defs = try self.invoking_defs.deserializeWithCopy(base_addr, gpa), + .callable_checking_facts = try self.callable_checking_facts.deserializeWithCopy(base_addr, gpa), + .checking_dependency = try self.checking_dependency.deserializeWithCopy(base_addr, gpa), .runtime_prepared = self.runtime_prepared, .idents = self.idents, .import_mapping = types_mod.import_mapping.ImportMapping.init(gpa), diff --git a/src/check/Check.zig b/src/check/Check.zig index 5e0b0726691..71505895d79 100644 --- a/src/check/Check.zig +++ b/src/check/Check.zig @@ -12833,6 +12833,11 @@ fn checkFileInternal(self: *Self, skip_numeric_defaults: bool) std.mem.Allocator try self.finishDispatchReplayFreezes(); try self.withdrawSupersededLiteralDefaultWarnings(); + try self.publishCallableCheckingFacts(); + var checking_scope = try checking_interface.makeScope(self.gpa, self.cir, self.builtin_ctx.builtin_module, self.imported_modules, self.owner_modules); + defer checking_scope.deinit(); + try checking_interface.publish(self.cir, &checking_scope, defReference, checkingBindingOwner); + self.debugAssertNominalDeclTableComplete(); self.debugAssertFramesBalanced(); } @@ -13880,6 +13885,53 @@ const HoistedCallableDef = struct { def: CIR.Def.Idx, }; +const callable_checking = @import("callable_checking.zig"); + +/// The declaration/body producer establishes foreign-callable eligibility. +/// Only local callee edges enter the fixed point; imported callees already +/// have completed facts. The scan does no inference or type-graph mutation. +fn publishCallableCheckingFacts(self: *Self) Allocator.Error!void { + const defs = self.cir.store.sliceDefs(self.cir.global_value_defs); + var graph = try callable_checking.Graph.init(self.gpa, defs.len); + defer graph.deinit(); + var ids = collections.DenseMap(CIR.Def.Idx, callable_checking.Id).init(self.gpa); + defer ids.deinit(); + for (defs, 0..) |def, i| try ids.put(def, @fromBackingInt(@as(u32, @intCast(i)))); + + var context = HoistedDependencyContext{}; + defer context.deinit(self.gpa); + var scratch = StoredConstScan.Eval.Scratch{}; + defer scratch.deinit(self.gpa); + for (defs, 0..) |def, i| { + context.clearRetainingCapacity(); + const caller: callable_checking.Id = @fromBackingInt(@as(u32, @intCast(i))); + var scan = StoredConstScan{ + .check = self, + .context = &context, + .publication = .{ .graph = &graph, .ids = &ids, .caller = caller }, + }; + if (!try StoredConstScan.Eval.runWith(self.gpa, &scratch, &scan, .{ .expr = .{ + .module = self.cir, + .expr = self.cir.store.getDef(def).expr, + } })) graph.reject(caller); + } + graph.finish(); + + var facts = try ModuleEnv.CallableCheckingFact.SafeList.initCapacity(self.cir.gpa, defs.len); + errdefer facts.deinit(self.cir.gpa); + for (defs, 0..) |def, i| _ = try facts.append(self.cir.gpa, .{ + .def = def, + .allows_stored_const = @intFromBool(graph.allowsStoredConst(@fromBackingInt(@as(u32, @intCast(i))))), + }); + std.mem.sort(ModuleEnv.CallableCheckingFact, facts.items.items, {}, struct { + fn lessThan(_: void, a: ModuleEnv.CallableCheckingFact, b: ModuleEnv.CallableCheckingFact) bool { + return @backingInt(a.def) < @backingInt(b.def); + } + }.lessThan); + self.cir.callable_checking_facts.deinit(self.cir.gpa); + self.cir.callable_checking_facts = facts; +} + fn hoistedRootCalleeAllowsStoredConst( self: *Self, callee: CIR.Expr.Idx, @@ -13896,6 +13948,11 @@ fn hoistedRootCalleeAllowsStoredConst( const StoredConstScan = struct { check: *Self, context: *HoistedDependencyContext, + publication: ?struct { + graph: *callable_checking.Graph, + ids: *const collections.DenseMap(CIR.Def.Idx, callable_checking.Id), + caller: callable_checking.Id, + } = null, const Leaf = union(enum) { expr: struct { module: *const ModuleEnv, expr: CIR.Expr.Idx }, @@ -13929,7 +13986,7 @@ const StoredConstScan = struct { .callee => |callee| { if (self.hoistedPromotedLocalProcedureForExpr(callee.module, callee.callee)) |lambda| { try items.add(.{ .local_procedure = lambda }); - } else if (self.hoistedCallableDefForExpr(callee.module, callee.callee)) |callable_def| { + } else if (try self.hoistedCallableDefForExpr(callee.module, callee.callee)) |callable_def| { try items.add(.{ .callable_def = callable_def }); } else return .{ .value = true }; return .{ .group = .all }; @@ -13941,6 +13998,15 @@ const StoredConstScan = struct { return .{ .group = .all }; }, .callable_def => |callable_def| { + if (callable_def.module != self.cir) { + return .{ .value = callable_def.module.callableAllowsStoredConst(callable_def.def) }; + } + if (scan.publication) |publication| { + const callee = publication.ids.get(callable_def.def) orelse + base.invariant("a callable dependency resolves to a published global definition", .{}); + try publication.graph.addDependency(publication.caller, callee); + return .{ .value = true }; + } const key = HoistedCallableKey{ .module_addr = @intFromPtr(callable_def.module), .def = callable_def.def, @@ -13995,7 +14061,10 @@ const StoredConstScan = struct { fn enterExpr(scan: *StoredConstScan, items: Eval.Items, module: *const ModuleEnv, expr: CIR.Expr.Idx) Allocator.Error!Eval.Expansion { const self = scan.check; - if (self.moduleHoistExprInvalidated(module, expr)) return .{ .value = false }; + // A foreign body is consumed after checking has replaced rejected code. + // Its published fact observes that final body, not transient local + // hoist-selection marks that only this check's own roots consume. + if (scan.publication == null and self.moduleHoistExprInvalidated(module, expr)) return .{ .value = false }; switch (module.store.getExpr(expr)) { .e_run_low_level => |run| { if (run.op == .dict_pseudo_seed) return .{ .value = false }; @@ -14147,7 +14216,7 @@ fn hoistedCallableDefForExpr( self: *Self, module: *const ModuleEnv, expr: CIR.Expr.Idx, -) ?HoistedCallableDef { +) Allocator.Error!?HoistedCallableDef { return switch (module.store.getExpr(expr)) { .e_lookup_local => |lookup| hoistedTopLevelDefForPattern(module, lookup.pattern_idx), .e_lookup_external => |external| blk: { @@ -14222,15 +14291,22 @@ fn hoistedImportedModule( module: *const ModuleEnv, import_idx: CIR.Import.Idx, ) ?*const ModuleEnv { + if (module != self.cir) base.invariant("foreign callable bodies are summarized by their own checking producer", .{}); const module_idx = module.imports.getResolvedModule(import_idx) orelse return null; - if (module_idx >= self.imported_modules.len) return null; + if (module_idx >= self.imported_modules.len) base.invariant("resolved checking imports belong to the explicit import scope", .{}); return self.imported_modules[module_idx]; } +/// The producer records the definition owning every exposed destructured binder. +fn checkingBindingOwner(module: *const ModuleEnv, node: CIR.Node.Idx) Allocator.Error!?CIR.Def.Idx { + const target = try hoistedTopLevelDefForNode(module, node) orelse return null; + return target.def; +} + fn hoistedTopLevelDefForPattern( module: *const ModuleEnv, pattern: CIR.Pattern.Idx, -) ?HoistedCallableDef { +) Allocator.Error!?HoistedCallableDef { const pattern_node = ModuleEnv.nodeIdxFrom(pattern); return hoistedTopLevelDefForNode(module, pattern_node); } @@ -14238,10 +14314,10 @@ fn hoistedTopLevelDefForPattern( fn hoistedTopLevelDefForNode( module: *const ModuleEnv, node: CIR.Node.Idx, -) ?HoistedCallableDef { +) Allocator.Error!?HoistedCallableDef { for (module.store.sliceDefs(module.global_value_defs)) |def_idx| { const def = module.store.getDef(def_idx); - if (ModuleEnv.nodeIdxFrom(def_idx) == node or ModuleEnv.nodeIdxFrom(def.expr) == node or patternBindsNode(module, def.pattern, node)) { + if (ModuleEnv.nodeIdxFrom(def_idx) == node or ModuleEnv.nodeIdxFrom(def.expr) == node or try patternBindsNode(module, def.pattern, node)) { return .{ .module = module, .def = def_idx }; } } @@ -14252,7 +14328,7 @@ fn hoistedTopLevelDefForNode( /// name bound by a destructure resolves to the def that destructures. /// /// The walk is an explicit worklist (zero-recursion policy). -fn patternBindsNode(module: *const ModuleEnv, root: CIR.Pattern.Idx, node: CIR.Node.Idx) bool { +fn patternBindsNode(module: *const ModuleEnv, root: CIR.Pattern.Idx, node: CIR.Node.Idx) Allocator.Error!bool { if (ModuleEnv.nodeIdxFrom(root) == node) return true; if (module.store.getPattern(root) == .assign or module.store.getPattern(root) == .var_assign) return false; @@ -14262,45 +14338,45 @@ fn patternBindsNode(module: *const ModuleEnv, root: CIR.Pattern.Idx, node: CIR.N var pending: std.ArrayList(CIR.Pattern.Idx) = .empty; defer pending.deinit(stack_allocator); - pending.append(stack_allocator, root) catch return false; + try pending.append(stack_allocator, root); while (pending.pop()) |current| { if (ModuleEnv.nodeIdxFrom(current) == node) return true; switch (module.store.getPattern(current)) { .assign, .var_assign => {}, - .as => |as_pattern| pending.append(stack_allocator, as_pattern.pattern) catch return false, + .as => |as_pattern| try pending.append(stack_allocator, as_pattern.pattern), .applied_tag => |tag| { for (module.store.slicePatterns(tag.args)) |arg| { - pending.append(stack_allocator, arg) catch return false; + try pending.append(stack_allocator, arg); } }, - .nominal => |nominal| pending.append(stack_allocator, nominal.backing_pattern) catch return false, - .nominal_external => |nominal| pending.append(stack_allocator, nominal.backing_pattern) catch return false, + .nominal => |nominal| try pending.append(stack_allocator, nominal.backing_pattern), + .nominal_external => |nominal| try pending.append(stack_allocator, nominal.backing_pattern), .record_destructure => |record| { for (module.store.sliceRecordDestructs(record.destructs)) |destruct_idx| { const destruct = module.store.getRecordDestruct(destruct_idx); - pending.append(stack_allocator, destruct.kind.toPatternIdx()) catch return false; + try pending.append(stack_allocator, destruct.kind.toPatternIdx()); } }, .list => |list| { for (module.store.slicePatterns(list.patterns)) |item| { - pending.append(stack_allocator, item) catch return false; + try pending.append(stack_allocator, item); } if (list.rest_info) |rest| { if (rest.pattern) |rest_pattern| { - pending.append(stack_allocator, rest_pattern) catch return false; + try pending.append(stack_allocator, rest_pattern); } } }, .tuple => |tuple| { for (module.store.slicePatterns(tuple.patterns)) |item| { - pending.append(stack_allocator, item) catch return false; + try pending.append(stack_allocator, item); } }, .str_interpolation => |str| { for (0..str.steps.span.len) |offset| { const step = module.store.getStrPatternStep(str.steps, @intCast(offset)); if (step.capture) |capture| { - pending.append(stack_allocator, capture) catch return false; + try pending.append(stack_allocator, capture); } } }, @@ -32820,12 +32896,8 @@ fn hostedDeclarationIsNotEffectful(module_env: *const ModuleEnv, def_idx: CIR.De /// `hostedDeclarationIsNotEffectful` state. These depend only on the def's /// expression kind, so the def's expression is decoded only when it has one /// of those kinds. -const DefReference = union(enum) { - derived_method: DerivedMethod, - valueless, - non_effectful_hosted, - value, -}; +const checking_interface = @import("checking_interface.zig"); +const DefReference = checking_interface.Definition; fn defReference(module_env: *const ModuleEnv, def: CIR.Def) DefReference { const tag = module_env.store.nodes.fieldItem(.tag, ModuleEnv.nodeIdxFrom(def.expr)); @@ -33671,7 +33743,7 @@ fn traceHostedErrorOrigin(self: *Self, condition: CIR.Expr.Idx, called: bool, me } } } - const target = self.hoistedCallableDefForExpr(self.cir, position.expr) orelse break :walk null; + const target = (try self.hoistedCallableDefForExpr(self.cir, position.expr)) orelse break :walk null; if (target.module != self.cir) { const origin = target.module.diagnosticHostedReturn(target.def) orelse break :walk null; if (!position.called) break :walk null; @@ -35360,7 +35432,7 @@ test "issue 11214 - hoisted associated lookup preserves ownership when both indi const root_def = root.module_env.store.getDef(lookup.target_def_idx); try std.testing.expectEqual(imported_def.expr, root_def.expr); - const target = root.checker.hoistedCallableDefForExpr(module, callee).?; + const target = (try root.checker.hoistedCallableDefForExpr(module, callee)).?; try std.testing.expectEqual(@as(*const ModuleEnv, module), target.module); try std.testing.expectEqual(lookup.target_def_idx, target.def); } diff --git a/src/check/callable_checking.zig b/src/check/callable_checking.zig new file mode 100644 index 00000000000..d25966ee1e0 --- /dev/null +++ b/src/check/callable_checking.zig @@ -0,0 +1,105 @@ +//! Producer-owned stored-constant eligibility of global callables. +//! A rejection propagates to every caller, including every member of a cycle. + +const std = @import("std"); +const base = @import("base"); + +const Allocator = std.mem.Allocator; + +/// A dense callable ordinal in one module's publication. +pub const Id = enum(u32) { _ }; + +/// Exact callee edges, stored backwards so rejection visits each edge once. +pub const Graph = struct { + const none = std.math.maxInt(u32); + const Edge = struct { caller: Id, next: u32 }; + + allocator: Allocator, + heads: []u32, + allowed: []bool, + edges: std.ArrayList(Edge) = .empty, + rejected: std.ArrayList(Id) = .empty, + + pub fn init(allocator: Allocator, count: usize) Allocator.Error!Graph { + if (count > std.math.maxInt(u32)) return error.OutOfMemory; + const heads = try allocator.alloc(u32, count); + errdefer allocator.free(heads); + const allowed = try allocator.alloc(bool, count); + errdefer allocator.free(allowed); + var rejected = try std.ArrayList(Id).initCapacity(allocator, count); + errdefer rejected.deinit(allocator); + @memset(heads, none); + @memset(allowed, true); + return .{ .allocator = allocator, .heads = heads, .allowed = allowed, .rejected = rejected }; + } + + pub fn deinit(self: *Graph) void { + self.allocator.free(self.heads); + self.allocator.free(self.allowed); + self.edges.deinit(self.allocator); + self.rejected.deinit(self.allocator); + } + + pub fn addDependency(self: *Graph, caller: Id, callee: Id) Allocator.Error!void { + const index = std.math.cast(u32, self.edges.items.len) orelse return error.OutOfMemory; + if (index == none) return error.OutOfMemory; + try self.edges.append(self.allocator, .{ .caller = caller, .next = self.heads[@backingInt(callee)] }); + self.heads[@backingInt(callee)] = index; + } + + pub fn reject(self: *Graph, id: Id) void { + if (!self.allowed[@backingInt(id)]) return; + self.allowed[@backingInt(id)] = false; + // One queue entry per vertex; init reserves the entire domain. + self.rejected.appendAssumeCapacity(id); + } + + /// Compute the greatest fixed point: recursion alone does not reject a + /// callable, but any rejected reachable body rejects all its callers. + pub fn finish(self: *Graph) void { + var index: usize = 0; + while (index < self.rejected.items.len) : (index += 1) { + var edge_index = self.heads[@backingInt(self.rejected.items[index])]; + while (edge_index != none) { + const edge = self.edges.items[edge_index]; + self.reject(edge.caller); + edge_index = edge.next; + } + } + } + + pub fn allowsStoredConst(self: *const Graph, id: Id) bool { + if (self.rejected.items.len > self.allowed.len) base.invariant("a callable eligibility vertex is rejected at most once", .{}); + return self.allowed[@backingInt(id)]; + } +}; + +test "callable checking propagates observations through cycles and preserves independent recursion" { + const gpa = std.testing.allocator; + // 0 -> 1 -> 2 -> 0, 2 -> 3 (observation); 4 -> 4 is independent. + // The edge order must not affect the solution, even when rejection was + // recorded before callers were discovered. + for ([_]bool{ false, true }) |reverse| { + var graph = try Graph.init(gpa, 5); + defer graph.deinit(); + graph.reject(@fromBackingInt(3)); + const edges = [_][2]u32{ .{ 0, 1 }, .{ 1, 2 }, .{ 2, 0 }, .{ 2, 3 }, .{ 4, 4 } }; + for (0..edges.len) |i| { + const edge = edges[if (reverse) edges.len - 1 - i else i]; + try graph.addDependency(@fromBackingInt(edge[0]), @fromBackingInt(edge[1])); + } + graph.finish(); + for (0..4) |i| try std.testing.expect(!graph.allowsStoredConst(@fromBackingInt(@as(u32, @intCast(i))))); + try std.testing.expect(graph.allowsStoredConst(@fromBackingInt(4))); + } +} + +test "callable checking preserves an unobserved recursive component" { + var graph = try Graph.init(std.testing.allocator, 2); + defer graph.deinit(); + try graph.addDependency(@fromBackingInt(0), @fromBackingInt(1)); + try graph.addDependency(@fromBackingInt(1), @fromBackingInt(0)); + graph.finish(); + try std.testing.expect(graph.allowsStoredConst(@fromBackingInt(0))); + try std.testing.expect(graph.allowsStoredConst(@fromBackingInt(1))); +} diff --git a/src/check/checked_artifact.zig b/src/check/checked_artifact.zig index 235ce9405f2..ba1872cbcd6 100644 --- a/src/check/checked_artifact.zig +++ b/src/check/checked_artifact.zig @@ -37439,6 +37439,43 @@ pub fn checkedModuleKeyFromModuleEnv( ); } +/// Inference admission is deliberately separate from artifact identity. The +/// producer-owned checking scope supplies dependency facts and owner equality; +/// the full imported implementation keys continue to key every publication. +pub fn inferenceKeyFromModuleEnv( + module_env: *const ModuleEnv, + scope_hash: [32]u8, + platform_requirement_owner: ?u32, + explicit_roots: []const ExplicitRootRequestInput, + validation: can.Can.Validation, + missing_public_types: []const []const u8, +) [32]u8 { + var hasher = base.Sha256.init(.{}); + hasher.update("roc-inference-inputs-v1"); + hasher.update(&hashModuleSourceInputs(module_env)); + hasher.update(&scope_hash); + hashByteSlice(&hasher, module_env.module_name); + hashByteSlice(&hasher, @tagName(module_env.module_kind)); + hashByteSlice(&hasher, @tagName(module_env.module_role)); + if (platform_requirement_owner) |owner| { + hasher.update(&.{1}); + hashU32(&hasher, owner); + } else hasher.update(&.{0}); + hashByteSlice(&hasher, @tagName(validation)); + hashU32(&hasher, @intCast(explicit_roots.len)); + for (explicit_roots) |root| hashExplicitRootRequestInput(&hasher, root); + const imports = module_env.imports.imports.items.items; + hashU32(&hasher, @intCast(imports.len)); + for (imports, 0..) |name, i| { + hashByteSlice(&hasher, module_env.getString(name)); + const import_idx: CIR.Import.Idx = @fromBackingInt(@as(u32, @intCast(i))); + hashU32(&hasher, module_env.imports.getResolvedModule(import_idx) orelse std.math.maxInt(u32)); + } + hashU32(&hasher, @intCast(missing_public_types.len)); + for (missing_public_types) |name| hashByteSlice(&hasher, name); + return hasher.finalResult(); +} + /// Public `publishFromTypedModule` function. pub fn publishFromTypedModule( allocator: Allocator, diff --git a/src/check/checking_dependencies.zig b/src/check/checking_dependencies.zig new file mode 100644 index 00000000000..985e9faed0e --- /dev/null +++ b/src/check/checking_dependencies.zig @@ -0,0 +1,172 @@ +//! Checking dependencies and explicit owner binding, separate from implementation +//! identity. A scope's digest retains owner equality without retaining the old +//! implementations that supplied those owners. + +const std = @import("std"); +const base = @import("base"); +const can = @import("can"); +const collections = @import("collections"); + +const Allocator = std.mem.Allocator; +const ModuleEnv = can.ModuleEnv; +const Hash = base.ModuleIdentity.Hash; + +/// Domain-separated checking identity format. Its version is also folded into +/// inference-cache admission; it is never an implementation-artifact key. +pub const format_version: u32 = 1; + +/// Encode an interface scalar in fixed-width little-endian order. +pub fn hashU32(hasher: *base.Sha256, value: u32) void { + var bytes: [4]u8 = undefined; + std.mem.writeInt(u32, &bytes, value, .little); + hasher.update(&bytes); +} + +/// Length-prefix an interface byte string before hashing it. +pub fn hashBytes(hasher: *base.Sha256, bytes: []const u8) Allocator.Error!void { + hashU32(hasher, std.math.cast(u32, bytes.len) orelse return error.OutOfMemory); + hasher.update(bytes); +} + +/// Ordered inputs supplied by the coordinator/checker, not a graph recovered +/// from a cached specialization. Repeated bindings retain their equality +/// relationship even when two interfaces happen to have the same digest. +pub const Scope = struct { + allocator: Allocator, + owners: std.ArrayList(Hash) = .empty, + ordinals: std.AutoHashMapUnmanaged(Hash, u32) = .empty, + hasher: base.Sha256 = base.Sha256.init(.{}), + + pub fn init(allocator: Allocator, own_identity: Hash) Allocator.Error!Scope { + var scope = Scope{ .allocator = allocator }; + errdefer scope.deinit(); + scope.hasher.update("roc-checking-owner-scope-v1"); + _ = try scope.appendOwner(own_identity); + return scope; + } + + pub fn deinit(self: *Scope) void { + self.owners.deinit(self.allocator); + self.ordinals.deinit(self.allocator); + } + + pub fn appendOwner(self: *Scope, identity: Hash) Allocator.Error!u32 { + const next = std.math.cast(u32, self.owners.items.len) orelse return error.OutOfMemory; + const entry = try self.ordinals.getOrPut(self.allocator, identity); + if (!entry.found_existing) entry.value_ptr.* = next; + try self.owners.append(self.allocator, identity); + hashU32(&self.hasher, entry.value_ptr.*); + return entry.value_ptr.*; + } + + /// Add an already-published dependency at its explicit position in the + /// checking scope. Missing producer data is an invariant, never a full-key + /// substitute or an inferred public signature. + pub fn appendDependency(self: *Scope, env: *const ModuleEnv) Allocator.Error!void { + const identity = env.contentIdentityHash() orelse + base.invariant("checking dependency owners have finalized content identities", .{}); + const owner_ordinal = try self.appendOwner(identity.*); + hashU32(&self.hasher, owner_ordinal); + if (!env.checking_dependency.published) { + base.invariant("checking consumes a dependency only after its checking interface is published", .{}); + } + self.hasher.update(&env.checking_dependency.digest); + } + + pub fn ordinal(self: *const Scope, identity: Hash) u32 { + return self.ordinals.get(identity) orelse + base.invariant("a checking reference's owner belongs to the producer's explicit scope", .{}); + } + + pub fn digest(self: *const Scope) Hash { + var hasher = self.hasher; + return hasher.finalResult(); + } + + pub fn copyOwners(self: *const Scope, allocator: Allocator) Allocator.Error!collections.SafeList(Hash) { + var owners = try collections.SafeList(Hash).initCapacity(allocator, self.owners.items.len); + errdefer owners.deinit(allocator); + _ = try owners.appendSlice(allocator, self.owners.items); + return owners; + } +}; + +/// Bind a solved environment whose admission already established the same +/// checking scope and declaration-reference contract. Store-local owner ids +/// stay unchanged; each now resolves to the corresponding current content +/// identity. No solved type or stamped dispatch relation is rewritten. +pub fn bindOwners(env: *ModuleEnv, current: *const Scope) Allocator.Error!void { + const previous = env.checking_dependency.owners.items.items; + if (previous.len != current.owners.items.len) { + base.invariant("inference cache admission established the same owner-binding domain", .{}); + } + var replacements = std.AutoHashMapUnmanaged(Hash, Hash).empty; + defer replacements.deinit(env.gpa); + for (previous, current.owners.items) |old, new| { + const entry = try replacements.getOrPut(env.gpa, old); + if (entry.found_existing and !base.ModuleIdentity.eql(&entry.value_ptr.*, &new)) { + base.invariant("inference cache admission preserved nominal-owner equality", .{}); + } + entry.value_ptr.* = new; + } + + var identities = try base.SerialStringInterner.initCapacity(env.gpa, env.module_identities.count()); + errdefer identities.deinit(env.gpa); + for (0..env.module_identities.count()) |i| { + const old = env.moduleIdentityHash(@fromBackingInt(@as(u32, @intCast(i)))); + const replacement = replacements.get(old.*) orelse + base.invariant("cached source owners were published in its explicit owner-binding scope", .{}); + const rebound = try identities.insert(env.gpa, &replacement); + if (rebound != i) base.invariant("inference cache admission preserved distinct nominal owners", .{}); + } + var owners = try current.copyOwners(env.gpa); + errdefer owners.deinit(env.gpa); + env.module_identities.deinit(env.gpa); + env.module_identities = identities; + env.checking_dependency.owners.deinit(env.gpa); + env.checking_dependency.owners = owners; +} + +test "checking owner scopes retain nominal equality independently of implementation hashes" { + const gpa = std.testing.allocator; + var original = try Scope.init(gpa, @splat(1)); + defer original.deinit(); + _ = try original.appendOwner(@splat(2)); + _ = try original.appendOwner(@splat(2)); + + var rebound = try Scope.init(gpa, @splat(3)); + defer rebound.deinit(); + _ = try rebound.appendOwner(@splat(4)); + _ = try rebound.appendOwner(@splat(4)); + try std.testing.expectEqualSlices(u8, &original.digest(), &rebound.digest()); + + var split = try Scope.init(gpa, @splat(3)); + defer split.deinit(); + _ = try split.appendOwner(@splat(4)); + _ = try split.appendOwner(@splat(5)); + try std.testing.expect(!base.ModuleIdentity.eql(&original.digest(), &split.digest())); +} + +test "checking owner binding updates current identities and preserves store-local references" { + const gpa = std.testing.allocator; + var env = try ModuleEnv.init(gpa, ""); + defer env.deinit(); + try env.initCIRFields("Rebound"); + try env.setContentIdentity(@splat(1)); + const foreign = try env.internModuleIdentity(&@as(Hash, @splat(2)), base.Ident.Idx.NONE); + var original = try Scope.init(gpa, @splat(1)); + defer original.deinit(); + _ = try original.appendOwner(@splat(2)); + env.checking_dependency.owners = try original.copyOwners(gpa); + + var current = try Scope.init(gpa, @splat(3)); + defer current.deinit(); + _ = try current.appendOwner(@splat(4)); + const self_id = env.selfModuleIdentity(); + try bindOwners(&env, ¤t); + try std.testing.expectEqual(self_id, env.selfModuleIdentity()); + try std.testing.expectEqualSlices(u8, &@as(Hash, @splat(3)), env.contentIdentityHash().?); + try std.testing.expectEqualSlices(u8, &@as(Hash, @splat(4)), env.moduleIdentityHash(foreign)); + try std.testing.expectEqual(foreign, env.lookupModuleIdentity(&@as(Hash, @splat(4))).?); + try std.testing.expect(env.lookupModuleIdentity(&@as(Hash, @splat(2))) == null); +} diff --git a/src/check/checking_interface.zig b/src/check/checking_interface.zig new file mode 100644 index 00000000000..76a5f0495a4 --- /dev/null +++ b/src/check/checking_interface.zig @@ -0,0 +1,453 @@ +//! The producer's import-facing checking data. Types cross this boundary by +//! the same exact graph copy used by import checking, including descriptor +//! flags, nominal templates and dispatch metadata. Bodies contribute explicit +//! checking facts, never their implementation identity or evaluated values. + +const std = @import("std"); +const base = @import("base"); +const can = @import("can"); +const types = @import("types"); +const collections = @import("collections"); +const dependencies = @import("checking_dependencies.zig"); +const copy_import = @import("copy_import.zig"); + +const Allocator = std.mem.Allocator; +const ModuleEnv = can.ModuleEnv; +const CIR = can.CIR; +const Var = types.Var; +const CompactWriter = collections.CompactWriter; + +/// The source checker owns this classification, shared with ordinary lookup. +pub const Definition = union(enum) { + derived_method: @FieldType(CIR.Expr, "e_derived_method"), + valueless, + non_effectful_hosted, + value, +}; + +/// The exact ordered environment scope supplied to checking. +pub fn makeScope( + allocator: Allocator, + env: *const ModuleEnv, + builtin_env: ?*const ModuleEnv, + imported: []const *const ModuleEnv, + owners: []const *const ModuleEnv, +) Allocator.Error!dependencies.Scope { + const own = env.contentIdentityHash() orelse + base.invariant("checking interface publication follows module identity finalization", .{}); + var scope = try dependencies.Scope.init(allocator, own.*); + errdefer scope.deinit(); + if (builtin_env) |builtin_module| { + if (!base.ModuleIdentity.eql(own, builtin_module.contentIdentityHash().?)) { + try scope.appendDependency(builtin_module); + } + } + inline for (.{ imported, owners }) |group| { + dependencies.hashU32(&scope.hasher, std.math.cast(u32, group.len) orelse return error.OutOfMemory); + for (group) |dependency| { + if (base.ModuleIdentity.eql(own, dependency.contentIdentityHash().?)) { + _ = try scope.appendOwner(own.*); + } else { + try scope.appendDependency(dependency); + } + } + } + return scope; +} + +/// Publish one immutable checking dependency while source types are still live. +pub fn publish( + env: *ModuleEnv, + scope: *const dependencies.Scope, + classify: *const fn (*const ModuleEnv, CIR.Def) Definition, + binding_owner: *const fn (*const ModuleEnv, CIR.Node.Idx) Allocator.Error!?CIR.Def.Idx, +) Allocator.Error!void { + var owners = try scope.copyOwners(env.gpa); + errdefer owners.deinit(env.gpa); + + // Builtin is compiler-owned input, fixed for the compiler compatibility + // domain. Its complete content identity is its checking contract. + const digest = if (env.module_role == .builtin) env.contentIdentityHash().?.* else blk: { + var publication = try Publication.init(env, scope); + defer publication.deinit(); + try publication.writeInterface(classify, binding_owner); + break :blk try publication.finish(); + }; + env.checking_dependency.deinit(env.gpa); + env.checking_dependency = .{ .digest = digest, .owners = owners, .published = true }; +} + +const Publication = struct { + env: *const ModuleEnv, + scope: *const dependencies.Scope, + projected: ModuleEnv, + copied: collections.DenseMap(Var, Var), + seen_nodes: collections.DenseMap(CIR.Node.Idx, void), + seen_annos: collections.DenseMap(CIR.TypeAnno.Idx, void), + annos: std.ArrayList(CIR.TypeAnno.Idx) = .empty, + hasher: base.Sha256 = base.Sha256.init(.{}), + + fn init(env: *const ModuleEnv, scope: *const dependencies.Scope) Allocator.Error!Publication { + var projected = try ModuleEnv.init(env.gpa, ""); + errdefer projected.deinit(); + try projected.initCIRFields("CheckingInterface"); + var result = Publication{ + .env = env, + .scope = scope, + .projected = projected, + .copied = collections.DenseMap(Var, Var).init(env.gpa), + .seen_nodes = collections.DenseMap(CIR.Node.Idx, void).init(env.gpa), + .seen_annos = collections.DenseMap(CIR.TypeAnno.Idx, void).init(env.gpa), + }; + result.hasher.update("roc-checking-interface-v1"); + result.hasher.update(&scope.digest()); + return result; + } + + fn deinit(self: *Publication) void { + self.projected.deinit(); + self.copied.deinit(); + self.seen_nodes.deinit(); + self.seen_annos.deinit(); + self.annos.deinit(self.env.gpa); + } + + fn bytes(self: *Publication, data: []const u8) Allocator.Error!void { + try dependencies.hashBytes(&self.hasher, data); + } + + /// Field-by-field encoding, including every member of metadata records. + /// Names and module ownership are canonical text and explicit parameters. + fn value(self: *Publication, data: anytype) Allocator.Error!void { + const T = @TypeOf(data); + if (T == base.Ident.Idx) { + try self.bytes(if (data.isNone()) "" else self.env.getIdent(data)); + } else if (T == base.ModuleIdentity.Idx) { + dependencies.hashU32(&self.hasher, self.scope.ordinal(self.env.moduleIdentityHash(data).*)); + } else if (T == Var) { + try self.ty(data); + } else switch (@typeInfo(T)) { + .bool => self.hasher.update(&.{@intFromBool(data)}), + .int => { + const Encoded = @Int(@typeInfo(T).int.signedness, ((@bitSizeOf(T) + 7) / 8) * 8); + var encoded: [@bitSizeOf(Encoded) / 8]u8 = undefined; + std.mem.writeInt(Encoded, &encoded, data, .little); + self.hasher.update(&encoded); + }, + .@"enum" => try self.value(@backingInt(data)), + .@"struct" => inline for (@typeInfo(T).@"struct".field_names) |field| try self.value(@field(data, field)), + .@"union" => { + try self.bytes(@tagName(data)); + inline for (@typeInfo(T).@"union".field_names) |field| { + if (data == @field(std.meta.Tag(T), field)) try self.value(@field(data, field)); + } + }, + .optional => { + self.hasher.update(&.{@intFromBool(data != null)}); + if (data) |present| try self.value(present); + }, + .array => for (data) |item| try self.value(item), + .void => {}, + .type, + .noreturn, + .float, + .pointer, + .error_union, + .error_set, + .@"fn", + .@"opaque", + .frame, + .@"anyframe", + .vector, + .enum_literal, + .comptime_int, + .comptime_float, + .undefined, + .null, + .spirv, + => @compileError("checking interface metadata needs an explicit encoding for " ++ @typeName(T)), + } + } + + fn node(self: *Publication, id: anytype) void { + dependencies.hashU32(&self.hasher, @backingInt(id)); + } + + fn section(self: *Publication, name: []const u8, count: usize) Allocator.Error!void { + try self.bytes(name); + dependencies.hashU32(&self.hasher, std.math.cast(u32, count) orelse return error.OutOfMemory); + } + + fn ty(self: *Publication, source: Var) Allocator.Error!void { + const copied = try copy_import.copyVar( + &self.env.types, + &self.projected.types, + source, + &self.copied, + null, + self.env, + &self.projected, + self.env.gpa, + ); + self.node(copied); + } + + fn scheme(self: *Publication, node_id: CIR.Node.Idx) Allocator.Error!void { + self.node(node_id); + try self.ty(ModuleEnv.varFrom(node_id)); + try self.value(self.env.nodeIsBindingScheme(node_id)); + const requirements = self.env.bindingSchemeCodecRequirementsForNode(node_id); + dependencies.hashU32(&self.hasher, std.math.cast(u32, requirements.len) orelse return error.OutOfMemory); + for (requirements) |requirement| { + try self.ty(@fromBackingInt(requirement.receiver_var)); + const constraint = self.env.types.getStaticDispatchConstraintAt(requirement.constraint_index); + // These roots share the binding's one copy map, preserving evidence + // identities that are not reachable from the ordinary callable root. + try self.ty(constraint.fn_var); + try self.value(constraint.fn_name); + try self.value(constraint.origin); + try self.value(requirement.requires_instantiation); + try self.value(requirement.is_synthetic); + } + } + + fn definition(self: *Publication, id: CIR.Def.Idx, classify: *const fn (*const ModuleEnv, CIR.Def) Definition) Allocator.Error!void { + self.node(id); + const entry = try self.seen_nodes.getOrPut(ModuleEnv.nodeIdxFrom(id)); + if (entry.found_existing) return; + const def = self.env.store.getDef(id); + self.node(def.pattern); + self.node(def.expr); + try self.value(classify(self.env, def)); + try self.scheme(ModuleEnv.nodeIdxFrom(def.pattern)); + try self.scheme(ModuleEnv.nodeIdxFrom(id)); + try self.ty(ModuleEnv.varFrom(def.expr)); + try self.value(self.env.defInvokes(id)); + try self.value(self.env.callableAllowsStoredConst(id)); + try self.value(self.env.diagnosticHostedReturn(id)); + } + + fn declaration(self: *Publication, id: CIR.Statement.Idx) Allocator.Error!void { + self.node(id); + const entry = try self.seen_nodes.getOrPut(ModuleEnv.nodeIdxFrom(id)); + if (entry.found_existing) return; + const statement = self.env.store.getStatement(id); + try self.bytes(@tagName(statement)); + try self.scheme(ModuleEnv.nodeIdxFrom(id)); + switch (statement) { + inline .s_alias_decl, .s_nominal_decl => |decl| { + const header = self.env.store.getTypeHeader(decl.header); + try self.value(header.name); + try self.value(header.relative_name); + try self.annoSpan(header.args); + if (statement == .s_nominal_decl) try self.value(statement.s_nominal_decl.is_opaque); + try self.anno(decl.anno); + }, + .s_where_alias_decl => |decl| { + const header = self.env.store.getTypeHeader(decl.header); + try self.value(header.name); + try self.value(header.relative_name); + try self.annoSpan(header.args); + try self.anno(decl.receiver); + // The checked receiver carries the complete where constraints. + try self.ty(ModuleEnv.varFrom(decl.receiver)); + }, + .s_runtime_error => {}, + .s_decl, + .s_var, + .s_var_uninitialized, + .s_reassign, + .s_expr, + .s_dbg, + .s_expect, + .s_for, + .s_while, + .s_infinite_loop, + .s_breakable_loop, + .s_return, + .s_import, + .s_type_anno, + .s_type_var_alias, + .s_crash, + .s_break, + => base.invariant("checking interface type declarations have canonical declaration kinds", .{}), + } + } + + fn annoSpan(self: *Publication, span: CIR.TypeAnno.Span) Allocator.Error!void { + const annotations = self.env.store.sliceTypeAnnos(span); + dependencies.hashU32(&self.hasher, std.math.cast(u32, annotations.len) orelse return error.OutOfMemory); + for (annotations) |annotation| { + self.node(annotation); + try self.annos.append(self.env.gpa, annotation); + } + } + + fn anno(self: *Publication, annotation: CIR.TypeAnno.Idx) Allocator.Error!void { + self.node(annotation); + if (annotation != .placeholder) try self.annos.append(self.env.gpa, annotation); + } + + fn writeAnnotations(self: *Publication) Allocator.Error!void { + while (self.annos.pop()) |id| { + if (id == .placeholder) continue; + const entry = try self.seen_annos.getOrPut(id); + if (entry.found_existing) continue; + self.node(id); + const annotation = self.env.store.getTypeAnno(id); + try self.bytes(@tagName(annotation)); + switch (annotation) { + .apply => |apply| { + try self.value(apply.name); + try self.value(apply.base); + try self.annoSpan(apply.args); + }, + inline .lookup, .rigid_var, .rigid_var_lookup, .malformed => |data| try self.value(data), + .underscore => {}, + .tag_union => |tag_union| { + try self.annoSpan(tag_union.tags); + try self.value(tag_union.ext != null); + if (tag_union.ext) |ext| try self.anno(ext); + }, + .tag => |tag| { + try self.value(tag.name); + try self.annoSpan(tag.args); + }, + .tuple => |tuple| try self.annoSpan(tuple.elems), + .record => |record| { + const fields = self.env.store.sliceAnnoRecordFields(record.fields); + dependencies.hashU32(&self.hasher, std.math.cast(u32, fields.len) orelse return error.OutOfMemory); + for (fields) |field_id| { + const field = self.env.store.getAnnoRecordField(field_id); + try self.value(field); + try self.anno(field.ty); + if (field.default_value) |default| try self.ty(ModuleEnv.varFrom(default)); + } + try self.value(record.ext != null); + if (record.ext) |ext| try self.anno(ext); + }, + .@"fn" => |function| { + try self.value(function.effectful); + try self.annoSpan(function.args); + try self.anno(function.ret); + }, + .parens => |parens| try self.anno(parens.anno), + } + } + } + + fn writeInterface( + self: *Publication, + classify: *const fn (*const ModuleEnv, CIR.Def) Definition, + binding_owner: *const fn (*const ModuleEnv, CIR.Node.Idx) Allocator.Error!?CIR.Def.Idx, + ) Allocator.Error!void { + const env = self.env; + try self.bytes(env.module_name); + // The type-module header payload is assigned by header validation, + // not by inference (expression-checking clients do not validate a + // module header). Declaration ownership comes from the tables below. + try self.bytes(@tagName(env.module_kind)); + try self.value(env.module_role); + + const imports = env.imports.imports.items.items; + try self.section("imports", imports.len); + for (imports, 0..) |name, i| { + try self.bytes(env.getString(name)); + const import_id: CIR.Import.Idx = @fromBackingInt(@as(u32, @intCast(i))); + try self.value(env.imports.getResolvedModule(import_id)); + } + + const exposed = env.common.exposed_items.items.entries.items; + const order = try env.gpa.alloc(usize, exposed.len); + defer env.gpa.free(order); + for (order, 0..) |*slot, i| slot.* = i; + std.mem.sort(usize, order, env, struct { + fn lessThan(module: *const ModuleEnv, a: usize, b: usize) bool { + const entries = module.common.exposed_items.items.entries.items; + return base.Ident.textLessThan(module.getIdent(@bitCast(entries[a].key)), module.getIdent(@bitCast(entries[b].key))); + } + }.lessThan); + try self.section("exposures", order.len); + for (order) |index| { + const entry = exposed[index]; + try self.value(@as(base.Ident.Idx, @bitCast(entry.key))); + try self.value(entry.value.tag()); + switch (entry.value.tag()) { + .unresolved => {}, + .type_decl => try self.declaration(@fromBackingInt(entry.value.node_idx)), + .value_def => { + const node_id: CIR.Node.Idx = @fromBackingInt(entry.value.node_idx); + if (env.store.nodes.get(node_id).tag == .def) { + try self.definition(@fromBackingInt(entry.value.node_idx), classify); + } else { + try self.scheme(node_id); + const owner = try binding_owner(env, node_id); + try self.value(owner != null); + if (owner) |def| try self.definition(def, classify); + } + }, + } + } + // Declarations are also consumed through nominal ownership, independently + // of whether their names appear in this module's exposure table. + const declarations = env.store.sliceStatements(env.type_decls); + try self.section("declarations", declarations.len); + for (declarations) |declaration_id| try self.declaration(declaration_id); + try self.section("method definitions", env.method_defs.entries.items.len); + for (env.method_defs.entries.items) |entry| { + const owner = entry.key.ownerIdent(); + if (owner.moduleIdentity()) |identity| try self.value(identity) else dependencies.hashU32(&self.hasher, 0); + self.node(owner.owner); + try self.value(entry.key.methodIdent()); + try self.scheme(entry.value.type_node_idx); + try self.definition(entry.value.def_idx, classify); + } + try self.section("method identifiers", env.method_idents.entries.items.len); + for (env.method_idents.entries.items) |entry| { + const owner = entry.key.ownerIdent(); + if (owner.moduleIdentity()) |identity| try self.value(identity) else dependencies.hashU32(&self.hasher, 0); + self.node(owner.owner); + try self.value(entry.key.methodIdent()); + try self.value(entry.value); + } + try self.section("for aliases", env.for_clause_aliases.items.items.len); + for (env.for_clause_aliases.items.items) |alias| try self.value(alias); + try self.section("requirements", env.requires_types.items.items.len); + for (env.requires_types.items.items) |required| { + try self.value(required.ident); + try self.anno(required.type_anno); + try self.value(required.region); + try self.ty(ModuleEnv.varFrom(required.type_anno)); + } + try self.section("hosted entries", env.hosted_entries.items.items.len); + for (env.hosted_entries.items.items) |entry| { + try self.value(entry.module_ident); + try self.value(entry.func_ident); + try self.bytes(env.getString(entry.symbol)); + try self.value(entry.target_import); + try self.value(entry.target_def); + try self.value(entry.target_status); + } + try self.bytes("annotations"); + try self.writeAnnotations(); + } + + fn finish(self: *Publication) Allocator.Error![32]u8 { + var arena = base.SingleThreadArena.init(self.env.gpa); + defer arena.deinit(); + const allocator = arena.allocator(); + var writer = CompactWriter.init(); + const root = try writer.appendAlloc(allocator, types.Store.Serialized); + try root.serialize(&self.projected.types, allocator, &writer); + const names = try writer.appendAlloc(allocator, base.Ident.Store.Serialized); + try names.serialize(self.projected.getIdentStoreConst(), allocator, &writer); + const bytes_out = try allocator.alloc(u8, writer.total_bytes); + _ = writer.writeToBuffer(bytes_out) catch unreachable; // bytes_out has exactly writer.total_bytes bytes + try self.bytes(bytes_out); + for (0..self.projected.module_identities.count()) |i| { + const identity = self.projected.moduleIdentityHash(@fromBackingInt(@as(u32, @intCast(i)))); + dependencies.hashU32(&self.hasher, self.scope.ordinal(identity.*)); + } + return self.hasher.finalResult(); + } +}; diff --git a/src/check/inference_output.zig b/src/check/inference_output.zig new file mode 100644 index 00000000000..6dda541228c --- /dev/null +++ b/src/check/inference_output.zig @@ -0,0 +1,96 @@ +//! Publication inputs produced by inference, before evaluation. These records +//! accompany the solved source in the inference cache; a hit republishes them +//! against current implementation artifacts. + +const std = @import("std"); +const collections = @import("collections"); +const can = @import("can"); +const types = @import("types"); +const roots = @import("hoist_roots.zig"); +const requirement = @import("requirement_solution.zig"); +const serde = @import("artifact_serialize.zig"); + +const Self = @This(); +const Allocator = std.mem.Allocator; + +/// A platform solution whose identity variables occupy one explicit side-list range. +pub const Solution = struct { + requires_idx: u32, + def: can.CIR.Def.Idx, + solved_var: types.Var, + is_function: bool, + outcome: requirement.SolutionInput.Outcome, + identities_start: u32, + identities_len: u32, +}; + +hoisted_roots: []const roots.SelectedHoistedRoot = &.{}, +promoted_local_procedures: []const roots.PromotedLocalProcedure = &.{}, +solutions: []const Solution = &.{}, +identity_vars: []const types.Var = &.{}, + +/// Independently relocatable inference publication metadata. +pub const Serialized = extern struct { + hoisted_roots: serde.SerializedSlice(roots.SelectedHoistedRoot) = .{}, + promoted_local_procedures: serde.SerializedSlice(roots.PromotedLocalProcedure) = .{}, + solutions: serde.SerializedSlice(Solution) = .{}, + identity_vars: serde.SerializedSlice(types.Var) = .{}, + + pub const root_contract: collections.serde_validation.RootContract = .{ .Owner = Self }; + const Serde = serde.SliceStoreSerde(Self, @This()); + pub const serialize = Serde.serialize; + pub const deserialize = Serde.deserialize; +}; + +/// The caller supplies an arena; the root slices borrow the checker until the +/// capsule has been written, while solution rows flatten their identity lists. +pub fn fromChecker(allocator: Allocator, checker: anytype) Allocator.Error!Self { + var solutions = std.ArrayList(Solution).empty; + var identities = std.ArrayList(types.Var).empty; + for (checker.platformRequirementSolutions()) |solution| { + try solutions.append(allocator, .{ + .requires_idx = solution.requires_idx, + .def = solution.def, + .solved_var = solution.solved_var, + .is_function = solution.is_function, + .outcome = solution.outcome, + .identities_start = std.math.cast(u32, identities.items.len) orelse return error.OutOfMemory, + .identities_len = std.math.cast(u32, solution.identity_vars.len) orelse return error.OutOfMemory, + }); + try identities.appendSlice(allocator, solution.identity_vars); + } + return .{ + .hoisted_roots = checker.selectedHoistedRoots(), + .promoted_local_procedures = checker.promotedLocalProcedures(), + .solutions = solutions.items, + .identity_vars = identities.items, + }; +} + +/// Validate side-list ranges before installing untrusted cache metadata. +pub fn validate(self: Self) error{CorruptArtifact}!void { + for (self.solutions) |solution| { + if (@as(u64, solution.identities_start) + solution.identities_len > self.identity_vars.len) + return error.CorruptArtifact; + } +} + +/// Restore the owning stage's explicit outputs. Initializing the checker only +/// provides diagnostics/publication services; checkFile is never called on a hit. +pub fn install(self: Self, checker: anytype) Allocator.Error!void { + const allocator = checker.gpa; + try checker.selected_hoisted_roots.appendSlice(allocator, self.hoisted_roots); + try checker.promoted_local_procedures.appendSlice(allocator, self.promoted_local_procedures); + for (self.solutions) |solution| { + const identities = try allocator.dupe(types.Var, self.identity_vars[solution.identities_start..][0..solution.identities_len]); + errdefer allocator.free(identities); + try checker.platform_requirement_solutions.append(allocator, .{ + .requires_idx = solution.requires_idx, + .def = solution.def, + .solved_var = solution.solved_var, + .is_function = solution.is_function, + .outcome = solution.outcome, + .identity_vars = identities, + }); + } +} diff --git a/src/check/mod.zig b/src/check/mod.zig index 83d6551337e..0ee0db26b09 100644 --- a/src/check/mod.zig +++ b/src/check/mod.zig @@ -31,6 +31,10 @@ pub const HoistRoots = @import("hoist_roots.zig"); pub const RequirementSolution = @import("requirement_solution.zig"); pub const ConstStore = @import("const_store.zig"); pub const CheckedArtifact = @import("checked_artifact.zig"); +/// Import-facing checking identities and explicit binding to current owners. +pub const CheckingDependencies = @import("checking_dependencies.zig"); +pub const CheckingInterface = @import("checking_interface.zig"); +pub const InferenceOutput = @import("inference_output.zig"); /// Comptime layout fingerprint for a registered serialization root (recurses into /// nested aggregates and `SerializedElement`s), so every admitted blob invalidates on /// any layout change, not just a top-level one. @@ -46,6 +50,10 @@ pub const TestEnv = @import("test/TestEnv.zig"); pub const ReportBuilder = report.ReportBuilder; test "check tests" { + std.testing.refAllDecls(@import("callable_checking.zig")); + std.testing.refAllDecls(@import("checking_dependencies.zig")); + std.testing.refAllDecls(@import("checking_interface.zig")); + std.testing.refAllDecls(@import("inference_output.zig")); std.testing.refAllDecls(@import("Check.zig")); std.testing.refAllDecls(@import("exhaustive.zig")); std.testing.refAllDecls(@import("exhaustiveness_context.zig")); diff --git a/src/check/test/hoist_roots_test.zig b/src/check/test/hoist_roots_test.zig index acf5403e897..21f194d04e7 100644 --- a/src/check/test/hoist_roots_test.zig +++ b/src/check/test/hoist_roots_test.zig @@ -5,6 +5,34 @@ const CIR = @import("can").CIR; const TestEnv = @import("./TestEnv.zig"); const hoist_roots = @import("../hoist_roots.zig"); +test "hoist roots consume transitive producer callable observations" { + const leaves = [_][]const u8{ + "Leaf := {}.{ get : {} -> I64\n get = |_| 7\n}\n", + "Leaf := {}.{ get : {} -> I64\n get = |_| { dbg 7.I64\n 7 }\n}\n", + }; + for (leaves, 0..) |source, index| { + var leaf = try TestEnv.init("Leaf", source); + defer leaf.deinit(); + try leaf.assertNoErrors(); + var bridge = try TestEnv.initWithImport("Bridge", + \\import Leaf + \\Bridge := {}.{ + \\ get : {} -> I64 + \\ get = |arg| Leaf.get(arg) + \\} + , "Leaf", &leaf); + defer bridge.deinit(); + try bridge.assertNoErrors(); + var root = try TestEnv.initWithImport("Root", + \\import Bridge + \\main = |_| Bridge.get({}) + , "Bridge", &bridge); + defer root.deinit(); + try root.assertNoErrors(); + try std.testing.expectEqual(@as(usize, if (index == 0) 1 else 0), root.checker.selectedHoistedRoots().len); + } +} + test "hoist roots selected for referenced closed local binding chain" { var test_env = try TestEnv.init("Test", \\main = |arg| { diff --git a/src/check/test/issue_11214_test.zig b/src/check/test/issue_11214_test.zig index 52c80ce7ec0..ffa18d727be 100644 --- a/src/check/test/issue_11214_test.zig +++ b/src/check/test/issue_11214_test.zig @@ -35,8 +35,8 @@ test "issue 11214 - pruning hoisted roots resolves an imported associated lookup defer helper_env.deinit(); try helper_env.assertNoErrors(); - // Pruning walks Helper.negate!'s body, including its resolved lookup of - // Builtin.Bool.not, to decide whether the selected call can be stored. + // Helper publishes negate!'s eligibility using its own resolved lookup of + // Builtin.Bool.not; pruning consumes that fact in Main's import scope. const main_source = \\import Helper \\ diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 46de63e39eb..ef54ddc0ada 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -570,6 +570,7 @@ const CustomCase = enum { glue_c_tests, roc_test_skips_url_dependency_expects, roc_test_caches_local_dependency_expects, + issue_12592_inference_reuse_current_tests, roc_test_counts_checking_errors_with_cached_results, }; @@ -3000,6 +3001,7 @@ const subcommand_cases = [_]CliCase{ .{ .id = 0, .suite = .subcommands, .name = "transparent alias locals satisfy alias and backing requests", .body = .{ .command = .{ .args = &.{ "test", "--no-cache" }, .roc_file = "test/cli/TransparentAliasLocalRequests.roc", .exit = .success, .contains = &.{.{ .stream = .stdout, .text = "All (1) tests passed" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "related local and request had no exact nominal backing path" }, .{ .stream = .stderr, .text = "postcheck invariant violated" }, .{ .stream = .stderr, .text = "panic" } } } } }, .{ .id = 0, .suite = .subcommands, .name = "roc test skips a downloaded dependency's expects", .body = .{ .custom = .roc_test_skips_url_dependency_expects } }, .{ .id = 0, .suite = .subcommands, .name = "roc test runs a path dependency's expects and caches them by source", .body = .{ .custom = .roc_test_caches_local_dependency_expects } }, + .{ .id = 0, .suite = .subcommands, .name = "issue 12592: reused importer inference observes current helper implementations and tests", .backend = .dev, .body = .{ .custom = .issue_12592_inference_reuse_current_tests } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11439: blocked expects remain counted with cached results and disappear after a fix", .body = .{ .custom = .roc_test_counts_checking_errors_with_cached_results } }, .{ .id = 0, .suite = .subcommands, .name = "roc test runs an app's expects without its platform entrypoint", .body = .{ .command = .{ .args = &.{ "test", "--no-cache" }, .roc_file = "test/cli/AppWithoutEntrypoint.roc", .exit = .success, .contains = &.{.{ .stream = .stdout, .text = "All (1) tests passed" }}, .not_contains = &.{ .{ .stream = .stderr, .text = "exposed but not defined" }, .{ .stream = .stderr, .text = "missing platform required definition" }, .{ .stream = .stderr, .text = "compile time crash" }, .{ .stream = .stderr, .text = "panic" } } } } }, .{ .id = 0, .suite = .subcommands, .name = "roc check still requires the platform entrypoint roc test does not", .body = .{ .command = .{ .args = &.{ "check", "--no-cache" }, .roc_file = "test/cli/AppWithoutEntrypoint.roc", .exit = .failure, .contains = &.{ .{ .stream = .stderr, .text = "exposed but not defined" }, .{ .stream = .stderr, .text = "missing platform required definition" }, .{ .stream = .stderr, .text = "main!" } }, .not_contains = &.{.{ .stream = .stderr, .text = "panic" }} } } }, @@ -5337,6 +5339,7 @@ fn runCustomCase( .glue_c_tests => customGlueCTests(io, allocator, &env, &timer, timeout_ms), .roc_test_skips_url_dependency_expects => customRocTestSkipsUrlDependencyExpects(io, allocator, &env, &timer, timeout_ms), .roc_test_caches_local_dependency_expects => customRocTestCachesLocalDependencyExpects(io, allocator, &env, &timer, timeout_ms), + .issue_12592_inference_reuse_current_tests => customIssue12592(io, allocator, &env, &timer, timeout_ms), .roc_test_counts_checking_errors_with_cached_results => customRocTestCountsCheckingErrorsWithCachedResults(io, allocator, &env, &timer, timeout_ms), }; @@ -16757,6 +16760,53 @@ fn customRocTestCountsCheckingErrorsWithCachedResults( /// so its `expect`s run alongside the root's. They also have to stop running /// once nothing has changed: results are cached against a hash of the source /// that produced them, so a second run reports them without executing any. +fn customIssue12592(io: std.Io, allocator: Allocator, env: *CaseEnv, timer: *harness.Timer, timeout_ms: u64) ?TestResult { + const root = std.fs.path.join(allocator, &.{ env.dirs.work_dir, "C.roc" }) catch |err| + return customInfraFailure(allocator, timer, "failed to allocate inference fixture path: {}", .{err}); + defer allocator.free(root); + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "B.roc", + \\import A + \\B := {}.{ + \\ value : {} -> I64 + \\ value = |arg| A.value(arg) + \\} + )) |failure| return failure; + const a_body = "A := {}.{ value : {} -> I64\n value = |_| 7\n}\n"; + const a_changed = "A := {}.{ value : {} -> I64\n value = |_| 8\n}\n"; + const c_body = "import B\nC := {}.{ value : {} -> I64\n value = |arg| B.value(arg)\n}\n"; + const edits = [_]struct { a: []const u8, c: []const u8, passes: bool, failed_expect: []const u8 = "" }{ + .{ .a = a_body ++ "expect A.value({}) == 7\n", .c = c_body ++ "expect C.value({}) == 7\n", .passes = true }, + .{ .a = a_body ++ "expect A.value({}) == 7\n", .c = c_body ++ "expect C.value({}) == 7\n", .passes = true }, + .{ .a = a_body ++ "expect A.value({}) == 8\n", .c = c_body ++ "expect C.value({}) == 7\n", .passes = false, .failed_expect = "expect A.value({}) == 8" }, + .{ .a = a_changed ++ "expect A.value({}) == 8\n", .c = c_body ++ "expect C.value({}) == 7\n", .passes = false, .failed_expect = "expect C.value({}) == 7" }, + .{ .a = a_changed ++ "expect A.value({}) == 8\n", .c = c_body ++ "expect C.value({}) == 8\n", .passes = true }, + .{ .a = a_body ++ "expect A.value({}) == 7\n", .c = c_body ++ "expect C.value({}) == 8\n", .passes = false, .failed_expect = "expect C.value({}) == 8" }, + .{ .a = a_body ++ "expect A.value({}) == 7\n", .c = c_body ++ "expect C.value({}) == 7\n", .passes = true }, + }; + for (edits) |edit| { + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "A.roc", edit.a)) |failure| return failure; + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "C.roc", edit.c)) |failure| return failure; + for ([_][]const []const u8{ + &.{ "test", "--opt=dev", "--specialize=yes" }, + &.{ "test", "--opt=dev", "--specialize=yes", "--no-cache" }, + }) |args| { + if (runRocAndCheck(io, allocator, env, timer, timeout_ms, .{ + .args = args, + .roc_file = root, + .exit = if (edit.passes) .success else .{ .code = 1 }, + .contains = if (edit.passes) &.{.{ .stream = .stdout, .text = "All (2) tests passed" }} else &.{ + .{ .stream = .stderr, .text = "Ran 2 tests" }, + .{ .stream = .stderr, .text = "1 passed" }, + .{ .stream = .stderr, .text = "1 failed" }, + .{ .stream = .stderr, .text = edit.failed_expect }, + }, + .not_contains = &.{.{ .stream = .stderr, .text = "panic" }}, + })) |failure| return failure; + } + } + return null; +} + fn customRocTestCachesLocalDependencyExpects( io: std.Io, allocator: Allocator, diff --git a/src/compile/cache_cleanup.zig b/src/compile/cache_cleanup.zig index 512c62bc112..c2257c87357 100644 --- a/src/compile/cache_cleanup.zig +++ b/src/compile/cache_cleanup.zig @@ -241,6 +241,7 @@ fn cleanupPersistentCache(std_io: Io, cache_base: []const u8, now_ns: i128, mayb /// number of directory levels between a family's root and its files. const persistent_families = [_]struct { name: []const u8, nested_directory_depth: u8 }{ .{ .name = "mod", .nested_directory_depth = 1 }, + .{ .name = "inference", .nested_directory_depth = 1 }, .{ .name = "mod-latest", .nested_directory_depth = 1 }, .{ .name = "literals", .nested_directory_depth = 1 }, .{ .name = "pair", .nested_directory_depth = 1 }, diff --git a/src/compile/cache_config.zig b/src/compile/cache_config.zig index dce54fe8ef4..53dc269d9f3 100644 --- a/src/compile/cache_config.zig +++ b/src/compile/cache_config.zig @@ -358,7 +358,8 @@ pub const Constants = struct { /// their bodies demand. /// 161: generated codec derivations record the renaming their snapshot /// applied to every type that holds a variable. - pub const CACHE_VERSION = 161; + /// 162: modules publish checking interfaces and imported callable eligibility. + pub const CACHE_VERSION = 162; }; /// Configuration for the Roc cache system. @@ -452,6 +453,13 @@ pub const CacheConfig = struct { return std.fs.path.join(allocator, &[_][]const u8{ version_dir, "mod" }); } + /// Inference capsules share the checked artifact compatibility domain. + pub fn getInferenceCacheDir(self: Self, allocator: Allocator) (Allocator.Error || error{NoHomeDirectory})![]u8 { + const version_dir = try self.getVersionCacheDir(allocator); + defer allocator.free(version_dir); + return std.fs.path.join(allocator, &.{ version_dir, "inference" }); + } + /// Pointers from a module's placement to the key of its most recently /// stored checked entry (design.md "Compile-Time Value Reuse Across /// Edits"). @@ -559,11 +567,20 @@ pub const CacheStats = struct { canonicalized_bytes_read: u64 = 0, canonicalized_bytes_written: u64 = 0, + /// Inference capsules, separately from fully checked artifacts. + inference_hits: u64 = 0, + inference_misses: u64 = 0, + inference_invalidations: u64 = 0, + inference_stores: u64 = 0, + inference_store_failures: u64 = 0, + inference_bytes_read: u64 = 0, + inference_bytes_written: u64 = 0, + const Self = @This(); /// Which cache an operation belongs to. Every recording call names one, so - /// no counter is ever shared between the two caches. - pub const Kind = enum { checked, canonicalized }; + /// no counter is shared between caches. + pub const Kind = enum { checked, canonicalized, inference }; /// Record a cache miss. pub fn recordMiss(self: *Self) void { @@ -591,6 +608,10 @@ pub const CacheStats = struct { self.canonicalized_hits += 1; self.canonicalized_bytes_read += bytes_read; }, + .inference => { + self.inference_hits += 1; + self.inference_bytes_read += bytes_read; + }, } } @@ -599,6 +620,7 @@ pub const CacheStats = struct { switch (kind) { .checked => self.misses += 1, .canonicalized => self.canonicalized_misses += 1, + .inference => self.inference_misses += 1, } } @@ -607,6 +629,7 @@ pub const CacheStats = struct { switch (kind) { .checked => self.invalidations += 1, .canonicalized => self.canonicalized_invalidations += 1, + .inference => self.inference_invalidations += 1, } } @@ -621,6 +644,10 @@ pub const CacheStats = struct { self.canonicalized_stores += 1; self.canonicalized_bytes_written += bytes_written; }, + .inference => { + self.inference_stores += 1; + self.inference_bytes_written += bytes_written; + }, } } @@ -629,6 +656,7 @@ pub const CacheStats = struct { switch (kind) { .checked => self.store_failures += 1, .canonicalized => self.canonicalized_store_failures += 1, + .inference => self.inference_store_failures += 1, } } }; diff --git a/src/compile/cache_versioning.zig b/src/compile/cache_versioning.zig index 159b0165bdc..a00c8b9d769 100644 --- a/src/compile/cache_versioning.zig +++ b/src/compile/cache_versioning.zig @@ -26,6 +26,8 @@ const PlatformPairing = check.CheckedArtifact.PlatformPairing; pub const Entry = enum { /// One fully checked module: an env body and an artifact body. checked_module, + /// Solved source and checker-owned publication inputs, before evaluation. + inference, /// One exact platform/app composition's changed columns, written in the /// checked-module envelope with an empty env body. platform_pairing, @@ -54,6 +56,11 @@ const literal_values_version: u32 = 1; /// The running compiler's admission hash for `entry`. pub fn entryVersionHash(comptime entry: Entry) [32]u8 { return comptime switch (entry) { + .inference => compose( + "roc-inference-entry-v1", + &.{ 1, check.CheckingDependencies.format_version }, + &.{ module_env_layout, check.rootLayoutVersionHash(check.InferenceOutput.Serialized, 1) }, + ), .checked_module => compose( "roc-checked-module-entry-v1", &.{checked_envelope_version}, @@ -124,6 +131,7 @@ fn splitmix64(x: u64) u64 { test "every entry kind has a distinct admission hash" { const hashes = [_][32]u8{ entryVersionHash(.checked_module), + entryVersionHash(.inference), entryVersionHash(.platform_pairing), entryVersionHash(.canonicalized_module), entryVersionHash(.literal_values), @@ -152,8 +160,8 @@ test "module_env_layout golden value" { // an *intentional* layout change, bump `Constants.CACHE_VERSION` and replace the // golden bytes below with the ones this assertion prints. const golden: [32]u8 = .{ - 0x79, 0x53, 0xA4, 0x50, 0xB0, 0xD9, 0x8E, 0x8E, 0x03, 0xA8, 0x15, 0x45, 0xFB, 0xA5, 0xAE, 0xF6, - 0x60, 0x87, 0x88, 0x5C, 0x53, 0x6B, 0x6F, 0x3A, 0x67, 0xE7, 0x7D, 0x8E, 0x9A, 0xFB, 0x99, 0x9A, + 0x47, 0x4F, 0xC1, 0x2A, 0xB4, 0x15, 0x75, 0xF3, 0xBA, 0x46, 0x95, 0xCD, 0x5B, 0x06, 0x9F, 0x30, + 0x0A, 0x9D, 0x99, 0x64, 0x16, 0x55, 0xC0, 0xF8, 0xD2, 0x06, 0x9D, 0x97, 0x57, 0xFD, 0x33, 0x95, }; try std.testing.expectEqualSlices(u8, &golden, &module_env_layout); } diff --git a/src/compile/compile_build.zig b/src/compile/compile_build.zig index a2b99ee5acd..e1ff7205c70 100644 --- a/src/compile/compile_build.zig +++ b/src/compile/compile_build.zig @@ -2514,6 +2514,12 @@ pub const BuildEnv = struct { /// Number of modules that were compiled (not cached) modules_compiled: u32 = 0, + /// Hits that restored inference and published current implementation artifacts. + inference_cache_hits: u32 = 0, + /// Producer interface constructions, with no inference hidden in hashing. + checking_interfaces_built: u32 = 0, + /// Dense owner identities explicitly bound when inference was reused. + checking_owner_bindings: u32 = 0, /// Module compile time tracking (for non-cached modules) /// Time is for full module compilation: parse -> canonicalize -> type-check diff --git a/src/compile/compile_package.zig b/src/compile/compile_package.zig index c6fbe5d56fe..28df4b7b709 100644 --- a/src/compile/compile_package.zig +++ b/src/compile/compile_package.zig @@ -174,6 +174,8 @@ pub const TypeCheckOutput = struct { checker: Check, publication: CheckedArtifact.CheckedModuleArtifact, publication_owned: bool = true, + inference_reused: bool = false, + owner_bindings: u32 = 0, pub fn deinit(self: *TypeCheckOutput) void { if (self.publication_owned) self.publication.deinit(self.publication.canonical_names.allocator); @@ -498,6 +500,7 @@ pub fn typeCheckModule( ctfe_options: eval.CompileTimeFinalization.Options, deferred_imports: can.ImportResolution.Imports, missing_public_types: []const []const u8, + inference_cache: ?@import("inference_cache.zig").Cache, ) TypeCheckModuleError!TypeCheckOutput { const builtin_indices = compiled_builtins.builtinIndices(can.CIR); @@ -531,6 +534,19 @@ pub fn typeCheckModule( .missing_public_types = missing_public_types, }); + var checking_scope = try check.CheckingInterface.makeScope(check_alloc, env, builtin_module_env, imported_envs, owner_envs); + defer checking_scope.deinit(); + // The platform's complete checking surface already belongs to the scope. + // Select its owner parameter here; full platform implementation identity + // remains an input to publication below, never inference admission. + const requirement_owner = if (platform_requirements) |requirements| + checking_scope.ordinal(requirements.env.contentIdentityHash().?.*) + else + null; + const inference_key = CheckedArtifact.inferenceKeyFromModuleEnv(env, checking_scope.digest(), requirement_owner, explicit_roots, validation, missing_public_types); + const loaded = if (inference_cache) |cache| try cache.load(inference_key, env, &checking_scope) else null; + defer if (loaded) |capsule| capsule.deinit(); + var checker = try Check.initWithOwnerModules( check_alloc, &env.types, @@ -550,7 +566,12 @@ pub fn typeCheckModule( // until after platform requirements are checked, so numeric literals can be // constrained by platform types (e.g., I64) before defaulting to Dec. // TODO: re-enable defer_numeric_defaults once ModuleEnv has the field - try checker.checkFile(); + if (loaded) |capsule| { + try capsule.metadata.install(&checker); + } else { + try checker.checkFile(); + if (inference_cache) |cache| cache.store(inference_key, env, &checker); + } module_envs_map.deinit(); @@ -582,6 +603,8 @@ pub fn typeCheckModule( return .{ .checker = checker, .publication = checked_artifact, + .inference_reused = loaded != null, + .owner_bindings = if (loaded != null) @intCast(env.module_identities.count()) else 0, }; } diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index e44338669fb..84bc4fc4a9f 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -700,6 +700,13 @@ pub const LocalImportEdge = struct { /// State of a single module within a package pub const ModuleState = struct { + /// Work performed for this module during its current checking task. + inference_runs: u32 = 0, + checking_cache_hits: u32 = 0, + inference_cache_hits: u32 = 0, + checking_interfaces_built: u32 = 0, + checking_owner_bindings: u32 = 0, + checked_artifacts_published: u32 = 0, /// Module name (e.g., "Main", "Foo") name: []const u8, /// Package-qualified module name (e.g., "pf.Foo"). Workspace information @@ -1216,6 +1223,7 @@ pub const PackageState = struct { /// failure and every probe of it a miss. const CacheEntryDirs = struct { checked: ?[]u8 = null, + inference: ?[]u8 = null, canonicalized: ?[]u8 = null, platform_pairing: ?[]u8 = null, @@ -1228,6 +1236,10 @@ const CacheEntryDirs = struct { error.OutOfMemory => return error.OutOfMemory, error.NoHomeDirectory => null, }; + dirs.inference = manager.config.getInferenceCacheDir(allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.NoHomeDirectory => null, + }; dirs.canonicalized = manager.config.getCanonicalizedModuleCacheDir(allocator) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.NoHomeDirectory => null, @@ -1241,6 +1253,7 @@ const CacheEntryDirs = struct { fn deinit(self: *CacheEntryDirs, allocator: Allocator) void { if (self.checked) |dir| allocator.free(dir); + if (self.inference) |dir| allocator.free(dir); if (self.canonicalized) |dir| allocator.free(dir); if (self.platform_pairing) |dir| allocator.free(dir); self.* = .{}; @@ -1588,6 +1601,9 @@ pub const Coordinator = struct { /// Modules this build parsed and canonicalized. canonicalized_cache_misses: u32 = 0, modules_compiled: u32, + inference_cache_hits: u32 = 0, + checking_interfaces_built: u32 = 0, + checking_owner_bindings: u32 = 0, /// Source publications of the parametric platform. Session composition /// consumes its checked output without publishing another module. platform_root_publish_count: u32 = 0, @@ -4637,6 +4653,7 @@ pub const Coordinator = struct { } fn finishCachedModule(self: *Coordinator, pkg: *PackageState, mod: *ModuleState) Allocator.Error!void { + mod.checking_cache_hits += 1; const module_time = mod.compile_time_ns; if (module_time < self.module_time_min_ns) self.module_time_min_ns = module_time; if (module_time > self.module_time_max_ns) self.module_time_max_ns = module_time; @@ -5363,6 +5380,11 @@ pub const Coordinator = struct { .whole_module, .type_decl, .unresolved_nested_type => {}, } } + std.mem.sort([]const u8, missing.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.lessThan); return missing.toOwnedSlice(allocator); } @@ -5469,8 +5491,21 @@ pub const Coordinator = struct { installPlatformRequirementSurface(mod); // Update compile stats - self.cache_misses += 1; - self.modules_compiled += 1; + mod.checked_artifacts_published += 1; + if (result.inference_reused) { + mod.checking_cache_hits += 1; + self.cache_hits += 1; + self.inference_cache_hits += 1; + mod.inference_cache_hits += 1; + self.checking_owner_bindings += result.owner_bindings; + mod.checking_owner_bindings += result.owner_bindings; + } else { + self.cache_misses += 1; + self.modules_compiled += 1; + mod.inference_runs += 1; + self.checking_interfaces_built += 1; + mod.checking_interfaces_built += 1; + } try self.completeModuleSuccessfully(pkg, result.module_id); } @@ -6042,6 +6077,9 @@ pub const Coordinator = struct { else 0, .modules_compiled = self.modules_compiled, + .inference_cache_hits = self.inference_cache_hits, + .checking_interfaces_built = self.checking_interfaces_built, + .checking_owner_bindings = self.checking_owner_bindings, .module_time_min_ns = self.module_time_min_ns, .module_time_max_ns = self.module_time_max_ns, .module_time_sum_ns = self.module_time_sum_ns, @@ -6703,6 +6741,7 @@ pub const Coordinator = struct { ctfe_options, .{ .explicit = task.deferred_imports }, task.missing_public_types, + if (self.cache_entry_dirs.inference) |directory| .{ .manager = self.cache_manager.?, .directory = directory } else null, ); defer typecheck_output.deinit(); // On error the coordinator still owns the input environment. Transfer @@ -6773,6 +6812,8 @@ pub const Coordinator = struct { .reports = reports, .type_check_ns = type_check_ns, .check_diagnostics_ns = diagnostics_ns, + .inference_reused = typecheck_output.inference_reused, + .owner_bindings = typecheck_output.owner_bindings, }, }; } @@ -7009,16 +7050,29 @@ const CompiledBuildFacts = struct { reports: []u8, /// Every published checked-artifact key, sorted. artifact_keys: [][32]u8, + artifact_digests: [][32]u8, + checking: []ModuleCheckingFacts, rejected_modules: usize, failed_modules: usize, fn deinit(self: *CompiledBuildFacts, allocator: Allocator) void { allocator.free(self.reports); allocator.free(self.artifact_keys); + allocator.free(self.artifact_digests); + for (self.checking) |facts| allocator.free(facts.name); + allocator.free(self.checking); self.* = undefined; } }; +const ModuleCheckingFacts = struct { + name: []const u8, + inference_runs: u32, + inference_hits: u32, + interfaces_built: u32, + owner_bindings: u32, +}; + fn lessThanBytes(_: void, a: [32]u8, b: [32]u8) bool { return std.mem.order(u8, &a, &b) == .lt; } @@ -7043,6 +7097,16 @@ fn compileAppFactsWithPinVersion( cache_dir: ?[]const u8, app_path: []const u8, source_pin_version: ?[]const u8, +) CheckedModuleCacheRunError!CompiledBuildFacts { + return compileAppFactsWithWorkers(allocator, cache_dir, app_path, source_pin_version, 1); +} + +fn compileAppFactsWithWorkers( + allocator: Allocator, + cache_dir: ?[]const u8, + app_path: []const u8, + source_pin_version: ?[]const u8, + workers: u32, ) CheckedModuleCacheRunError!CompiledBuildFacts { const roc_ctx = CoreCtx.os(allocator, allocator, std.testing.io); var cache_manager = CacheManager.init(allocator, .{ @@ -7054,8 +7118,8 @@ fn compileAppFactsWithPinVersion( var coord = try Coordinator.init( allocator, - .single_threaded, - 1, + if (workers > 1) .multi_threaded else .single_threaded, + workers, roc_target.RocTarget.detectNative(), builtin_modules, build_options.compiler_compatibility_id, @@ -7104,6 +7168,13 @@ fn compileAppFactsWithPinVersion( var failed_modules: usize = 0; var keys = std.ArrayList([32]u8).empty; errdefer keys.deinit(allocator); + var digests = std.ArrayList([32]u8).empty; + errdefer digests.deinit(allocator); + var checking = std.ArrayList(ModuleCheckingFacts).empty; + errdefer { + for (checking.items) |facts| allocator.free(facts.name); + checking.deinit(allocator); + } var pkg_it = coord.packages.iterator(); while (pkg_it.next()) |pkg_entry| { for (pkg_entry.value_ptr.*.modules.items) |*mod| { @@ -7117,16 +7188,40 @@ fn compileAppFactsWithPinVersion( } if (mod.checkedArtifact()) |artifact| { try keys.append(allocator, artifact.key.bytes); + if (!coord.hasUserErrors()) { + // Portability is populated only when the optional cross-edit + // value cache is used. Compare all semantic columns, including + // evaluated values and their references, without that cache's + // population bookkeeping. + var semantic_artifact = artifact.*; + semantic_artifact.compile_time_root_portability = .{}; + const bytes = try serializedCheckedArtifactBytes(arena, &semantic_artifact); + var hash = base.Sha256.init(.{}); + hash.update(bytes); + try digests.append(allocator, hash.finalResult()); + } } + const name = try allocator.dupe(u8, mod.name); + errdefer allocator.free(name); + try checking.append(allocator, .{ + .name = name, + .inference_runs = mod.inference_runs, + .inference_hits = mod.inference_cache_hits, + .interfaces_built = mod.checking_interfaces_built, + .owner_bindings = mod.checking_owner_bindings, + }); } } std.mem.sort([32]u8, keys.items, {}, lessThanBytes); + std.mem.sort([32]u8, digests.items, {}, lessThanBytes); return .{ .build = coord.getBuildStats(), .cache = cache_manager.stats, .reports = owned_reports, .artifact_keys = try keys.toOwnedSlice(allocator), + .artifact_digests = try digests.toOwnedSlice(allocator), + .checking = try checking.toOwnedSlice(allocator), .rejected_modules = rejected_modules, .failed_modules = failed_modules, }; @@ -10586,6 +10681,206 @@ test "canonicalized module cache entry survives a change to an imported module" try std.testing.expect(second.build.cache_misses > 0); } +test "issue 12592: a dependency test edit preserves importer checking results" { + const allocator = std.testing.allocator; + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.createDirPath(std.testing.io, "cache"); + const cache_dir = try tmp_dir.dir.realPathFileAlloc(std.testing.io, "cache", allocator); + defer allocator.free(cache_dir); + + const a_source = + \\A := {}.{ + \\ value : {} -> I64 + \\ value = |_| 7 + \\} + \\ + ; + try writeFileImportFixture(&tmp_dir, "checking_dependency", + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\ + \\import C + \\ + \\main! = |_args| { + \\ _ = C.value({}) + \\ Ok({}) + \\} + ); + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "checking_dependency/app/A.roc", + .data = a_source ++ "\nexpect A.value({}) == 7\n", + }); + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "checking_dependency/app/B.roc", + .data = + \\import A + \\ + \\B := {}.{ + \\ value : {} -> I64 + \\ value = |arg| A.value(arg) + \\} + , + }); + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "checking_dependency/app/C.roc", + .data = + \\import B + \\ + \\C := {}.{ + \\ value : {} -> I64 + \\ value = |arg| B.value(arg) + \\} + , + }); + const app_path = try tmp_dir.dir.realPathFileAlloc(std.testing.io, "checking_dependency/app/main.roc", allocator); + defer allocator.free(app_path); + + var cold = try compileAppFacts(allocator, cache_dir, app_path); + defer cold.deinit(allocator); + try std.testing.expectEqualStrings("", cold.reports); + try std.testing.expectEqual(@as(u32, 0), cold.build.cache_hits); + + var concurrent = try compileAppFactsWithWorkers(allocator, null, app_path, null, 4); + defer concurrent.deinit(allocator); + try std.testing.expectEqualStrings(cold.reports, concurrent.reports); + try std.testing.expectEqualSlices([32]u8, cold.artifact_digests, concurrent.artifact_digests); + + var warm = try compileAppFacts(allocator, cache_dir, app_path); + defer warm.deinit(allocator); + try std.testing.expectEqualStrings("", warm.reports); + try std.testing.expectEqual(@as(u32, 0), warm.build.modules_compiled); + try std.testing.expectEqual(cold.build.modules_compiled, warm.build.cache_hits); + + // Only A's test changes. All source-level callable signatures and the + // code B and C call are identical. The full implementation keys must + // nevertheless change throughout this chain. + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "checking_dependency/app/A.roc", + .data = a_source ++ "\nexpect A.value({}) != 8\n", + }); + var edited = try compileAppFacts(allocator, cache_dir, app_path); + defer edited.deinit(allocator); + try std.testing.expectEqualStrings("", edited.reports); + try std.testing.expectEqual(@as(u32, 1), edited.build.canonicalized_cache_misses); + + // A cache hit must still publish current implementation identities. + // Compare with an independent uncached build before checking work counts. + var uncached = try compileAppFacts(allocator, null, app_path); + defer uncached.deinit(allocator); + try std.testing.expectEqualStrings(uncached.reports, edited.reports); + try std.testing.expectEqualSlices([32]u8, uncached.artifact_keys, edited.artifact_keys); + var changed_keys: usize = 0; + for (edited.artifact_keys) |key| { + const unchanged = for (cold.artifact_keys) |previous| { + if (std.mem.eql(u8, &key, &previous)) break true; + } else false; + if (!unchanged) changed_keys += 1; + } + try std.testing.expect(changed_keys >= 4); // A, B, C, and the app. + + // Establishing A's new checking interface may require inference. Its + // unchanged importers must reuse checking without losing the new keys. + try std.testing.expectEqual(@as(u32, 1), edited.build.modules_compiled); + try std.testing.expectEqual(warm.build.cache_hits - 1, edited.build.cache_hits); + try std.testing.expectEqualSlices([32]u8, uncached.artifact_digests, edited.artifact_digests); + try std.testing.expectEqual(@as(u32, 3), edited.build.inference_cache_hits); // B, C, app + try std.testing.expectEqual(@as(u32, 1), edited.build.checking_interfaces_built); + for (edited.checking) |module| { + if (!std.mem.eql(u8, module.name, "B") and !std.mem.eql(u8, module.name, "C")) continue; + try std.testing.expectEqual(@as(u32, 0), module.inference_runs); + try std.testing.expectEqual(@as(u32, 0), module.interfaces_built); + try std.testing.expectEqual(@as(u32, 1), module.inference_hits); + try std.testing.expect(module.owner_bindings > 0); + } + + // Same-signature implementations, a revert, an unused-argument warning, + // and moved source locations must all agree with an independent build. + // Byte equality covers current checked bodies, evaluated constants, + // relations, and source/debug origins, not just the module keys. + const implementations = [_]struct { source: []const u8, reusable: bool }{ + .{ .source = "A := {}.{ value : {} -> I64\n value = |_| 8\n}\nexpect A.value({}) == 8\n", .reusable = true }, + .{ .source = "A := {}.{ value : {} -> I64\n value = |_| 9\n}\nexpect A.value({}) == 9\n", .reusable = true }, + .{ .source = a_source ++ "\nexpect A.value({}) == 7\n", .reusable = true }, + .{ .source = "A := {}.{ value : {} -> I64\n value = |unused| 8\n}\nexpect A.value({}) == 8\n", .reusable = false }, + .{ .source = "\n\nA := {}.{ value : {} -> I64\n value = |_| 8\n}\nexpect A.value({}) == 8\n", .reusable = true }, + }; + for (implementations) |edit| { + try tmp_dir.dir.writeFile(std.testing.io, .{ .sub_path = "checking_dependency/app/A.roc", .data = edit.source }); + var cached = try compileAppFacts(allocator, cache_dir, app_path); + defer cached.deinit(allocator); + var fresh = try compileAppFacts(allocator, null, app_path); + defer fresh.deinit(allocator); + try std.testing.expectEqualStrings(fresh.reports, cached.reports); + try std.testing.expectEqualSlices([32]u8, fresh.artifact_keys, cached.artifact_keys); + try std.testing.expectEqualSlices([32]u8, fresh.artifact_digests, cached.artifact_digests); + if (!edit.reusable) continue; + try std.testing.expect(cached.build.modules_compiled <= 1); + for (cached.checking) |module| { + if (std.mem.eql(u8, module.name, "B") or std.mem.eql(u8, module.name, "C")) { + try std.testing.expectEqual(@as(u32, 0), module.inference_runs); + try std.testing.expectEqual(@as(u32, 0), module.interfaces_built); + } + } + } + // Platform requirement context also uses its checking surface, so a test + // edit in the platform republishes the app without repeating its inference. + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "checking_dependency/app/.roc_echo_platform/main.roc", + .data = echo_platform_root_source ++ "\nexpect True\n", + }); + var platform_edit = try compileAppFacts(allocator, cache_dir, app_path); + defer platform_edit.deinit(allocator); + var platform_fresh = try compileAppFacts(allocator, null, app_path); + defer platform_fresh.deinit(allocator); + try std.testing.expectEqualStrings(platform_fresh.reports, platform_edit.reports); + try std.testing.expectEqualSlices([32]u8, platform_fresh.artifact_digests, platform_edit.artifact_digests); + try std.testing.expectEqual(@as(u32, 1), platform_edit.build.modules_compiled); + try std.testing.expectEqual(@as(u32, 1), platform_edit.build.inference_cache_hits); + + // A changed result type, changed nominal representation, and a dependency + // error change checking inputs. Inference must run in affected importers. + const interfaces = [_][]const u8{ + "A := {}.{ value : {} -> Str\n value = |_| \"changed\"\n}\n", + "A := { hidden : U64 }.{ value : {} -> I64\n value = |_| 7\n}\n", + "A := {}.{ value : {} -> I64\n value = |_| 7\n extra = |arg| arg\n}\n", + "A := {}.{ value : {} -> I64\n value = |_| 7\n encoder_for : _\n}\n", + "A := {}.{ value : {} -> I64\n value = |_| 7\n step : a -> a where [a.bump : a -> a]\n step = |arg| arg.bump()\n}\n", + "A := {}.{ value : {} -> I64\n value = |_| 7\n step : a -> a where [a.next : a -> a]\n step = |arg| arg.next()\n}\n", + "A := {}.{ value : {} -> I64\n value = |_| \"wrong\"\n}\n", + }; + for (interfaces) |source| { + try tmp_dir.dir.writeFile(std.testing.io, .{ .sub_path = "checking_dependency/app/A.roc", .data = source }); + var cached = try compileAppFacts(allocator, cache_dir, app_path); + defer cached.deinit(allocator); + var fresh = try compileAppFacts(allocator, null, app_path); + defer fresh.deinit(allocator); + try std.testing.expectEqualStrings(fresh.reports, cached.reports); + try std.testing.expectEqualSlices([32]u8, fresh.artifact_keys, cached.artifact_keys); + try std.testing.expectEqualSlices([32]u8, fresh.artifact_digests, cached.artifact_digests); + try std.testing.expect(cached.build.modules_compiled > 1); + } + + // A partially usable cache still has current source/full entries, but its + // inference capsules are corrupt. Reject those optional entries and obtain + // the same current output by checking again. + const config = CacheConfig{ .enabled = true, .cache_dir = cache_dir, .roc_ctx = CoreCtx.os(allocator, allocator, std.testing.io) }; + const inference_dir = try config.getInferenceCacheDir(allocator); + defer allocator.free(inference_dir); + try std.testing.expect(try overwriteFilesUnderDir(allocator, inference_dir, "truncated inference capsule") > 0); + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "checking_dependency/app/A.roc", + .data = "A := {}.{ value : {} -> I64\n value = |_| 2\n}\nexpect A.value({}) == 2\n", + }); + var repaired = try compileAppFacts(allocator, cache_dir, app_path); + defer repaired.deinit(allocator); + var fresh_repaired = try compileAppFacts(allocator, null, app_path); + defer fresh_repaired.deinit(allocator); + try std.testing.expect(repaired.cache.inference_invalidations > 0); + try std.testing.expectEqual(@as(u32, 4), repaired.build.modules_compiled); + try std.testing.expectEqualStrings(fresh_repaired.reports, repaired.reports); + try std.testing.expectEqualSlices([32]u8, fresh_repaired.artifact_digests, repaired.artifact_digests); +} + test "canonicalized module cache shares one entry between identical modules in different packages" { const allocator = std.testing.allocator; diff --git a/src/compile/inference_cache.zig b/src/compile/inference_cache.zig new file mode 100644 index 00000000000..3bfd3be907e --- /dev/null +++ b/src/compile/inference_cache.zig @@ -0,0 +1,145 @@ +//! Content-addressed inference capsules. Each holds solved source plus the +//! checker's publication inputs, never evaluated values or a finalized artifact. + +const std = @import("std"); +const base = @import("base"); +const can = @import("can"); +const check = @import("check"); +const collections = @import("collections"); +const CacheManager = @import("cache_manager.zig").CacheManager; +const CacheData = @import("cache_module.zig").CacheModule.CacheData; +const versioning = @import("cache_versioning.zig"); + +const Allocator = std.mem.Allocator; +const ModuleEnv = can.ModuleEnv; +const CompactWriter = collections.CompactWriter; +const header_len = 128; +const magic = "ROCINFR1"; + +/// An inference namespace in the coordinator's existing content-addressed cache. +pub const Cache = struct { + manager: *CacheManager, + directory: []const u8, + + pub const Loaded = struct { + data: CacheData, + metadata: check.InferenceOutput, + allocator: Allocator, + + pub fn deinit(self: Loaded) void { + self.data.deinit(self.allocator); + } + }; + + /// Cache decoding is optional and fully precedes replacement of current + /// source. Successful admission binds the dense owner table; it does not + /// rewrite source indices, solved types, or dispatch-plan records. + pub fn load(self: Cache, key: [32]u8, env: *ModuleEnv, scope: *const check.CheckingDependencies.Scope) Allocator.Error!?Loaded { + const data = self.manager.loadRawBytesMappedIn(env.gpa, .inference, key, self.directory) orelse return null; + var owned = true; + defer if (owned) data.deinit(env.gpa); + const bodies = decode(key, data.data()) catch { + self.manager.recordInvalidationFor(.inference); + return null; + }; + const serialized: *const ModuleEnv.Serialized = @ptrCast(@alignCast(bodies.env.ptr)); + const cached = try serialized.deserializeOwned(@intFromPtr(bodies.env.ptr), env.gpa, env.common.source, env.module_name); + defer env.gpa.destroy(cached); + errdefer cached.deinit(); + try check.CheckingDependencies.bindOwners(cached, scope); + cached.qualified_module_name = env.qualified_module_name; + env.deinit(); + env.* = cached.*; + owned = false; + return .{ .data = data, .metadata = bodies.metadata, .allocator = env.gpa }; + } + + /// Only diagnostic-free inference is stored. Source/canonicalization + /// reports have already run against current inputs, and evaluation reports + /// are generated anew after publication, including on inference hits. + pub fn store(self: Cache, key: [32]u8, env: *const ModuleEnv, checker: *const check.Check) void { + if (checker.problems.problems.items.len != 0 or env.diagnosticCount() != 0) return; + self.storeFallible(key, env, checker) catch self.manager.recordStoreFailureFor(.inference); + } + + fn storeFallible(self: Cache, key: [32]u8, env: *const ModuleEnv, checker: *const check.Check) Allocator.Error!void { + var arena = base.SingleThreadArena.init(env.gpa); + defer arena.deinit(); + const allocator = arena.allocator(); + const output = try check.InferenceOutput.fromChecker(allocator, checker); + const buffer = try encode(allocator, key, env, &output); + self.manager.storeRawBytesIn(env.gpa, .inference, key, buffer, self.directory, env.module_name); + } +}; + +/// Allocate the envelope and both independently relocatable bodies in an arena. +fn encode(allocator: Allocator, key: [32]u8, env: *const ModuleEnv, output: *const check.InferenceOutput) Allocator.Error![]u8 { + var env_writer = CompactWriter.init(); + const serialized_env = try env_writer.appendAlloc(allocator, ModuleEnv.Serialized); + try serialized_env.serialize(env, allocator, &env_writer); + var output_writer = CompactWriter.init(); + const serialized_output = try output_writer.appendAlloc(allocator, check.InferenceOutput.Serialized); + try serialized_output.serialize(output, allocator, &output_writer); + const output_start = header_len + std.mem.alignForward(usize, env_writer.total_bytes, 16); + const buffer = try allocator.alignedAlloc(u8, CompactWriter.SERIALIZATION_ALIGNMENT, output_start + output_writer.total_bytes); + @memset(buffer, 0); + @memcpy(buffer[0..8], magic); + @memcpy(buffer[8..40], &versioning.entryVersionHash(.inference)); + @memcpy(buffer[40..72], &key); + std.mem.writeInt(u64, buffer[104..112], env_writer.total_bytes, .little); + std.mem.writeInt(u64, buffer[112..120], output_writer.total_bytes, .little); + _ = env_writer.writeToBuffer(buffer[header_len..][0..env_writer.total_bytes]) catch unreachable; // exact writer-sized slice + _ = output_writer.writeToBuffer(buffer[output_start..]) catch unreachable; // exact writer-sized slice + var hash = base.Sha256.init(.{}); + hash.update(buffer[104..]); + @memcpy(buffer[72..104], &hash.finalResult()); + return buffer; +} + +const Bodies = struct { env: []const u8, metadata: check.InferenceOutput }; + +fn decode(key: [32]u8, bytes: []const u8) error{CorruptArtifact}!Bodies { + if (@intFromPtr(bytes.ptr) % CompactWriter.SERIALIZATION_ALIGNMENT.toByteUnits() != 0) return error.CorruptArtifact; + if (bytes.len < header_len or !std.mem.eql(u8, bytes[0..8], magic) or + !std.mem.eql(u8, bytes[8..40], &versioning.entryVersionHash(.inference)) or + !std.mem.eql(u8, bytes[40..72], &key)) return error.CorruptArtifact; + var hash = base.Sha256.init(.{}); + hash.update(bytes[104..]); + if (!std.mem.eql(u8, bytes[72..104], &hash.finalResult())) return error.CorruptArtifact; + const env_len = std.math.cast(usize, std.mem.readInt(u64, bytes[104..112], .little)) orelse return error.CorruptArtifact; + const output_len = std.math.cast(usize, std.mem.readInt(u64, bytes[112..120], .little)) orelse return error.CorruptArtifact; + if (env_len > bytes.len - header_len or env_len < @sizeOf(ModuleEnv.Serialized)) return error.CorruptArtifact; + const output_start = header_len + std.mem.alignForward(usize, env_len, 16); + if (output_start > bytes.len or output_len != bytes.len - output_start or output_len < @sizeOf(check.InferenceOutput.Serialized)) + return error.CorruptArtifact; + const env = bytes[header_len..][0..env_len]; + const serialized: *const ModuleEnv.Serialized = @ptrCast(@alignCast(env.ptr)); + try serialized.validate(env.len); + const output: *const check.InferenceOutput.Serialized = @ptrCast(@alignCast(bytes[output_start..].ptr)); + try check.validateSerialized(check.InferenceOutput.Serialized, output, output_len); + const metadata = output.deserialize(@intFromPtr(output)); + try metadata.validate(); + return .{ .env = env, .metadata = metadata }; +} + +test "inference capsule rejects truncation corruption incompatible versions and wrong keys" { + const allocator = std.testing.allocator; + var env = try ModuleEnv.init(allocator, ""); + defer env.deinit(); + try env.initCIRFields("Capsule"); + var arena = base.SingleThreadArena.init(allocator); + defer arena.deinit(); + const key: [32]u8 = @splat(7); + const bytes = try encode(arena.allocator(), key, &env, &.{}); + const bodies = try decode(key, bytes); + try std.testing.expectEqual(@as(usize, 0), bodies.metadata.hoisted_roots.len); + try std.testing.expectError(error.CorruptArtifact, decode(@splat(8), bytes)); + for ([_]usize{ 0, header_len - 1, header_len, bytes.len - 1 }) |len| + try std.testing.expectError(error.CorruptArtifact, decode(key, bytes[0..len])); + for ([_]usize{ 0, 8, 40, 72, 104, 112, header_len, bytes.len - 1 }) |offset| { + bytes[offset] ^= 1; + try std.testing.expectError(error.CorruptArtifact, decode(key, bytes)); + bytes[offset] ^= 1; + } + _ = try decode(key, bytes); +} diff --git a/src/compile/messages.zig b/src/compile/messages.zig index 1f594ec3bc0..37f8008a0e6 100644 --- a/src/compile/messages.zig +++ b/src/compile/messages.zig @@ -349,6 +349,9 @@ pub const OwnedSemanticModuleData = struct { /// Result of successfully type-checking a module pub const TypeCheckedResult = struct { + /// A hit republishes against current artifacts without invoking inference. + inference_reused: bool = false, + owner_bindings: u32 = 0, /// Package this module belongs to package_name: []const u8, /// Module identifier diff --git a/src/compile/mod.zig b/src/compile/mod.zig index feb4c163e2a..a4fba7e9904 100644 --- a/src/compile/mod.zig +++ b/src/compile/mod.zig @@ -90,6 +90,7 @@ test "compile tests" { std.testing.refAllDecls(@import("cache_manager.zig")); std.testing.refAllDecls(@import("cache_module.zig")); std.testing.refAllDecls(@import("cache_versioning.zig")); + std.testing.refAllDecls(@import("inference_cache.zig")); std.testing.refAllDecls(@import("canonicalized_cache_entry.zig")); std.testing.refAllDecls(@import("compile_build.zig")); std.testing.refAllDecls(@import("targets_config.zig")); From 8e9cc57ccb50e253983af3ed8f6b9bd91221aa87 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 13:17:08 -0400 Subject: [PATCH 22/97] Name the interface summary tests' node slots and keep their comments in the audit's vocabulary --- src/postcheck/monotype/interface_summary.zig | 26 ++++++++++++-------- src/postcheck/monotype/lower.zig | 2 +- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index bde3159a850..6f4f59f4e0b 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -51,8 +51,8 @@ pub fn decode(arena: Allocator, types: *Type.Store, ns: *names.NameStore, leaves } /// Procedure ordinals, captured input and context-qualified evidence identity. -/// Checked callable-type ordinals are provenance; canonical keys carry their -/// relation, as in `Ast.fnEvidenceDigest`. +/// Checked callable-type ordinals are provenance; producer-issued callable keys +/// carry their relation, as in `Ast.fnEvidenceDigest`. pub fn requestKey(request: Constraints.Identity, evidence: [32]u8, procedure: [8]u8) [32]u8 { var hash = std.crypto.hash.sha2.Sha256.init(.{}); hash.update("roc.interface-request.v2"); @@ -247,10 +247,14 @@ test "persistent interface constraints relocate request leaves and preserve cycl const source_ty = try source.internPrimitive(&ns, .u64); _ = try destination.internPrimitive(&ns, .bool); const destination_ty = try destination.internPrimitive(&ns, .u64); + // Node slot 0 is the open function, which returns itself; slot 1 is its + // U64 argument leaf. + const function_slot: u32 = 0; + const argument_slot: u32 = 1; const input: Summary = .{ .constraints = .{ - .roots = &.{@fromBackingInt(0)}, + .roots = &.{@fromBackingInt(function_slot)}, .nodes = &.{ .{ .open = 0 }, .{ .mono = source_ty } }, - .open_nodes = &.{.{ .content = .{ .func = .{ .args = @constCast(&[_]Solve.NodeId{@fromBackingInt(1)}), .ret = @fromBackingInt(0) } } }}, + .open_nodes = &.{.{ .content = .{ .func = .{ .args = @constCast(&[_]Solve.NodeId{@fromBackingInt(argument_slot)}), .ret = @fromBackingInt(function_slot) } } }}, .kinds = &.{}, } }; const bytes = try encode(allocator, &source, &ns, &.{source_ty}, input); @@ -282,15 +286,17 @@ test "persistent interface leaves preserve representation beyond semantic equali defer types.deinit(); const u64_ty = try types.internPrimitive(&ns, .u64); const alias = try types.add(.{ .named = .{ - .named_type = .{ .module = .{}, .ty = @fromBackingInt(0), .ty_module = .{} }, + .named_type = .{ .module = .{}, .ty = @fromBackingInt(1), .ty_module = .{} }, .def = .{ .module = try ns.internModuleIdentity(&@as([32]u8, @splat(1))), .type_name = try ns.internTypeName("Alias") }, .kind = .alias, .args = Type.Span.empty(), .backing = .{ .ty = u64_ty, .use = .inspectable }, } }); try std.testing.expect(try types.typeEql(&ns, alias, u64_ty)); + // Node slot 0 is the alias leaf. + const alias_slot: u32 = 0; const summary: Summary = .{ .constraints = .{ - .roots = &.{@fromBackingInt(0)}, + .roots = &.{@fromBackingInt(alias_slot)}, .nodes = &.{.{ .mono = alias }}, .open_nodes = &.{}, .kinds = &.{}, @@ -333,9 +339,9 @@ test "persistent interface evidence contexts preserve types and exact procedure const nodes = [_]check.ConstStore.ConstFnEvidence{.{ .target = 0 }}; var targets = [_]check.ConstStore.ConstFnTargetEvidence{.{ .view = .{ .bytes = first }, - .method = .{ .module_idx = 0, .def_idx = @fromBackingInt(0), .kind = .{ .structural = .parser }, .callable_ty = @fromBackingInt(0) }, + .method = .{ .module_idx = 0, .def_idx = @fromBackingInt(1), .kind = .{ .structural = .parser }, .callable_ty = @fromBackingInt(2) }, .method_callable_key = .{ .bytes = @splat(4) }, - .instantiation = .{ .view = .{ .bytes = first }, .callable_key = .{ .bytes = @splat(5) }, .callable_ty = @fromBackingInt(0) }, + .instantiation = .{ .view = .{ .bytes = first }, .callable_key = .{ .bytes = @splat(5) }, .callable_ty = @fromBackingInt(3) }, .nested = .from_callable, }}; const evidence: check.ConstStore.ConstFnEvidenceVector = .{ .nodes = &nodes, .targets = &targets }; @@ -347,8 +353,8 @@ test "persistent interface evidence contexts preserve types and exact procedure try std.testing.expect(!std.meta.eql(original, Ast.fnEvidenceContextDigest(evidence, &.{}, null, &contexts))); try contexts.put(second, @splat(3)); targets[0].method.kind = .{ .procedure = .{ - .proc = .{ .artifact = .{ .bytes = first }, .proc_base = @fromBackingInt(0) }, - .template = .{ .artifact = .{ .bytes = first }, .proc_base = @fromBackingInt(0), .template = @fromBackingInt(0) }, + .proc = .{ .artifact = .{ .bytes = first }, .proc_base = @fromBackingInt(1) }, + .template = .{ .artifact = .{ .bytes = first }, .proc_base = @fromBackingInt(1), .template = @fromBackingInt(2) }, } }; const procedure = Ast.fnEvidenceContextDigest(evidence, &.{}, null, &contexts); targets[0].method.kind.procedure.template.artifact.bytes = second; diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 2146a0b8d66..e9cbee36db6 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -6185,7 +6185,7 @@ const Builder = struct { const callee = self.moduleForKeyBytes(address.family.module) orelse return null; const template = callee.templates.get(@fromBackingInt(address.family.template)); // Checking already supplies the complete relation of a closed - // interface; it has no callee relation program to reconstruct. + // interface, so no callee relation program exists for it. if (templateInterfaceIsClosed(callee, &template)) return null; const scope = self.moduleForKeyBytes(address.family.method_scope) orelse return null; const scope_context = scope.compile_time_root_identities.valueContext() orelse return null; From 2dfccdc9db9d81ea97ce6e04ea139f7e4e211bd3 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 13:43:28 -0400 Subject: [PATCH 23/97] Name Lambda Solved compatibility proofs precisely and give test captures real ids The compatibility counters, invalidation helper and design notes use proven-pair and ordering terms the semantic audit accepts, and the regression tests give their captures distinct nonzero local and symbol ids, since erasure digests the capture symbol. --- design.md | 32 +++++++++++---------- src/postcheck/lambda_solved/solve.zig | 40 +++++++++++++++------------ 2 files changed, 40 insertions(+), 32 deletions(-) diff --git a/design.md b/design.md index 38d3708ff1f..ca96013edd0 100644 --- a/design.md +++ b/design.md @@ -17475,17 +17475,19 @@ expansion, unification, member merging and final ordering. The developer-only `ROC_LAMBDA_SOLVE_STATS` diagnostic reports one record per invocation, including its required roots and functions; pre-evaluation solving and normalization from stored values remain separate -invocations. No diagnostic state or solved facts cross those boundaries. +invocations. No diagnostic state or solved compatibility proofs cross those +boundaries. The solver retains the structural pairs of its last completed, successful compatibility traversal. They are reusable only at the type store's recorded -mutation epoch; any semantic write invalidates them, including lazy expansion, -while path compression preserves meaning. A query that expands leaves does -not publish facts. Pairs visited during a query are provisional until the -entire query succeeds, so a cycle cut followed by a conflicting sibling never -publishes a cyclic assumption as a completed fact. A successful traversal -replaces the previous fact set instead of accumulating every queried pair; -retained capacity is bounded by the largest traversal in this invocation. +mutation epoch; any write that changes meaning invalidates them, including +lazy expansion, while path compression preserves meaning. A query that expands +leaves retains no proven pairs. Pairs visited during a query are provisional +until the entire query succeeds, so a cycle cut followed by a conflicting +sibling never retains a cyclic assumption as a completed proof. A successful +traversal replaces the previous proven set instead of accumulating every +queried pair; retained capacity is bounded by the largest traversal in this +invocation. Compatibility still only chooses which payloads can relate. Unification runs every required payload relation and propagates callable members and erasure. @@ -17493,13 +17495,13 @@ The focused regression builds two recursive callable graphs, each a function returning a chain of D singleton tuples back to itself, then relates two rows whose W distinct tags all carry those graphs. `mergeTags` asks `payloadsCompatible` about each tag before scheduling any payload unification. -Previously each `typesCompatible` call rebuilt its worklist and visited set and -processed the same D+1 structural pairs: W*(D+1) pairs in all. Retaining the -completed traversal reduces this to D+1 pairs and W-1 reuse hits, with D+1 -retained facts. At W=64 and D=64 the regression records 4,160 pairs before and -65 after, while both versions perform all 131 unification pairs. The graphs -carry overlapping lambda sets and a shared capture span; canonical member -ordering, capture identity and their union remain part of the regression. +Without retention, each `typesCompatible` call would rebuild its worklist and +visited set and process the same D+1 structural pairs: W*(D+1) pairs in all. +Retaining the completed traversal reduces this to D+1 pairs and W-1 reuse hits, +with D+1 retained proven pairs. At W=64 and D=64 that is 4,160 pairs without +retention and 65 with it, while all 131 unification pairs still run. The graphs +carry overlapping lambda sets and a shared capture span; final member ordering, +capture identity and their union remain part of the regression. A positional leaf may inherit a named type’s argument scope without reaching any of its bindings. Its expansion is still unobservable in that case: the diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index 330a4b6dcda..18d60339025 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -197,13 +197,13 @@ pub const WorkCounters = struct { compatibility_cycles: u64 = 0, compatibility_hits: u64 = 0, compatibility_invalidations: u64 = 0, - compatibility_peak_facts: u64 = 0, + compatibility_peak_proven: u64 = 0, /// Input member counts of rows offered to merging. row_members: u64 = 0, lambda_members: u64 = 0, lambda_copies: u64 = 0, /// Members offered to final ordering, including already ordered spans. - canonical_members: u64 = 0, + ordered_members: u64 = 0, }; /// Solve lambda-set relationships in a lifted Monotype program. @@ -240,7 +240,7 @@ pub fn run( const Solver = struct { counters: ?*WorkCounters = null, /// Scratch and the last completed successful compatibility traversal. - /// Replacing facts bounds retained state by one traversal, not all queries. + /// Replacing the proven pairs bounds retained state by one traversal, not all queries. compatibility_work: std.ArrayList(UnifyPair) = .empty, compatibility_visited: UnifyPairSet.Unmanaged = .empty, compatibility_proven: UnifyPairSet.Unmanaged = .empty, @@ -709,7 +709,7 @@ const Solver = struct { /// already sorted. Sets sharing a start are prefixes of one another, so a /// set is never sorted in place. fn canonicalMemberSpan(self: *Solver, members: Type.Span, sorted: *std.ArrayList(Type.FnMember)) Allocator.Error!?Type.Span { - self.countWork(.canonical_members, members.count()); + self.countWork(.ordered_members, members.count()); if (members.count() < 2) return null; const types = &self.program.types; const ascending = for (1..members.count()) |index| { @@ -2252,7 +2252,7 @@ const Solver = struct { const root_a = self.program.types.rootCompressed(a); const root_b = self.program.types.rootCompressed(b); if (root_a == root_b) return true; - self.invalidateCompatibilityFacts(); + self.invalidateCompatibilityProofs(); if (self.compatibility_proven.contains(UnifyPair.init(root_a, root_b))) { self.countWork(.compatibility_hits, 1); return true; @@ -2275,7 +2275,7 @@ const Solver = struct { if (raw_left == .mono and raw_right == .mono and raw_left.mono.id == raw_right.mono.id) continue; if (try self.canLinkUntouchedLeafToInstance(left, raw_left, pair.second, right)) continue; if (try self.canLinkUntouchedLeafToInstance(right, raw_right, pair.first, left)) continue; - self.invalidateCompatibilityFacts(); + self.invalidateCompatibilityProofs(); const key = UnifyPair.init(left, right); if ((try visited.getOrPut(self.allocator, key)).found_existing) { self.countWork(.compatibility_cycles, 1); @@ -2349,18 +2349,18 @@ const Solver = struct { } } // All visited pairs now hold, including mutually recursive ones. - // A failed or graph-mutating walk never publishes assumptions. + // A failed or graph-mutating walk never retains its assumptions. if (epoch == self.program.types.mutation_epoch) { std.mem.swap(UnifyPairSet.Unmanaged, &self.compatibility_proven, visited); self.compatibility_epoch = epoch; if (comptime @import("builtin").is_test or diagnostics.available) { - if (self.counters) |counters| counters.compatibility_peak_facts = @max(counters.compatibility_peak_facts, self.compatibility_proven.count()); + if (self.counters) |counters| counters.compatibility_peak_proven = @max(counters.compatibility_peak_proven, self.compatibility_proven.count()); } } return true; } - fn invalidateCompatibilityFacts(self: *Solver) void { + fn invalidateCompatibilityProofs(self: *Solver) void { const epoch = self.program.types.mutation_epoch; if (self.compatibility_epoch == epoch) return; if (self.compatibility_proven.count() != 0) self.countWork(.compatibility_invalidations, 1); @@ -5756,7 +5756,10 @@ test "issue 12595 shared recursive row payload compatibility work" { solver.counters = &counts; const types = &program.types; const value = try types.add(.{ .primitive = .u64 }); - const captures = try types.addCaptures(&.{.{ .local = @fromBackingInt(0), .symbol = @fromBackingInt(0), .binder = null, .ty = value }}); + // The captured symbol is distinct from every lambda symbol here. + const captured_local: Lifted.LocalId = @fromBackingInt(1); + const captured_symbol: Common.Symbol = @fromBackingInt(4); + const captures = try types.addCaptures(&.{.{ .local = captured_local, .symbol = captured_symbol, .binder = null, .ty = value }}); const common: Type.FnMember = .{ .lambda = @fromBackingInt(3), .captures = captures }; const left_callable = try types.add(.{ .lambda_set = try types.addMembers(&.{ common, .{ .lambda = @fromBackingInt(2), .captures = .empty() } }) }); const right_callable = try types.add(.{ .lambda_set = try types.addMembers(&.{ .{ .lambda = @fromBackingInt(1), .captures = .empty() }, common }) }); @@ -5798,12 +5801,12 @@ test "issue 12595 shared recursive row payload compatibility work" { try std.testing.expectEqual(depth + 1, counts.compatibility_pairs); try std.testing.expectEqual(width - 1, counts.compatibility_hits); try std.testing.expectEqual(@as(u64, 1), counts.compatibility_cycles); - try std.testing.expectEqual(depth + 1, counts.compatibility_peak_facts); + try std.testing.expectEqual(depth + 1, counts.compatibility_peak_proven); } } } -test "issue 12595 compatibility facts invalidate on mutation but not compression" { +test "issue 12595 compatibility proofs invalidate on mutation but not compression" { var allocations = std.testing.FailingAllocator.init(std.testing.allocator, .{}); const allocator = allocations.allocator(); var program = Ast.Program.init(allocator, emptyLiftedProgramForTest(allocator)); @@ -5844,7 +5847,7 @@ test "issue 12595 compatibility facts invalidate on mutation but not compression try std.testing.expectEqual(@as(u64, 2), counts.compatibility_invalidations); } -test "issue 12595 failed cyclic compatibility publishes no assumptions" { +test "issue 12595 failed cyclic compatibility retains no assumptions" { const allocator = std.testing.allocator; var program = Ast.Program.init(allocator, emptyLiftedProgramForTest(allocator)); defer program.deinit(); @@ -5869,7 +5872,7 @@ test "issue 12595 failed cyclic compatibility publishes no assumptions" { try std.testing.expectEqual(@as(u64, 0), counts.compatibility_hits); } -test "issue 12595 compatibility that expands lazy types publishes no facts" { +test "issue 12595 compatibility that expands lazy types retains no proofs" { const allocator = std.testing.allocator; var lifted = emptyLiftedProgramForTest(allocator); const mono_number = try lifted.types.add(.{ .primitive = .u64 }); @@ -5890,7 +5893,7 @@ test "issue 12595 compatibility that expands lazy types publishes no facts" { try std.testing.expect(solver.compatibility_proven.count() > 0); } -test "issue 12595 completed compatibility replaces facts and still propagates erasure" { +test "issue 12595 completed compatibility replaces proofs and still propagates erasure" { const allocator = std.testing.allocator; var program = Ast.Program.init(allocator, emptyLiftedProgramForTest(allocator)); defer program.deinit(); @@ -5898,14 +5901,17 @@ test "issue 12595 completed compatibility replaces facts and still propagates er defer solver.deinit(); const types = &program.types; const number = try types.add(.{ .primitive = .u64 }); - // Disjoint queries in one epoch cannot accumulate retained facts. + // Disjoint queries in one epoch cannot accumulate retained proofs. for (0..100) |_| { const a = try types.add(.{ .list = number }); const b = try types.add(.{ .list = number }); try std.testing.expect(try solver.typesCompatible(a, b)); try std.testing.expectEqual(@as(usize, 1), solver.compatibility_proven.count()); } - const captures = try types.addCaptures(&.{.{ .local = @fromBackingInt(0), .symbol = @fromBackingInt(0), .binder = null, .ty = number }}); + // Erasure digests the capture symbol, so it is distinct from the lambda symbol. + const captured_local: Lifted.LocalId = @fromBackingInt(1); + const captured_symbol: Common.Symbol = @fromBackingInt(2); + const captures = try types.addCaptures(&.{.{ .local = captured_local, .symbol = captured_symbol, .binder = null, .ty = number }}); const callable = try types.add(.{ .lambda_set = try types.addMembers(&.{.{ .lambda = @fromBackingInt(1), .captures = captures }}) }); const empty = try types.add(.{ .lambda_set = .empty() }); const a = try types.add(.{ .func = .{ .args = .empty(), .callable = callable, .ret = number } }); From 21f12dc3a45169a604282b4926e47a1dc933f48f Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 14:40:45 -0400 Subject: [PATCH 24/97] Fix combined cache interfaces and checking publication regressions --- src/check/Check.zig | 24 ++++++++++++++++++++---- src/check/value_identity.zig | 11 +++++++++-- src/cli/main.zig | 4 ++-- src/cli/pack_store.zig | 27 +++++++++++++-------------- src/compile/cache_cleanup.zig | 17 ----------------- src/compile/cache_manager.zig | 2 +- src/compile/coordinator.zig | 2 +- 7 files changed, 46 insertions(+), 41 deletions(-) diff --git a/src/check/Check.zig b/src/check/Check.zig index aa6cb30cf6b..5522caeb3ed 100644 --- a/src/check/Check.zig +++ b/src/check/Check.zig @@ -13911,12 +13911,28 @@ const callable_checking = @import("callable_checking.zig"); /// Only local callee edges enter the fixed point; imported callees already /// have completed facts. The scan does no inference or type-graph mutation. fn publishCallableCheckingFacts(self: *Self) Allocator.Error!void { - const defs = self.cir.store.sliceDefs(self.cir.global_value_defs); - var graph = try callable_checking.Graph.init(self.gpa, defs.len); - defer graph.deinit(); + var definitions = std.ArrayList(CIR.Def.Idx).empty; + defer definitions.deinit(self.gpa); var ids = collections.DenseMap(CIR.Def.Idx, callable_checking.Id).init(self.gpa); defer ids.deinit(); - for (defs, 0..) |def, i| try ids.put(def, @fromBackingInt(@as(u32, @intCast(i)))); + // Checking can publish methods after canonicalization fixed the global + // definition span. Those explicit method bindings belong to the same + // eligibility graph as source globals and must receive producer facts. + for (self.cir.store.sliceDefs(self.cir.global_value_defs)) |def| { + const entry = try ids.getOrPut(def); + if (entry.found_existing) continue; + entry.value_ptr.* = @fromBackingInt(@as(u32, @intCast(definitions.items.len))); + try definitions.append(self.gpa, def); + } + for (self.cir.method_defs.entries.items) |method| { + const entry = try ids.getOrPut(method.value.def_idx); + if (entry.found_existing) continue; + entry.value_ptr.* = @fromBackingInt(@as(u32, @intCast(definitions.items.len))); + try definitions.append(self.gpa, method.value.def_idx); + } + const defs = definitions.items; + var graph = try callable_checking.Graph.init(self.gpa, defs.len); + defer graph.deinit(); var context = HoistedDependencyContext{}; defer context.deinit(self.gpa); diff --git a/src/check/value_identity.zig b/src/check/value_identity.zig index f0d22d4a49d..1226b18c8ac 100644 --- a/src/check/value_identity.zig +++ b/src/check/value_identity.zig @@ -417,8 +417,15 @@ const State = struct { } fn importedByIndex(self: *const State, index: CIR.Import.Idx) checked_artifact.ImportedModuleView { - const identity = self.inputs.env.importIdentity(index) orelse base.invariant("checked external lookup has no module identity", .{}); - return self.importedByIdentity(self.inputs.env.moduleIdentityHash(identity).*); + // External lookups use the resolved import scope supplied to checked + // publication. The drain's identity table need not contain imports + // introduced by checking (including implicit builtin imports). + const module_idx = self.inputs.env.imports.getResolvedModule(index) orelse + base.invariant("checked external lookup has no resolved module", .{}); + for (self.inputs.imports) |entry| { + if (entry.module_idx == module_idx) return entry.view; + } + base.invariant("checked external lookup has no published imported artifact", .{}); } fn importedByIdentity(self: *const State, identity: [32]u8) checked_artifact.ImportedModuleView { diff --git a/src/cli/main.zig b/src/cli/main.zig index ebcfa8e6252..5678c5230d6 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -11688,7 +11688,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu .gpa = ctx.gpa, .build_env = &build_env, .store = store, - .manager = &cache_manager, + .manager = build_env.cache_manager.?, .policy = programOutputPolicy(args, link_type), .linked_stores = if (linked_stores) |*stores| stores else null, .objects = &link_objects, @@ -11817,7 +11817,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu }; if (program_probe) |*probe| probe.runtime_emissions += 1; defer written.deinit(); - if (object_compiler.capture_artifacts) { + if (object_compiler.capture_excerpts) { object_compiler.captured_excerpts = written.excerpts; written.excerpts = null; } diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 03259b681ca..ec9370b44e8 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -58,7 +58,7 @@ pub const Store = struct { self.allocator.free(self.procedure_root); } - fn identity(self: *const Store, family: compile.artifacts.Family, module: [32]u8, key: [32]u8) compile.artifacts.Identity { + fn artifactIdentity(self: *const Store, family: compile.artifacts.Family, module: [32]u8, key: [32]u8) compile.artifacts.Identity { var result: compile.artifacts.Identity = .{ .namespace = undefined, .family = family, .group = undefined, .key = key, .pack_format = PackFile.format_version, .variant = self.variant }; std.crypto.hash.sha2.Sha256.hash(@import("build_options").compiler_compatibility_id, &result.namespace, .{}); var hash = std.crypto.hash.sha2.Sha256.init(.{}); @@ -84,7 +84,7 @@ pub const Store = struct { } pub fn linkObjectIdentity(self: *const Store, digest: [32]u8) compile.artifacts.Identity { - var id = self.identity(.program_output, @splat(0), digest); + var id = self.artifactIdentity(.program_output, @splat(0), digest); var hash = std.crypto.hash.sha2.Sha256.init(.{}); hash.update(&id.variant); hash.update("roc-native-link-object-v1"); @@ -112,11 +112,11 @@ pub const Store = struct { } pub fn readProgramPrefix(self: *const Store, key: [32]u8, len: usize, allocator: Allocator) Allocator.Error!?[]const u8 { - return self.readPrefix(self.identity(.program_output, @splat(0), key), len, allocator); + return self.readPrefix(self.artifactIdentity(.program_output, @splat(0), key), len, allocator); } fn packIdentity(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, key: [32]u8) compile.artifacts.Identity { - return self.identity(if (origin == .local) .local_pack else .package_pack, module, key); + return self.artifactIdentity(if (origin == .local) .local_pack else .package_pack, module, key); } fn read(self: *const Store, allocator: Allocator, id: compile.artifacts.Identity) Allocator.Error!?[]u8 { @@ -144,25 +144,25 @@ pub const Store = struct { pub fn writeDiscovery(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { var key: [32]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &key, .{}); - try self.writeEntry(self.identity(if (origin == .local) .local_discovery else .package_discovery, module, key), bytes); + try self.writeEntry(self.artifactIdentity(if (origin == .local) .local_discovery else .package_discovery, module, key), bytes); } pub fn readProgram(self: *const Store, key: [32]u8, allocator: Allocator) Allocator.Error!?[]u8 { - return self.read(allocator, self.identity(.program_output, @splat(0), key)); + return self.read(allocator, self.artifactIdentity(.program_output, @splat(0), key)); } pub fn writeProgram(self: *const Store, key: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { - try self.writeEntry(self.identity(.program_output, @splat(0), key), bytes); + try self.writeEntry(self.artifactIdentity(.program_output, @splat(0), key), bytes); } pub fn optimizedInputKey(self: *const Store, module: [32]u8) Allocator.Error!?[32]u8 { - const bytes = (try self.readPrefix(self.identity(.optimized_pack, module, @splat(0)), PackFile.object_input_key_prefix_len, self.allocator)) orelse return null; + const bytes = (try self.readPrefix(self.artifactIdentity(.optimized_pack, module, @splat(0)), PackFile.object_input_key_prefix_len, self.allocator)) orelse return null; defer self.allocator.free(bytes); return PackFile.objectInputKey(bytes); } pub fn replaceOptimized(self: *const Store, module: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { - try self.writeEntry(self.identity(.optimized_pack, module, @splat(0)), bytes); + try self.writeEntry(self.artifactIdentity(.optimized_pack, module, @splat(0)), bytes); } pub fn has(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, key: [32]u8) Allocator.Error!bool { @@ -178,7 +178,7 @@ pub const Store = struct { try self.writeEntry(self.packIdentity(origin, module, key), bytes); } - pub fn loadIdentity(self: *const Store, _: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, module: [32]u8) LoadedPacks.LoadError!void { + pub fn loadIdentity(self: *const Store, _: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, module: [32]u8) Allocator.Error!void { try packs.addIndexProvider(self, origin, module); } @@ -274,7 +274,7 @@ pub const Store = struct { } pub fn loadIdentityDiscovery(self: *const Store, _: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, module: [32]u8) LoadedPacks.LoadError!void { - try self.loadGroup(packs, self.identity(if (origin == .local) .local_discovery else .package_discovery, module, @splat(0)), .discovery); + try self.loadGroup(packs, self.artifactIdentity(if (origin == .local) .local_discovery else .package_discovery, module, @splat(0)), .discovery); } fn publishIndexed(self: *const Store, id: compile.artifacts.Identity, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!bool { @@ -613,7 +613,6 @@ pub const LinkedStores = struct { /// Selection was emitted by the checked producer. Its keys let an early /// completed-build lookup inspect current optimized stores without IR. pub fn selectionDigest(self: *const LinkedStores, selection: Selection) Allocator.Error![32]u8 { - const allocator = self.stores[0].allocator; var hasher = std.crypto.hash.Blake3.init(.{}); hasher.update("roc.optimization-state.v1"); for (&self.stores) |*store| { @@ -623,7 +622,7 @@ pub const LinkedStores = struct { } const identity = selection.optimized_identity orelse continue; const input_key = try store.optimizedInputKey(identity) orelse continue; - hasher.update(&store.identity(.optimized_pack, identity, @splat(0)).indexedKey()); + hasher.update(&store.artifactIdentity(.optimized_pack, identity, @splat(0)).indexedKey()); hasher.update(&input_key); } var digest: [32]u8 = undefined; @@ -1460,7 +1459,7 @@ pub const LoadedPacks = struct { try self.loadStoredObject(store, store.packIdentity(.pkg, module.identity, module.key)); } if (root_placement) |placement| { - try self.loadStoredObject(store, store.identity(.optimized_pack, placement.identity, @splat(0))); + try self.loadStoredObject(store, store.artifactIdentity(.optimized_pack, placement.identity, @splat(0))); } } try self.indexObjectPacks(try programBindingKey(self.allocator, pending.build_env, root_artifact)); diff --git a/src/compile/cache_cleanup.zig b/src/compile/cache_cleanup.zig index 5b0a838c4f1..2fd089b8c85 100644 --- a/src/compile/cache_cleanup.zig +++ b/src/compile/cache_cleanup.zig @@ -107,20 +107,3 @@ test "foreground events grant completed checkpoints and revoke optional admissio session.tickAt(now + maintenance.Policy.grant_interval, .{ .waiting = .{ .budget = 1, .tasks = 1 } }, &stats); try std.testing.expectEqual(@as(usize, 1), stats.units); // saturated one-worker progress } - -test "scratch cleanup preserves a live link lease and removes an abandoned one" { - const io = std.testing.io; - const allocator = std.testing.allocator; - var tmp = std.testing.tmpDir(.{}); - defer tmp.cleanup(); - try tmp.dir.createDirPath(io, "version/tmp/link"); - const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); - defer allocator.free(root); - var lease = try tmp.dir.createFile(io, "version/tmp/link/link.lock", .{ .read = true, .lock = .exclusive }); - const future = nowNs(io) + Config.TEMP_MAX_AGE_NS + std.time.ns_per_s; - cleanupScratchDirs(io, root, future, null); - try tmp.dir.access(io, "version/tmp/link", .{}); - lease.close(io); - cleanupScratchDirs(io, root, future, null); - try std.testing.expectError(error.FileNotFound, tmp.dir.access(io, "version/tmp/link", .{})); -} diff --git a/src/compile/cache_manager.zig b/src/compile/cache_manager.zig index e0de6ca977e..779b12fa6ca 100644 --- a/src/compile/cache_manager.zig +++ b/src/compile/cache_manager.zig @@ -125,7 +125,7 @@ pub const CacheManager = struct { fn familyFor(kind: Kind) artifacts.Family { return switch (kind) { - .checked => .checked, + .checked, .inference => .checked, .canonicalized => .canonicalized, }; } diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index f2fa15f4a06..4b451a3a2c0 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -11054,7 +11054,7 @@ test "issue 12592: a dependency test edit preserves importer checking results" { const config = CacheConfig{ .enabled = true, .cache_dir = cache_dir, .roc_ctx = CoreCtx.os(allocator, allocator, std.testing.io) }; const inference_dir = try config.getInferenceCacheDir(allocator); defer allocator.free(inference_dir); - try std.testing.expect(try overwriteFilesUnderDir(allocator, inference_dir, "truncated inference capsule") > 0); + try std.testing.expect(try overwriteFilesUnderDir(allocator, cache_dir, inference_dir, .checked, "truncated inference capsule") > 0); try tmp_dir.dir.writeFile(std.testing.io, .{ .sub_path = "checking_dependency/app/A.roc", .data = "A := {}.{ value : {} -> I64\n value = |_| 2\n}\nexpect A.value({}) == 2\n", From c6a6c3b55c00e5540544c1a5c053d3d2962f22c0 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 15:39:44 -0400 Subject: [PATCH 25/97] Name the error sets of the completion analysis test helpers --- src/postcheck/monotype/lower.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 0542bb87d79..f0e86dfadf9 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -66596,7 +66596,7 @@ const CompletionAnalysisFixture = struct { self.types.deinit(); } - fn expectRelation(self: *@This(), expected: BodyContext.RequestCompletion, request: NodeId, produced: NodeId) !void { + fn expectRelation(self: *@This(), expected: BodyContext.RequestCompletion, request: NodeId, produced: NodeId) (Allocator.Error || error{TestExpectedEqual})!void { var path = BodyContext.RequestCompletionPath.init(self.allocator); defer path.deinit(); for (0..2) |_| { @@ -66608,7 +66608,7 @@ const CompletionAnalysisFixture = struct { try std.testing.expectEqual(expected, try self.ctx.requestCompletionRelation(request, produced, &path, null)); } - fn expectUninhabited(self: *@This(), node: NodeId, access: BodyContext.UninhabitedBackingAccess, expected: bool) !void { + fn expectUninhabited(self: *@This(), node: NodeId, access: BodyContext.UninhabitedBackingAccess, expected: bool) (Allocator.Error || error{TestExpectedEqual})!void { for (0..2) |_| try std.testing.expectEqual(expected, try self.ctx.nodeIsProvenUninhabitedInner(node, access)); for (&self.ctx.node_uninhabited_memo) |*memo| memo.clearRetainingCapacity(); try std.testing.expectEqual(expected, try self.ctx.nodeIsProvenUninhabitedInner(node, access)); @@ -66847,7 +66847,7 @@ test "completion analysis does not publish a recursive inhabitation assumption" test "completion analysis scratch and partial memos survive every allocation failure" { const Probe = struct { - fn run(ctx: *BodyContext, inhabited: NodeId, request: NodeId, produced: NodeId) !bool { + fn run(ctx: *BodyContext, inhabited: NodeId, request: NodeId, produced: NodeId) (Allocator.Error || error{TestUnexpectedResult})!bool { try std.testing.expect(!try ctx.nodeIsProvenUninhabited(inhabited)); return ctx.resultCompletesRequest(request, produced); } From 7d43d9f3461e0a6b06fb840ceee26ba84f30c509 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 15:45:53 -0400 Subject: [PATCH 26/97] Wire the command record tests, document their types and name their error sets --- design.md | 8 ++++---- src/base/dev_diagnostics.zig | 2 ++ src/cli/completed_test.zig | 30 +++++++++++++++++++++------- src/cli/main.zig | 11 +++++++--- src/cli/test/parallel_cli_runner.zig | 4 +++- 5 files changed, 40 insertions(+), 15 deletions(-) diff --git a/design.md b/design.md index 15beaa7b896..9f8a8d9700e 100644 --- a/design.md +++ b/design.md @@ -23646,10 +23646,10 @@ runtime demand construction, specialization, CTFE, LIR/ARC, static data, native test code generation and execution. It does not load procedure packs. The command envelope and per-module result payloads carry content checksums. -Command source paths and diagnostic path elements are relative to the current -working directory and are rebound only after validation. Compilation diagnostics -are replayed from their document elements, before test observations; color, -verbosity and terminal width are current renderer inputs. The command's arena +Command source paths and the paths inside diagnostics are relative to the +current working directory and are rebound only after validation. Compilation +diagnostics are replayed from their structured document content, before test +observations; color, verbosity and terminal width are current renderer inputs. The command's arena owns validated source bytes and lightweight source environments throughout replay. ### Command-level execution diff --git a/src/base/dev_diagnostics.zig b/src/base/dev_diagnostics.zig index a1123e4a1c6..4d7e9146109 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -70,11 +70,13 @@ pub const TestWork = enum { }; var test_work: [@typeInfo(TestWork).@"enum".field_names.len]std.atomic.Value(u64) = @splat(.init(0)); +/// Add `amount` to a test-work counter when test command work tracing is on. pub fn countTestWork(counter: TestWork, amount: usize) void { if (!isSet(.test_command_work)) return; _ = test_work[@backingInt(counter)].fetchAdd(amount, .monotonic); } +/// The number of times a stage has counted this test work. pub fn testWorkCount(counter: TestWork) u64 { return test_work[@backingInt(counter)].load(.monotonic); } diff --git a/src/cli/completed_test.zig b/src/cli/completed_test.zig index 66cb7005e0a..4d2f38a96b8 100644 --- a/src/cli/completed_test.zig +++ b/src/cli/completed_test.zig @@ -6,22 +6,31 @@ const reporting = @import("reporting"); const compile = @import("compile"); const Allocator = std.mem.Allocator; +/// Prefix of every command record; changing the record format changes it. pub const magic = "ROC_COMPLETED_DEV_TEST_V1"; +/// A SHA-256 content digest. pub const Digest = [32]u8; +/// A file a producer read, with the content state it saw. pub const Input = struct { path: []const u8, state: compile.watch_inputs.State, }; +/// A module path and the cwd-relative physical file the import resolver admitted for it. pub const Resolution = struct { path: []const u8, target: []const u8 }; +/// Everything a command's outcome depends on outside the compiler, in sorted order. pub const Plan = struct { inputs: []Input, resolutions: []Resolution }; +/// A module's qualified-name digest and its cwd-relative source path. pub const Source = struct { name: Digest, path: []const u8 }; +/// A test root's execution order and source region. pub const Root = struct { order: u32, region: base.Region }; +/// A tested module: its source, its per-module result key and payload digest, and its roots. pub const Module = struct { source: u32, key: Digest, payload_hash: Digest, roots: []const Root, }; +/// A non-error report from compilation, stored as its structured document content. pub const Diagnostic = struct { title: []const u8, severity: reporting.Severity, @@ -29,11 +38,13 @@ pub const Diagnostic = struct { headline: []reporting.DocumentElement, body: []reporting.DocumentElement, + /// Borrow a report's content; the caller keeps the report alive until encoding. pub fn fromReport(report: *const reporting.Report) Diagnostic { return .{ .title = report.title, .severity = report.severity, .file = report.file, .headline = report.headline.elements.items, .body = report.document.elements.items }; } - pub fn render(self: Diagnostic, allocator: Allocator, writer: *std.Io.Writer, config: reporting.ReportingConfig) !void { + /// Render the stored report with the current terminal configuration. + pub fn render(self: Diagnostic, allocator: Allocator, writer: *std.Io.Writer, config: reporting.ReportingConfig) (Allocator.Error || std.Io.Writer.Error)!void { // The record arena owns all document content. Rendering borrows it. var report = reporting.Report{ .title = self.title, @@ -48,6 +59,7 @@ pub const Diagnostic = struct { try reporting.renderReportToTerminal(&report, writer, reporting.ColorUtils.getPaletteForConfig(config), config); } }; +/// A complete test command's outcome, valid only for the plan whose key is `input_key`. pub const Record = struct { input_key: Digest, sources: []const Source, @@ -57,6 +69,7 @@ pub const Record = struct { modules_with_tests: u32, }; +/// The digest used for every input, payload and record in the command cache. pub fn hash(bytes: []const u8) Digest { return compile.watch_inputs.hashBytes(bytes); } @@ -99,13 +112,15 @@ fn put(allocator: Allocator, bytes: *std.ArrayList(u8), value: anytype) Allocato .@"struct" => |s| inline for (s.field_names) |field| { try put(allocator, bytes, @field(value, field)); }, - .@"union" => { - try put(allocator, bytes, std.meta.activeTag(value)); - switch (value) { - inline else => |v| try put(allocator, bytes, v), + .@"union" => |u| { + const tag = std.meta.activeTag(value); + try put(allocator, bytes, tag); + inline for (u.field_names) |field| { + if (std.mem.eql(u8, @tagName(tag), field)) return put(allocator, bytes, @field(value, field)); } + unreachable; // the active tag names one of the union's declared fields }, - else => @compileError("unsupported command record field " ++ @typeName(T)), + .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported command record field " ++ @typeName(T)), } } @@ -157,11 +172,12 @@ const Reader = struct { } unreachable; // the decoded union tag names one of its declared fields }, - else => @compileError("unsupported command record field " ++ @typeName(T)), + .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported command record field " ++ @typeName(T)), }; } }; +/// Decode a checksummed record into `allocator`; a damaged or foreign record is null. pub fn decode(allocator: Allocator, comptime T: type, bytes: []const u8) Allocator.Error!?T { if (bytes.len < magic.len + 32 or !std.mem.startsWith(u8, bytes, magic)) return null; const body = bytes[0 .. bytes.len - 32]; diff --git a/src/cli/main.zig b/src/cli/main.zig index 17bc2a7b267..51c719a9737 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -180,6 +180,7 @@ comptime { std.testing.refAllDecls(cli_context); std.testing.refAllDecls(cli_problem); std.testing.refAllDecls(pack_store); + std.testing.refAllDecls(completed_test); std.testing.refAllDecls(@import("builder.zig")); std.testing.refAllDecls(@import("host_symbols.zig")); std.testing.refAllDecls(@import("test/platform_config.zig")); @@ -12886,7 +12887,11 @@ fn cliCompletedTestSeed(allocator: Allocator, build: *BuildEnv, args: cli_args.T return completed_test.hash(bytes); } -fn cliCompletedTestDir(allocator: Allocator, manager: *CacheManager) ![]const u8 { +/// Locating the test cache directory needs a home directory; the rest of the +/// completed-test cache work fails only on allocation. +const CliCompletedTestError = Allocator.Error || error{NoHomeDirectory}; + +fn cliCompletedTestDir(allocator: Allocator, manager: *CacheManager) CliCompletedTestError![]const u8 { const test_dir = try manager.config.getTestCacheDir(allocator); return std.fs.path.join(allocator, &.{ test_dir, "commands" }); } @@ -12940,7 +12945,7 @@ const CliCompletedTest = struct { } }; -fn loadCliCompletedTest(ctx: *CliCtx, arena: Allocator, build: *BuildEnv, seed: [32]u8) !?CliCompletedTest { +fn loadCliCompletedTest(ctx: *CliCtx, arena: Allocator, build: *BuildEnv, seed: [32]u8) CliCompletedTestError!?CliCompletedTest { const manager = build.cache_manager orelse return null; const directory = try cliCompletedTestDir(arena, manager); const plan_bytes = manager.loadRawBytes(seed, directory) orelse return null; @@ -13013,7 +13018,7 @@ fn storeCliCompletedTest( module_results: []const CliModuleTestResult, debug_messages: []const []const u8, diagnostics: []const BuildEnv.DrainedModuleReports, -) !void { +) CliCompletedTestError!void { const manager = build.cache_manager orelse return; for (module_results) |module| for (module.results) |result| { if (!result.cacheable or result.result == .compiler_error) return; diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 95e6eecabeb..8468fe93fba 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -13694,7 +13694,9 @@ fn customCompletedTestCommand(io: std.Io, allocator: Allocator, env: *const Case /// Damage only this case's isolated cache. Command records and referenced /// module payloads must both be admitted before any observation is replayed. -fn damageCompletedTestCache(io: std.Io, allocator: Allocator, cache_path: []const u8, remove_payload: bool) !bool { +const DamageCompletedTestCacheError = std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || std.Io.Dir.ReadFileAllocError || std.Io.Dir.DeleteFileError || std.Io.Dir.WriteFileError; + +fn damageCompletedTestCache(io: std.Io, allocator: Allocator, cache_path: []const u8, remove_payload: bool) DamageCompletedTestCacheError!bool { var directory = try std.Io.Dir.cwd().openDir(io, cache_path, .{ .iterate = true }); defer directory.close(io); var walker = try directory.walk(allocator); From e37277bd43b8309812a96a18b0788c1fd74cb27c Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 15:49:07 -0400 Subject: [PATCH 27/97] Give method definitions stored-constant eligibility and key inference reuse by source-pin validation --- design.md | 9 ++++++--- src/canonicalize/ModuleEnv.zig | 14 ++++++++------ src/check/Check.zig | 29 ++++++++++++++++++++++++----- src/check/callable_checking.zig | 18 +++++++++++++----- src/check/checked_artifact.zig | 4 +++- src/compile/compile_package.zig | 3 ++- src/compile/coordinator.zig | 15 +++++++++++++++ src/compile/messages.zig | 4 ++++ 8 files changed, 75 insertions(+), 21 deletions(-) diff --git a/design.md b/design.md index 89ec1ce3bf9..2b231a87ce2 100644 --- a/design.md +++ b/design.md @@ -4995,8 +4995,9 @@ the explicitly rejected edges described under Module Completion Boundary. The inference-result cache stores the solved source environment together with the checker-selected publication inputs, before evaluation. Its key includes -the module's own source inputs, validation and resolution context, and the -checking dependencies it consumed. Serialized references retain their explicit +the module's own source inputs, validation (including the source-pin +validation input canonicalization reported against) and resolution context, +and the checking dependencies it consumed. Serialized references retain their explicit owner and declaration bindings; admission must establish those bindings against the current dependency publications. Equality of a signature, a name or an incidental node index is never a license to substitute a declaration. Source @@ -5034,7 +5035,9 @@ publication, reference binding and artifact publication, so interface creation cannot hide a second checking pass. Stored-constant eligibility of a foreign callable is a producer fact. Checking -scans each module-global definition's own body once, records the exact local +scans the own body of each module-global definition and of each method +binding's definition (block-local nominal types own method definitions inside +bodies, and resolved associated lookups reach them) once, records the exact local callee edges and current imported eligibility facts, and propagates rejection backwards through those edges to a fixed point. A recursive component with no rejected body or outgoing dependency is eligible. Importers consume the diff --git a/src/canonicalize/ModuleEnv.zig b/src/canonicalize/ModuleEnv.zig index 78a49a7bfe1..69a6f023234 100644 --- a/src/canonicalize/ModuleEnv.zig +++ b/src/canonicalize/ModuleEnv.zig @@ -888,8 +888,9 @@ pub const BindingScheme = extern struct { pub const SafeList = collections.SafeList(@This()); }; -/// Import-facing eligibility of a module-global callable for stored constants. -/// Checking publishes one row per global definition, ordered by definition id. +/// Import-facing eligibility of a callable definition for stored constants. +/// Checking publishes one row per module-global definition and per method +/// binding's definition, ordered by definition id. /// The body and its transitive callee dependencies are owned by that checker. pub const CallableCheckingFact = extern struct { def: CIR.Def.Idx, @@ -1069,7 +1070,7 @@ top_level_demand_dependencies_ready: bool, /// Demand"): functions whose bodies can, and definitions holding values. /// Checking produces it with the strict-demand relation; importers read it. invoking_defs: collections.SafeList(CIR.Def.Idx), -/// Exact stored-constant eligibility of each global callable, published after +/// Exact stored-constant eligibility of each global or method callable, published after /// checking settles its body. Importers consume this instead of scanning CIR. callable_checking_facts: CallableCheckingFact.SafeList, checking_dependency: CheckingDependency, @@ -1664,8 +1665,9 @@ pub fn defInvokes(self: *const Self, def_idx: CIR.Def.Idx) bool { return false; } -/// Import-facing stored-constant eligibility. Every global definition has a -/// producer row, including rejected definitions; absence is a compiler bug. +/// Import-facing stored-constant eligibility. Every global definition and every +/// method binding's definition has a producer row, including rejected +/// definitions; absence is a compiler bug. pub fn callableAllowsStoredConst(self: *const Self, def_idx: CIR.Def.Idx) bool { const facts = self.callable_checking_facts.items.items; var low: usize = 0; @@ -1680,7 +1682,7 @@ pub fn callableAllowsStoredConst(self: *const Self, def_idx: CIR.Def.Idx) bool { return facts[mid].allows_stored_const != 0; } } - base.invariant("checked global callable has no producer-owned stored-constant eligibility", .{}); + base.invariant("checked callable definition has no producer-owned stored-constant eligibility", .{}); } /// Return the producer-authored low-level implementation for `def_idx`. diff --git a/src/check/Check.zig b/src/check/Check.zig index 71505895d79..023b64dd8d9 100644 --- a/src/check/Check.zig +++ b/src/check/Check.zig @@ -13890,13 +13890,20 @@ const callable_checking = @import("callable_checking.zig"); /// The declaration/body producer establishes foreign-callable eligibility. /// Only local callee edges enter the fixed point; imported callees already /// have completed facts. The scan does no inference or type-graph mutation. +/// +/// A callable reference resolves either to a module-global definition or, +/// through a resolved associated lookup, to a method binding's definition, +/// which a block-local nominal type owns inside a body. Both are vertices. fn publishCallableCheckingFacts(self: *Self) Allocator.Error!void { - const defs = self.cir.store.sliceDefs(self.cir.global_value_defs); - var graph = try callable_checking.Graph.init(self.gpa, defs.len); - defer graph.deinit(); + var domain: std.ArrayList(CIR.Def.Idx) = .empty; + defer domain.deinit(self.gpa); var ids = collections.DenseMap(CIR.Def.Idx, callable_checking.Id).init(self.gpa); defer ids.deinit(); - for (defs, 0..) |def, i| try ids.put(def, @fromBackingInt(@as(u32, @intCast(i)))); + for (self.cir.store.sliceDefs(self.cir.global_value_defs)) |def| try self.addCallableCheckingVertex(&domain, &ids, def); + for (self.cir.method_defs.entries.items) |entry| try self.addCallableCheckingVertex(&domain, &ids, entry.value.def_idx); + const defs = domain.items; + var graph = try callable_checking.Graph.init(self.gpa, defs.len); + defer graph.deinit(); var context = HoistedDependencyContext{}; defer context.deinit(self.gpa); @@ -13932,6 +13939,18 @@ fn publishCallableCheckingFacts(self: *Self) Allocator.Error!void { self.cir.callable_checking_facts = facts; } +fn addCallableCheckingVertex( + self: *Self, + domain: *std.ArrayList(CIR.Def.Idx), + ids: *collections.DenseMap(CIR.Def.Idx, callable_checking.Id), + def: CIR.Def.Idx, +) Allocator.Error!void { + const entry = try ids.getOrPut(def); + if (entry.found_existing) return; + entry.value_ptr.* = @fromBackingInt(std.math.cast(u32, domain.items.len) orelse return error.OutOfMemory); + try domain.append(self.gpa, def); +} + fn hoistedRootCalleeAllowsStoredConst( self: *Self, callee: CIR.Expr.Idx, @@ -14003,7 +14022,7 @@ const StoredConstScan = struct { } if (scan.publication) |publication| { const callee = publication.ids.get(callable_def.def) orelse - base.invariant("a callable dependency resolves to a published global definition", .{}); + base.invariant("a callable dependency resolves to a published callable definition", .{}); try publication.graph.addDependency(publication.caller, callee); return .{ .value = true }; } diff --git a/src/check/callable_checking.zig b/src/check/callable_checking.zig index d25966ee1e0..cefe6ebeba9 100644 --- a/src/check/callable_checking.zig +++ b/src/check/callable_checking.zig @@ -1,4 +1,4 @@ -//! Producer-owned stored-constant eligibility of global callables. +//! Producer-owned stored-constant eligibility of callable definitions. //! A rejection propagates to every caller, including every member of a cycle. const std = @import("std"); @@ -20,6 +20,7 @@ pub const Graph = struct { edges: std.ArrayList(Edge) = .empty, rejected: std.ArrayList(Id) = .empty, + /// A graph over `count` callables, all eligible until rejected. pub fn init(allocator: Allocator, count: usize) Allocator.Error!Graph { if (count > std.math.maxInt(u32)) return error.OutOfMemory; const heads = try allocator.alloc(u32, count); @@ -33,6 +34,7 @@ pub const Graph = struct { return .{ .allocator = allocator, .heads = heads, .allowed = allowed, .rejected = rejected }; } + /// Free the graph's storage. pub fn deinit(self: *Graph) void { self.allocator.free(self.heads); self.allocator.free(self.allowed); @@ -40,6 +42,7 @@ pub const Graph = struct { self.rejected.deinit(self.allocator); } + /// Record that `caller`'s body reaches `callee`. pub fn addDependency(self: *Graph, caller: Id, callee: Id) Allocator.Error!void { const index = std.math.cast(u32, self.edges.items.len) orelse return error.OutOfMemory; if (index == none) return error.OutOfMemory; @@ -47,6 +50,7 @@ pub const Graph = struct { self.heads[@backingInt(callee)] = index; } + /// Mark a callable whose own body is ineligible. pub fn reject(self: *Graph, id: Id) void { if (!self.allowed[@backingInt(id)]) return; self.allowed[@backingInt(id)] = false; @@ -68,6 +72,7 @@ pub const Graph = struct { } } + /// The settled eligibility of `id`; valid after `finish`. pub fn allowsStoredConst(self: *const Graph, id: Id) bool { if (self.rejected.items.len > self.allowed.len) base.invariant("a callable eligibility vertex is rejected at most once", .{}); return self.allowed[@backingInt(id)]; @@ -97,9 +102,12 @@ test "callable checking propagates observations through cycles and preserves ind test "callable checking preserves an unobserved recursive component" { var graph = try Graph.init(std.testing.allocator, 2); defer graph.deinit(); - try graph.addDependency(@fromBackingInt(0), @fromBackingInt(1)); - try graph.addDependency(@fromBackingInt(1), @fromBackingInt(0)); + // The graph's two definitions, which depend on each other. + const first_def: u32 = 0; + const second_def: u32 = 1; + try graph.addDependency(@fromBackingInt(first_def), @fromBackingInt(second_def)); + try graph.addDependency(@fromBackingInt(second_def), @fromBackingInt(first_def)); graph.finish(); - try std.testing.expect(graph.allowsStoredConst(@fromBackingInt(0))); - try std.testing.expect(graph.allowsStoredConst(@fromBackingInt(1))); + try std.testing.expect(graph.allowsStoredConst(@fromBackingInt(first_def))); + try std.testing.expect(graph.allowsStoredConst(@fromBackingInt(second_def))); } diff --git a/src/check/checked_artifact.zig b/src/check/checked_artifact.zig index ba1872cbcd6..ac4b54c37a2 100644 --- a/src/check/checked_artifact.zig +++ b/src/check/checked_artifact.zig @@ -37448,10 +37448,11 @@ pub fn inferenceKeyFromModuleEnv( platform_requirement_owner: ?u32, explicit_roots: []const ExplicitRootRequestInput, validation: can.Can.Validation, + source_pin_validation: [32]u8, missing_public_types: []const []const u8, ) [32]u8 { var hasher = base.Sha256.init(.{}); - hasher.update("roc-inference-inputs-v1"); + hasher.update("roc-inference-inputs-v2"); hasher.update(&hashModuleSourceInputs(module_env)); hasher.update(&scope_hash); hashByteSlice(&hasher, module_env.module_name); @@ -37462,6 +37463,7 @@ pub fn inferenceKeyFromModuleEnv( hashU32(&hasher, owner); } else hasher.update(&.{0}); hashByteSlice(&hasher, @tagName(validation)); + hasher.update(&source_pin_validation); hashU32(&hasher, @intCast(explicit_roots.len)); for (explicit_roots) |root| hashExplicitRootRequestInput(&hasher, root); const imports = module_env.imports.imports.items.items; diff --git a/src/compile/compile_package.zig b/src/compile/compile_package.zig index 28df4b7b709..21bfe79c0e0 100644 --- a/src/compile/compile_package.zig +++ b/src/compile/compile_package.zig @@ -497,6 +497,7 @@ pub fn typeCheckModule( platform_requirement_context: ?CheckedArtifact.PlatformRequirementContextKey, explicit_roots: []const CheckedArtifact.ExplicitRootRequestInput, validation: Can.Validation, + source_pin_validation: [32]u8, ctfe_options: eval.CompileTimeFinalization.Options, deferred_imports: can.ImportResolution.Imports, missing_public_types: []const []const u8, @@ -543,7 +544,7 @@ pub fn typeCheckModule( checking_scope.ordinal(requirements.env.contentIdentityHash().?.*) else null; - const inference_key = CheckedArtifact.inferenceKeyFromModuleEnv(env, checking_scope.digest(), requirement_owner, explicit_roots, validation, missing_public_types); + const inference_key = CheckedArtifact.inferenceKeyFromModuleEnv(env, checking_scope.digest(), requirement_owner, explicit_roots, validation, source_pin_validation, missing_public_types); const loaded = if (inference_cache) |cache| try cache.load(inference_key, env, &checking_scope) else null; defer if (loaded) |capsule| capsule.deinit(); diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index 84bc4fc4a9f..c082d5b4d0f 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -5359,6 +5359,7 @@ pub const Coordinator = struct { .platform_requirements = platform_surface, .explicit_roots = explicit_roots, .validation = mod.validation, + .source_pin_validation = canonicalized_cache_entry.pinValidationHash(mod.has_source_version_pin, self.source_pin_version), }, }); } @@ -6738,6 +6739,7 @@ pub const Coordinator = struct { if (task.platform_requirements) |surface| surface.context else null, task.explicit_roots, task.validation, + task.source_pin_validation, ctfe_options, .{ .explicit = task.deferred_imports }, task.missing_public_types, @@ -10348,11 +10350,18 @@ test "Coordinator corrupt checked module cache entries compile from source" { defer allocator.free(checked_module_cache_dir); const overwritten = try overwriteFilesUnderDir(allocator, checked_module_cache_dir, "not a checked module cache entry"); try std.testing.expect(overwritten > 0); + // Inference entries also restore checked environments; corrupting them as + // well leaves source as the only admissible input. + const inference_cache_dir = try config.getInferenceCacheDir(allocator); + defer allocator.free(inference_cache_dir); + const overwritten_inference = try overwriteFilesUnderDir(allocator, inference_cache_dir, "not an inference cache entry"); + try std.testing.expect(overwritten_inference > 0); const second = try compileAppWithCheckedModuleCache(allocator, cache_dir, "test/str/app_message.roc"); try std.testing.expectEqual(@as(u32, 0), second.build.cache_hits); try std.testing.expect(second.build.modules_compiled > 0); try std.testing.expect(second.cache.invalidations > 0); + try std.testing.expect(second.cache.inference_invalidations > 0); } test "Coordinator corrupt checked module cache env relocations compile from source" { @@ -10371,6 +10380,12 @@ test "Coordinator corrupt checked module cache env relocations compile from sour defer allocator.free(checked_module_cache_dir); const corrupted = try corruptCheckedModuleEnvIdentBytesLens(allocator, checked_module_cache_dir); try std.testing.expect(corrupted > 0); + // Inference entries also restore checked environments; corrupting them as + // well leaves source as the only admissible input. + const inference_cache_dir = try config.getInferenceCacheDir(allocator); + defer allocator.free(inference_cache_dir); + const overwritten_inference = try overwriteFilesUnderDir(allocator, inference_cache_dir, "not an inference cache entry"); + try std.testing.expect(overwritten_inference > 0); const second = try compileAppWithCheckedModuleCache(allocator, cache_dir, "test/str/app_message.roc"); try std.testing.expect(second.build.modules_compiled > 0); diff --git a/src/compile/messages.zig b/src/compile/messages.zig index 37f8008a0e6..531c79ea751 100644 --- a/src/compile/messages.zig +++ b/src/compile/messages.zig @@ -197,6 +197,10 @@ pub const TypeCheckTask = struct { /// an app root's entrypoint contract with its platform is enforced, and /// participates in the checked-artifact cache identity. validation: can.Can.Validation = .checking, + /// The source-pin validation input canonicalization reported against. + /// Inference admission keys it, as checked-entry admission does, because + /// a reused inference result replaces the canonicalized environment. + source_pin_validation: [32]u8, }; /// The platform root's requirement surface, borrowed from its completed From 7c134fd7e86f4265588c9e69be0efba868e56e3b Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 16:19:42 -0400 Subject: [PATCH 28/97] Reuse inference across declaration layout changes and checking diagnostics --- design.md | 60 +++-- src/canonicalize/ModuleEnv.zig | 11 +- src/check/checking_dependencies.zig | 6 +- src/check/checking_interface.zig | 124 +++++++++-- src/check/checking_outcome.zig | 171 ++++++++++++++ src/check/inference_output.zig | 133 ++++++++++- src/check/problem/store.zig | 4 +- src/check/problem/types.zig | 4 +- src/check/reference_binding.zig | 320 +++++++++++++++++++++++++++ src/cli/test/parallel_cli_runner.zig | 4 +- src/compile/cache_config.zig | 3 +- src/compile/cache_versioning.zig | 4 +- src/compile/compile_build.zig | 2 + src/compile/compile_package.zig | 8 +- src/compile/coordinator.zig | 134 ++++++++++- src/compile/inference_cache.zig | 30 ++- src/compile/messages.zig | 2 + src/types/store.zig | 61 ++++- 18 files changed, 1019 insertions(+), 62 deletions(-) create mode 100644 src/check/checking_outcome.zig create mode 100644 src/check/reference_binding.zig diff --git a/design.md b/design.md index 89ec1ce3bf9..2450f0f0628 100644 --- a/design.md +++ b/design.md @@ -5002,18 +5002,36 @@ the current dependency publications. Equality of a signature, a name or an incidental node index is never a license to substitute a declaration. Source regions that are retained as checking input participate in admission as well. -The first format seals the producer's declaration-reference layout as part of -its interface: every reachable source declaration/binding/annotation index is -encoded with the facts it names. A changed layout misses, even if its printed -types are identical. Admission therefore proves the retained foreign indices -still name the same published checking facts; it never searches for a matching -name or guesses an index correspondence. Only the dense module-identity table -is rebound, using the ordered owner parameters and their equality partition. -Source indices and solved graphs are not rewritten. The import-facing type -projection uses the ordinary import graph copier, preserving sharing, cycles, -descriptor flags, nominal templates and dispatch metadata; names are encoded -as text and owner identities as parameters. Section lengths, source ordering -and sorted exposures make the encoding independent of hash-map iteration. +The producer assigns declaration-reference slots while publishing its checking +interface. Slots are first-encounter ordinals in the interface's deterministic +traversal: sorted exposures, declared types and methods, and their binding and +annotation children. They are scoped to that complete interface, not global +identities or a name-matching scheme. The publication carries a parallel table +from slot to current CIR node. The digest encodes slots and their checking facts, +never the table's incidental CIR indices. Changed traversal structure or checking +facts changes the interface and invalidates importers. Builtin references remain +fixed within the compiler compatibility domain. + +An inference capsule carries the old dependency reference tables and their owner +parameters. Admission establishes equal interfaces and owner equality partitions +before translating each retained foreign reference through old node -> published +slot -> current node. Translation covers CIR value, associated, nominal, pattern +and annotation references, suffix and hosted targets, method-owner keys, nominal +and alias source declarations, record defaults, and their type-store indices. +Dispatch evidence's type variables and relations are local to the stored solved +graph and are preserved; only their foreign declaration locators change. A +reference without a published correspondence rejects the optional capsule. No +name, printed signature or numerical coincidence supplies a missing mapping. +This relocation changes no solved type relationship or dispatch decision. + +Diagnostic provenance has a separate admission contract. Stored outcomes retain +checking problems and canonicalization diagnostics in order, together with all +reporting data they require. Local source is already an exact cache-key input; +foreign source-dependent diagnostics require explicit current-source validation +and otherwise miss. Evaluation diagnostics and empirical exhaustiveness outcomes +are produced after publication against current implementations. Restored checking +errors follow ordinary reporting and rejection, including publication's erroneous +code exclusions; a cache hit cannot turn an error into successful executable code. A hit binds the inference result to current owners and publishes a new checked artifact against current imported artifacts. Publication consumes the stored @@ -5027,10 +5045,12 @@ contract in the existing cache infrastructure. Missing, incompatible or corrupt entries are cache misses. Dependency diagnostics are produced by each current dependency's ordinary compilation. An inference result with checking reports is not admitted unless those reports and their current provenance are explicitly -part of the stored result. The first format stores only diagnostic-free -inference, and always reports evaluation diagnostics from current publication. -Work counters distinguish inference, interface -publication, reference binding and artifact publication, so interface creation +part of the stored result. Checking outcomes include snapshots, their formatted +text, checker regions, and pending static-exhaustiveness recipes. Platform-side +source regions require an exact current-platform-source digest; otherwise the +capsule misses. Evaluation diagnostics always come from current publication. +Work counters distinguish inference, interface publication, owner and declaration +reference binding, diagnostic replay and artifact publication, so interface creation cannot hide a second checking pass. Stored-constant eligibility of a foreign callable is a producer fact. Checking @@ -13164,6 +13184,14 @@ site to any family below must classify it here. Other solved-graph mutations: +- `Store.rebindSourceReferences`—mechanism: inference-cache declaration + relocation (Checking dependencies and inference reuse). Equal producer + interfaces establish the declaration correspondence before this store-owned + operation translates nominal/alias/default locators and the nominal lookup + index. It preserves every variable, equivalence class, descriptor flag, + structural edge, and dispatch decision. It also normalizes the private + import-facing type projection to producer slots before hashing it. + - `checkConcreteBuiltinNumeral`—mechanism: Concrete Builtin Numeral Introduction (above). Uses the existing `unifyWith` descriptor-introduction mechanism for a proved concrete builtin conversion; does not mutate the diff --git a/src/canonicalize/ModuleEnv.zig b/src/canonicalize/ModuleEnv.zig index 78a49a7bfe1..3b64b4219e2 100644 --- a/src/canonicalize/ModuleEnv.zig +++ b/src/canonicalize/ModuleEnv.zig @@ -904,29 +904,36 @@ pub const CallableCheckingFact = extern struct { pub const CheckingDependency = struct { digest: [32]u8 = @splat(0), owners: collections.SafeList(base.ModuleIdentity.Hash) = .{}, + /// Producer-owned interface slot to current source node. + references: collections.SafeList(u32) = .{}, published: bool = false, pub fn deinit(self: *CheckingDependency, allocator: Allocator) void { self.owners.deinit(allocator); + self.references.deinit(allocator); } pub const Serialized = extern struct { digest: [32]u8, owners: collections.SafeList(base.ModuleIdentity.Hash).Serialized, + references: collections.SafeList(u32).Serialized, published: u64, pub fn serialize(self: *@This(), value: *const CheckingDependency, allocator: Allocator, writer: *CompactWriter) Allocator.Error!void { self.digest = value.digest; self.published = @intFromBool(value.published); try self.owners.serialize(&value.owners, allocator, writer); + try self.references.serialize(&value.references, allocator, writer); } pub fn deserializeInto(self: *const @This(), address: usize) CheckingDependency { - return .{ .digest = self.digest, .owners = self.owners.deserializeInto(address), .published = self.published != 0 }; + return .{ .digest = self.digest, .owners = self.owners.deserializeInto(address), .references = self.references.deserializeInto(address), .published = self.published != 0 }; } pub fn deserializeWithCopy(self: *const @This(), address: usize, allocator: Allocator) Allocator.Error!CheckingDependency { - return .{ .digest = self.digest, .owners = try self.owners.deserializeWithCopy(address, allocator), .published = self.published != 0 }; + var owners = try self.owners.deserializeWithCopy(address, allocator); + errdefer owners.deinit(allocator); + return .{ .digest = self.digest, .owners = owners, .references = try self.references.deserializeWithCopy(address, allocator), .published = self.published != 0 }; } }; }; diff --git a/src/check/checking_dependencies.zig b/src/check/checking_dependencies.zig index 985e9faed0e..94c9aa7739e 100644 --- a/src/check/checking_dependencies.zig +++ b/src/check/checking_dependencies.zig @@ -13,7 +13,7 @@ const Hash = base.ModuleIdentity.Hash; /// Domain-separated checking identity format. Its version is also folded into /// inference-cache admission; it is never an implementation-artifact key. -pub const format_version: u32 = 1; +pub const format_version: u32 = 2; /// Encode an interface scalar in fixed-width little-endian order. pub fn hashU32(hasher: *base.Sha256, value: u32) void { @@ -34,6 +34,8 @@ pub fn hashBytes(hasher: *base.Sha256, bytes: []const u8) Allocator.Error!void { pub const Scope = struct { allocator: Allocator, owners: std.ArrayList(Hash) = .empty, + imported: []const *const ModuleEnv = &.{}, + environments: std.AutoHashMapUnmanaged(Hash, *const ModuleEnv) = .empty, ordinals: std.AutoHashMapUnmanaged(Hash, u32) = .empty, hasher: base.Sha256 = base.Sha256.init(.{}), @@ -48,6 +50,7 @@ pub const Scope = struct { pub fn deinit(self: *Scope) void { self.owners.deinit(self.allocator); self.ordinals.deinit(self.allocator); + self.environments.deinit(self.allocator); } pub fn appendOwner(self: *Scope, identity: Hash) Allocator.Error!u32 { @@ -65,6 +68,7 @@ pub const Scope = struct { pub fn appendDependency(self: *Scope, env: *const ModuleEnv) Allocator.Error!void { const identity = env.contentIdentityHash() orelse base.invariant("checking dependency owners have finalized content identities", .{}); + try self.environments.put(self.allocator, identity.*, env); const owner_ordinal = try self.appendOwner(identity.*); hashU32(&self.hasher, owner_ordinal); if (!env.checking_dependency.published) { diff --git a/src/check/checking_interface.zig b/src/check/checking_interface.zig index 76a5f0495a4..690df4b03d6 100644 --- a/src/check/checking_interface.zig +++ b/src/check/checking_interface.zig @@ -37,6 +37,7 @@ pub fn makeScope( base.invariant("checking interface publication follows module identity finalization", .{}); var scope = try dependencies.Scope.init(allocator, own.*); errdefer scope.deinit(); + scope.imported = imported; if (builtin_env) |builtin_module| { if (!base.ModuleIdentity.eql(own, builtin_module.contentIdentityHash().?)) { try scope.appendDependency(builtin_module); @@ -67,14 +68,19 @@ pub fn publish( // Builtin is compiler-owned input, fixed for the compiler compatibility // domain. Its complete content identity is its checking contract. + var references: collections.SafeList(u32) = .{}; + errdefer references.deinit(env.gpa); const digest = if (env.module_role == .builtin) env.contentIdentityHash().?.* else blk: { var publication = try Publication.init(env, scope); defer publication.deinit(); try publication.writeInterface(classify, binding_owner); - break :blk try publication.finish(); + const result = try publication.finish(); + references = publication.references; + publication.references = .{}; + break :blk result; }; env.checking_dependency.deinit(env.gpa); - env.checking_dependency = .{ .digest = digest, .owners = owners, .published = true }; + env.checking_dependency = .{ .digest = digest, .owners = owners, .references = references, .published = true }; } const Publication = struct { @@ -84,6 +90,8 @@ const Publication = struct { copied: collections.DenseMap(Var, Var), seen_nodes: collections.DenseMap(CIR.Node.Idx, void), seen_annos: collections.DenseMap(CIR.TypeAnno.Idx, void), + slots: collections.DenseMap(CIR.Node.Idx, u32), + references: collections.SafeList(u32) = .{}, annos: std.ArrayList(CIR.TypeAnno.Idx) = .empty, hasher: base.Sha256 = base.Sha256.init(.{}), @@ -98,8 +106,9 @@ const Publication = struct { .copied = collections.DenseMap(Var, Var).init(env.gpa), .seen_nodes = collections.DenseMap(CIR.Node.Idx, void).init(env.gpa), .seen_annos = collections.DenseMap(CIR.TypeAnno.Idx, void).init(env.gpa), + .slots = collections.DenseMap(CIR.Node.Idx, u32).init(env.gpa), }; - result.hasher.update("roc-checking-interface-v1"); + result.hasher.update("roc-checking-interface-v2"); result.hasher.update(&scope.digest()); return result; } @@ -109,6 +118,8 @@ const Publication = struct { self.copied.deinit(); self.seen_nodes.deinit(); self.seen_annos.deinit(); + self.slots.deinit(); + self.references.deinit(self.env.gpa); self.annos.deinit(self.env.gpa); } @@ -124,6 +135,23 @@ const Publication = struct { try self.bytes(if (data.isNone()) "" else self.env.getIdent(data)); } else if (T == base.ModuleIdentity.Idx) { dependencies.hashU32(&self.hasher, self.scope.ordinal(self.env.moduleIdentityHash(data).*)); + } else if (T == CIR.TypeAnno.LocalOrExternal) { + try self.bytes(@tagName(data)); + switch (data) { + .local => |local| try self.node(local.decl_idx), + .external => |external| { + try self.value(external.module_idx); + dependencies.hashU32(&self.hasher, try self.importNode(external.module_idx, external.target_node_idx)); + }, + .external_identity => |external| { + try self.value(external.module_identity); + dependencies.hashU32(&self.hasher, try self.foreignNode(external.module_identity, external.target_node_idx)); + }, + .builtin => |builtin| try self.value(builtin), + .pending => base.invariant("checking interface annotations have resolved imports", .{}), + } + } else if (T == CIR.Node.Idx or T == CIR.Statement.Idx or T == CIR.Def.Idx or T == CIR.Pattern.Idx or T == CIR.Expr.Idx or T == CIR.TypeAnno.Idx) { + try self.node(data); } else if (T == Var) { try self.ty(data); } else switch (@typeInfo(T)) { @@ -169,8 +197,38 @@ const Publication = struct { } } - fn node(self: *Publication, id: anytype) void { - dependencies.hashU32(&self.hasher, @backingInt(id)); + fn slot(self: *Publication, raw: u32) Allocator.Error!u32 { + const entry = try self.slots.getOrPut(@fromBackingInt(raw)); + if (!entry.found_existing) { + entry.value_ptr.* = std.math.cast(u32, self.references.items.items.len) orelse return error.OutOfMemory; + _ = try self.references.append(self.env.gpa, raw); + } + return entry.value_ptr.*; + } + + fn node(self: *Publication, id: anytype) Allocator.Error!void { + dependencies.hashU32(&self.hasher, try self.slot(@backingInt(id))); + } + + fn foreignNode(self: *Publication, owner: base.ModuleIdentity.Idx, raw: u32) Allocator.Error!u32 { + if (owner == self.env.selfModuleIdentity()) return self.slot(raw); + return self.foreignHash(self.env.moduleIdentityHash(owner).*, raw); + } + + fn importNode(self: *Publication, import_idx: CIR.Import.Idx, raw: u32) Allocator.Error!u32 { + const resolved = self.env.imports.getResolvedModule(import_idx) orelse + base.invariant("published external annotation imports are resolved", .{}); + return self.foreignHash(self.scope.imported[resolved].contentIdentityHash().?.*, raw); + } + + fn foreignHash(self: *Publication, hash: base.ModuleIdentity.Hash, raw: u32) Allocator.Error!u32 { + const dependency = self.scope.environments.get(hash) orelse + base.invariant("checking reference owners belong to the publication scope", .{}); + if (dependency.module_role == .builtin) return raw; + for (dependency.checking_dependency.references.items.items, 0..) |node_id, ordinal| { + if (node_id == raw) return @intCast(ordinal); + } + base.invariant("foreign checking references have producer-published slots", .{}); } fn section(self: *Publication, name: []const u8, count: usize) Allocator.Error!void { @@ -189,11 +247,11 @@ const Publication = struct { &self.projected, self.env.gpa, ); - self.node(copied); + dependencies.hashU32(&self.hasher, @backingInt(copied)); } fn scheme(self: *Publication, node_id: CIR.Node.Idx) Allocator.Error!void { - self.node(node_id); + try self.node(node_id); try self.ty(ModuleEnv.varFrom(node_id)); try self.value(self.env.nodeIsBindingScheme(node_id)); const requirements = self.env.bindingSchemeCodecRequirementsForNode(node_id); @@ -212,13 +270,23 @@ const Publication = struct { } fn definition(self: *Publication, id: CIR.Def.Idx, classify: *const fn (*const ModuleEnv, CIR.Def) Definition) Allocator.Error!void { - self.node(id); + try self.node(id); const entry = try self.seen_nodes.getOrPut(ModuleEnv.nodeIdxFrom(id)); if (entry.found_existing) return; const def = self.env.store.getDef(id); - self.node(def.pattern); - self.node(def.expr); + try self.node(def.pattern); + try self.node(def.expr); try self.value(classify(self.env, def)); + try self.value(def.annotation != null); + if (def.annotation) |annotation_id| { + try self.node(annotation_id); + const annotation = self.env.store.getAnnotation(annotation_id); + try self.value(annotation.erroneous); + try self.value(annotation.mentions_type_var); + try self.value(annotation.introduces_type_var); + try self.value(annotation.contains_underscore); + try self.anno(annotation.anno); + } try self.scheme(ModuleEnv.nodeIdxFrom(def.pattern)); try self.scheme(ModuleEnv.nodeIdxFrom(id)); try self.ty(ModuleEnv.varFrom(def.expr)); @@ -228,7 +296,7 @@ const Publication = struct { } fn declaration(self: *Publication, id: CIR.Statement.Idx) Allocator.Error!void { - self.node(id); + try self.node(id); const entry = try self.seen_nodes.getOrPut(ModuleEnv.nodeIdxFrom(id)); if (entry.found_existing) return; const statement = self.env.store.getStatement(id); @@ -278,13 +346,13 @@ const Publication = struct { const annotations = self.env.store.sliceTypeAnnos(span); dependencies.hashU32(&self.hasher, std.math.cast(u32, annotations.len) orelse return error.OutOfMemory); for (annotations) |annotation| { - self.node(annotation); + try self.node(annotation); try self.annos.append(self.env.gpa, annotation); } } fn anno(self: *Publication, annotation: CIR.TypeAnno.Idx) Allocator.Error!void { - self.node(annotation); + try self.node(annotation); if (annotation != .placeholder) try self.annos.append(self.env.gpa, annotation); } @@ -293,7 +361,7 @@ const Publication = struct { if (id == .placeholder) continue; const entry = try self.seen_annos.getOrPut(id); if (entry.found_existing) continue; - self.node(id); + try self.node(id); const annotation = self.env.store.getTypeAnno(id); try self.bytes(@tagName(annotation)); switch (annotation) { @@ -360,7 +428,7 @@ const Publication = struct { const exposed = env.common.exposed_items.items.entries.items; const order = try env.gpa.alloc(usize, exposed.len); defer env.gpa.free(order); - for (order, 0..) |*slot, i| slot.* = i; + for (order, 0..) |*position, i| position.* = i; std.mem.sort(usize, order, env, struct { fn lessThan(module: *const ModuleEnv, a: usize, b: usize) bool { const entries = module.common.exposed_items.items.entries.items; @@ -397,7 +465,7 @@ const Publication = struct { for (env.method_defs.entries.items) |entry| { const owner = entry.key.ownerIdent(); if (owner.moduleIdentity()) |identity| try self.value(identity) else dependencies.hashU32(&self.hasher, 0); - self.node(owner.owner); + dependencies.hashU32(&self.hasher, try self.foreignNode(owner.moduleIdentity() orelse env.selfModuleIdentity(), @backingInt(owner.owner))); try self.value(entry.key.methodIdent()); try self.scheme(entry.value.type_node_idx); try self.definition(entry.value.def_idx, classify); @@ -406,7 +474,7 @@ const Publication = struct { for (env.method_idents.entries.items) |entry| { const owner = entry.key.ownerIdent(); if (owner.moduleIdentity()) |identity| try self.value(identity) else dependencies.hashU32(&self.hasher, 0); - self.node(owner.owner); + dependencies.hashU32(&self.hasher, try self.foreignNode(owner.moduleIdentity() orelse env.selfModuleIdentity(), @backingInt(owner.owner))); try self.value(entry.key.methodIdent()); try self.value(entry.value); } @@ -425,7 +493,12 @@ const Publication = struct { try self.value(entry.func_ident); try self.bytes(env.getString(entry.symbol)); try self.value(entry.target_import); - try self.value(entry.target_def); + try self.value(entry.target_def != null); + if (entry.target_def) |def| { + if (entry.target_import) |import_idx| { + dependencies.hashU32(&self.hasher, try self.importNode(import_idx, @backingInt(def))); + } else try self.node(def); + } try self.value(entry.target_status); } try self.bytes("annotations"); @@ -436,6 +509,8 @@ const Publication = struct { var arena = base.SingleThreadArena.init(self.env.gpa); defer arena.deinit(); const allocator = arena.allocator(); + var mapper = ProjectionBinding{ .publication = self }; + try self.projected.types.rebindSourceReferences(&mapper); var writer = CompactWriter.init(); const root = try writer.appendAlloc(allocator, types.Store.Serialized); try root.serialize(&self.projected.types, allocator, &writer); @@ -451,3 +526,16 @@ const Publication = struct { return self.hasher.finalResult(); } }; + +const ProjectionBinding = struct { + pub const Error = Allocator.Error; + publication: *Publication, + + pub fn node(self: *ProjectionBinding, owner: base.ModuleIdentity.Idx, raw: u32) Error!u32 { + const publication = self.publication; + const hash = publication.projected.moduleIdentityHash(owner); + const original = publication.env.lookupModuleIdentity(hash) orelse + base.invariant("projected type ownership was copied from the source environment", .{}); + return publication.foreignNode(original, raw); + } +}; diff --git a/src/check/checking_outcome.zig b/src/check/checking_outcome.zig new file mode 100644 index 00000000000..2a044a5a18c --- /dev/null +++ b/src/check/checking_outcome.zig @@ -0,0 +1,171 @@ +//! Pre-evaluation checking diagnostics and every byte needed to render them. +//! Problems retain their order and snapshots; rendering still uses the current +//! filename and current source. Empirical/evaluation diagnostics are not cached. +const std = @import("std"); +const base = @import("base"); +const collections = @import("collections"); +const problem = @import("problem.zig"); +const snapshot = @import("snapshot.zig"); +const serde = @import("artifact_serialize.zig"); +const dependencies = @import("checking_dependencies.zig"); +const Self = @This(); +const Allocator = std.mem.Allocator; + +/// Whether the pre-evaluation outcome contains checking diagnostics. +pub const Status = enum(u32) { diagnostic_free, with_diagnostics }; +/// A range in the stored diagnostic text pool. +pub const Text = extern struct { start: u32, len: u32 }; +/// A snapshot and its preserved display text. +pub const Formatted = extern struct { snapshot: snapshot.SnapshotContentIdx, text: Text }; +/// Pointer-free snapshot tag with its preserved display text. +pub const Tag = struct { name: base.Ident.Idx, args: @FieldType(snapshot.SnapshotTag, "args"), text: Text }; +/// An explicit foreign source dependency for retained diagnostic regions. +pub const Source = extern struct { owner: u32, digest: [32]u8 }; + +status: Status = .diagnostic_free, +problems: []const problem.Problem = &.{}, +pending: []const problem.Store.PendingStaticExhaustiveness = &.{}, +extra_strings: []const u8 = &.{}, +missing_patterns: []const problem.ExtraStringIdx = &.{}, +regions: []const base.Region = &.{}, +contents: []const snapshot.SnapshotContent = &.{}, +content_indexes: []const snapshot.SnapshotContentIdx = &.{}, +record_fields: []const snapshot.SnapshotRecordField = &.{}, +tags: []const Tag = &.{}, +constraints: []const snapshot.SnapshotStaticDispatchConstraint = &.{}, +formatted: []const Formatted = &.{}, +text: []const u8 = &.{}, +sources: []const Source = &.{}, + +/// Portable diagnostic storage, independent of evaluated outcomes. +pub const Serialized = extern struct { + status: Status = .diagnostic_free, + problems: serde.SerializedSlice(problem.Problem) = .{}, + pending: serde.SerializedSlice(problem.Store.PendingStaticExhaustiveness) = .{}, + extra_strings: serde.SerializedSlice(u8) = .{}, + missing_patterns: serde.SerializedSlice(problem.ExtraStringIdx) = .{}, + regions: serde.SerializedSlice(base.Region) = .{}, + contents: serde.SerializedSlice(snapshot.SnapshotContent) = .{}, + content_indexes: serde.SerializedSlice(snapshot.SnapshotContentIdx) = .{}, + record_fields: serde.SerializedSlice(snapshot.SnapshotRecordField) = .{}, + tags: serde.SerializedSlice(Tag) = .{}, + constraints: serde.SerializedSlice(snapshot.SnapshotStaticDispatchConstraint) = .{}, + formatted: serde.SerializedSlice(Formatted) = .{}, + text: serde.SerializedSlice(u8) = .{}, + sources: serde.SerializedSlice(Source) = .{}, + + pub const root_contract: collections.serde_validation.RootContract = .{ .Owner = Self }; + pub fn serialize(self: *@This(), value: *const Self, allocator: Allocator, writer: *collections.CompactWriter) Allocator.Error!void { + self.status = value.status; + inline for (@typeInfo(@This()).@"struct".field_names) |field| { + if (comptime !serde.comptimeStrEq(field, "status")) try @field(self, field).serialize(@field(value, field), allocator, writer); + } + } + pub fn deserialize(self: *const @This(), address: usize) Self { + var result: Self = .{ .status = self.status }; + inline for (@typeInfo(@This()).@"struct".field_names) |field| { + if (comptime !serde.comptimeStrEq(field, "status")) @field(result, field) = @field(self, field).deserialize(address); + } + return result; + } +}; + +fn appendText(allocator: Allocator, text: *std.ArrayList(u8), bytes: []const u8) Allocator.Error!Text { + return try serde.appendSpan(Text, u8, text, allocator, bytes); +} + +/// Borrow checker storage until serialization; flatten pointer-bearing strings +/// and multi-lists into arena-owned arrays with deterministic index order. +pub fn capture(allocator: Allocator, checker: anytype, scope: *const dependencies.Scope) Allocator.Error!Self { + const snapshots = &checker.snapshots; + var text = std.ArrayList(u8).empty; + var tags = std.ArrayList(Tag).empty; + for (0..snapshots.tags.len()) |i| { + const tag = snapshots.tags.get(@fromBackingInt(@as(u32, @intCast(i)))); + try tags.append(allocator, .{ .name = tag.name, .args = tag.args, .text = try appendText(allocator, &text, tag.formatted) }); + } + var formatted = std.ArrayList(Formatted).empty; + for (0..snapshots.contents.len()) |i| { + const id: snapshot.SnapshotContentIdx = @fromBackingInt(@as(u32, @intCast(i))); + if (snapshots.getFormattedString(id)) |bytes| try formatted.append(allocator, .{ .snapshot = id, .text = try appendText(allocator, &text, bytes) }); + } + const fields = try allocator.alloc(snapshot.SnapshotRecordField, snapshots.record_fields.len()); + for (fields, 0..) |*field, i| field.* = snapshots.record_fields.get(@fromBackingInt(@as(u32, @intCast(i)))); + var sources = std.ArrayList(Source).empty; + // Platform diagnostics can retain a platform-side source region. Pin that + // declared provenance to the current platform source before replay. + if (checker.problems.problems.items.len != 0) if (checker.platform_requirements) |requirements| { + var hash: [32]u8 = undefined; + base.Sha256.hash(requirements.env.common.source, &hash, .{}); + try sources.append(allocator, .{ .owner = scope.ordinal(requirements.env.contentIdentityHash().?.*), .digest = hash }); + }; + return .{ + .status = if (checker.problems.problems.items.len != 0 or checker.cir.diagnosticCount() != 0) .with_diagnostics else .diagnostic_free, + .problems = checker.problems.problems.items, + .pending = checker.problems.pending_static_exhaustiveness.items, + .extra_strings = checker.problems.extra_strings_backing.items, + .missing_patterns = checker.problems.missing_patterns_backing.items, + .regions = checker.regions.items.items, + .contents = snapshots.contents.items.items, + .content_indexes = snapshots.content_indexes.items.items, + .record_fields = fields, + .tags = tags.items, + .constraints = snapshots.static_dispatch_constraints.items.items, + .formatted = formatted.items, + .text = text.items, + .sources = sources.items, + }; +} + +/// Validate source origins before the capsule is installed. A source shift that +/// invalidates a stored foreign region is a miss, not stale diagnostic replay. +pub fn sourcesMatch(self: Self, scope: *const dependencies.Scope) bool { + for (self.sources) |source| { + if (source.owner >= scope.owners.items.len) return false; + const env = scope.environments.get(scope.owners.items[source.owner]) orelse return false; + var hash: [32]u8 = undefined; + base.Sha256.hash(env.common.source, &hash, .{}); + if (!base.ModuleIdentity.eql(&source.digest, &hash)) return false; + } + return true; +} + +/// Check diagnostic text ranges before restoring optional cache data. +pub fn validate(self: Self) error{CorruptArtifact}!void { + for (self.tags) |tag| try self.validateText(tag.text); + for (self.formatted) |formatted| { + if (@backingInt(formatted.snapshot) >= self.contents.len) return error.CorruptArtifact; + try self.validateText(formatted.text); + } + for (self.missing_patterns) |range| { + if (range.start > self.extra_strings.len or range.count > self.extra_strings.len - range.start) return error.CorruptArtifact; + } +} + +fn validateText(self: Self, range: Text) error{CorruptArtifact}!void { + if (@as(u64, range.start) + range.len > self.text.len) return error.CorruptArtifact; +} + +/// Restore exactly the pre-publication problem store, including deferred +/// exhaustiveness recipes. Ordinary publication and reporting decide failures. +pub fn install(self: Self, checker: anytype) Allocator.Error!void { + const allocator = checker.gpa; + try checker.problems.problems.appendSlice(allocator, self.problems); + try checker.problems.pending_static_exhaustiveness.appendSlice(allocator, self.pending); + try checker.problems.extra_strings_backing.appendSlice(self.extra_strings); + try checker.problems.missing_patterns_backing.appendSlice(self.missing_patterns); + checker.regions.items.clearRetainingCapacity(); + _ = try checker.regions.appendSlice(allocator, self.regions); + const snapshots = &checker.snapshots; + _ = try snapshots.contents.appendSlice(allocator, self.contents); + _ = try snapshots.content_indexes.appendSlice(allocator, self.content_indexes); + _ = try snapshots.record_fields.appendSlice(allocator, self.record_fields); + _ = try snapshots.static_dispatch_constraints.appendSlice(allocator, self.constraints); + for (self.tags) |tag| { + const formatted = try allocator.dupe(u8, self.text[tag.text.start..][0..tag.text.len]); + errdefer allocator.free(formatted); + _ = try snapshots.tags.append(allocator, .{ .name = tag.name, .args = tag.args, .formatted = formatted }); + } + try snapshots.formatted_strings_backing.appendSlice(self.text); + for (self.formatted) |formatted| try snapshots.formatted_strings.put(allocator, formatted.snapshot, .{ .start = formatted.text.start, .count = formatted.text.len }); +} diff --git a/src/check/inference_output.zig b/src/check/inference_output.zig index 6dda541228c..16372b603ec 100644 --- a/src/check/inference_output.zig +++ b/src/check/inference_output.zig @@ -7,6 +7,8 @@ const collections = @import("collections"); const can = @import("can"); const types = @import("types"); const roots = @import("hoist_roots.zig"); +const dependencies = @import("checking_dependencies.zig"); +const base = @import("base"); const requirement = @import("requirement_solution.zig"); const serde = @import("artifact_serialize.zig"); @@ -24,6 +26,11 @@ pub const Solution = struct { identities_len: u32, }; +/// One producer-owned slot and its source index in the stored dependency. +pub const ForeignReference = extern struct { owner: u32, node: u32, slot: u32 }; + +outcome: @import("checking_outcome.zig") = .{}, +foreign_references: []const ForeignReference = &.{}, hoisted_roots: []const roots.SelectedHoistedRoot = &.{}, promoted_local_procedures: []const roots.PromotedLocalProcedure = &.{}, solutions: []const Solution = &.{}, @@ -31,6 +38,8 @@ identity_vars: []const types.Var = &.{}, /// Independently relocatable inference publication metadata. pub const Serialized = extern struct { + outcome: @import("checking_outcome.zig").Serialized = .{}, + foreign_references: serde.SerializedSlice(ForeignReference) = .{}, hoisted_roots: serde.SerializedSlice(roots.SelectedHoistedRoot) = .{}, promoted_local_procedures: serde.SerializedSlice(roots.PromotedLocalProcedure) = .{}, solutions: serde.SerializedSlice(Solution) = .{}, @@ -44,7 +53,16 @@ pub const Serialized = extern struct { /// The caller supplies an arena; the root slices borrow the checker until the /// capsule has been written, while solution rows flatten their identity lists. -pub fn fromChecker(allocator: Allocator, checker: anytype) Allocator.Error!Self { +pub fn fromChecker(allocator: Allocator, checker: anytype, scope: *const dependencies.Scope) Allocator.Error!Self { + var references = std.ArrayList(ForeignReference).empty; + for (scope.owners.items, 0..) |hash, owner| { + if (owner == 0 or scope.ordinal(hash) != owner) continue; + const env = scope.environments.get(hash) orelse + base.invariant("foreign reference tables belong to explicit dependency environments", .{}); + for (env.checking_dependency.references.items.items, 0..) |node, slot| { + try references.append(allocator, .{ .owner = @intCast(owner), .node = node, .slot = @intCast(slot) }); + } + } var solutions = std.ArrayList(Solution).empty; var identities = std.ArrayList(types.Var).empty; for (checker.platformRequirementSolutions()) |solution| { @@ -60,6 +78,8 @@ pub fn fromChecker(allocator: Allocator, checker: anytype) Allocator.Error!Self try identities.appendSlice(allocator, solution.identity_vars); } return .{ + .outcome = try @import("checking_outcome.zig").capture(allocator, checker, scope), + .foreign_references = references.items, .hoisted_roots = checker.selectedHoistedRoots(), .promoted_local_procedures = checker.promotedLocalProcedures(), .solutions = solutions.items, @@ -69,6 +89,7 @@ pub fn fromChecker(allocator: Allocator, checker: anytype) Allocator.Error!Self /// Validate side-list ranges before installing untrusted cache metadata. pub fn validate(self: Self) error{CorruptArtifact}!void { + try self.outcome.validate(); for (self.solutions) |solution| { if (@as(u64, solution.identities_start) + solution.identities_len > self.identity_vars.len) return error.CorruptArtifact; @@ -77,8 +98,23 @@ pub fn validate(self: Self) error{CorruptArtifact}!void { /// Restore the owning stage's explicit outputs. Initializing the checker only /// provides diagnostics/publication services; checkFile is never called on a hit. -pub fn install(self: Self, checker: anytype) Allocator.Error!void { +pub fn install(self: Self, checker: anytype, scope: *const dependencies.Scope) Allocator.Error!void { const allocator = checker.gpa; + try self.outcome.install(checker); + var binding = self.makeBinding(checker.cir, scope) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.IncompatibleReferences => base.invariant("admitted inference references remain valid during diagnostic installation", .{}), + }; + defer binding.references.deinit(allocator); + for (0..checker.snapshots.record_fields.len()) |i| { + const id = @as(snapshotFieldIndex(), @fromBackingInt(@as(u32, @intCast(i)))); + var field = checker.snapshots.record_fields.get(id); + types.store.rebindSourceReferenceValue(&field, &binding) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.IncompatibleReferences => base.invariant("admission established snapshot default correspondence", .{}), + }; + checker.snapshots.record_fields.set(id, field); + } try checker.selected_hoisted_roots.appendSlice(allocator, self.hoisted_roots); try checker.promoted_local_procedures.appendSlice(allocator, self.promoted_local_procedures); for (self.solutions) |solution| { @@ -94,3 +130,96 @@ pub fn install(self: Self, checker: anytype) Allocator.Error!void { }); } } + +/// Admission is complete before the restored source replaces the live source. +/// A missing correspondence rejects the optional cache entry, never a compiler +/// invariant or a guessed mapping. +pub fn bindReferences(self: Self, env: *can.ModuleEnv, scope: *const dependencies.Scope) (Allocator.Error || error{IncompatibleReferences})!void { + var binding = try self.makeBinding(env, scope); + defer binding.references.deinit(env.gpa); + for (self.outcome.record_fields) |record| { + var field = record; + try types.store.rebindSourceReferenceValue(&field, &binding); + } + try @import("reference_binding.zig").source(env, &binding); +} + +fn makeBinding(self: Self, env: *can.ModuleEnv, scope: *const dependencies.Scope) Binding.Error!Binding { + var binding = Binding{ .env = env, .scope = scope }; + errdefer binding.references.deinit(env.gpa); + for (self.foreign_references) |reference| { + if (reference.owner >= scope.owners.items.len) return error.IncompatibleReferences; + const current = scope.environments.get(scope.owners.items[reference.owner]) orelse return error.IncompatibleReferences; + if (reference.slot >= current.checking_dependency.references.items.items.len) return error.IncompatibleReferences; + const entry = try binding.references.getOrPut(env.gpa, .{ .owner = reference.owner, .node = reference.node }); + if (entry.found_existing) return error.IncompatibleReferences; + entry.value_ptr.* = current.checking_dependency.references.items.items[reference.slot]; + } + return binding; +} + +fn snapshotFieldIndex() type { + return @import("snapshot.zig").SnapshotRecordFieldSafeList.Idx; +} + +const Binding = struct { + pub const Error = Allocator.Error || error{IncompatibleReferences}; + const Key = struct { owner: u32, node: u32 }; + env: *can.ModuleEnv, + scope: *const dependencies.Scope, + references: std.AutoHashMapUnmanaged(Key, u32) = .empty, + + pub fn importNode(self: *Binding, env: *const can.ModuleEnv, import_idx: u32, raw: u32) Error!u32 { + const resolved = env.imports.getResolvedModule(@fromBackingInt(import_idx)) orelse return error.IncompatibleReferences; + if (resolved >= self.scope.imported.len) return error.IncompatibleReferences; + const current = self.scope.imported[resolved]; + if (current.module_role == .builtin) return raw; + return self.references.get(.{ .owner = self.scope.ordinal(current.contentIdentityHash().?.*), .node = raw }) orelse error.IncompatibleReferences; + } + + pub fn node(self: *Binding, owner: base.ModuleIdentity.Idx, raw: u32) Error!u32 { + if (owner == self.env.selfModuleIdentity()) return raw; + const old = self.env.moduleIdentityHash(owner); + const previous = self.env.checking_dependency.owners.items.items; + const ordinal = for (previous, 0..) |hash, i| { + if (base.ModuleIdentity.eql(&hash, old)) break i; + } else return error.IncompatibleReferences; + if (ordinal >= self.scope.owners.items.len) return error.IncompatibleReferences; + const current = self.scope.environments.get(self.scope.owners.items[ordinal]) orelse return error.IncompatibleReferences; + if (current.module_role == .builtin) return raw; + return self.references.get(.{ .owner = @intCast(ordinal), .node = raw }) orelse error.IncompatibleReferences; + } +}; + +test "inference references require exact producer slots" { + const allocator = std.testing.allocator; + var env = try can.ModuleEnv.init(allocator, ""); + defer env.deinit(); + try env.initCIRFields("Importer"); + try env.setContentIdentity(@splat(1)); + const foreign = try env.internModuleIdentity(&@as(base.ModuleIdentity.Hash, @splat(2)), base.Ident.Idx.NONE); + var dependency = try can.ModuleEnv.init(allocator, ""); + defer dependency.deinit(); + try dependency.initCIRFields("Dependency"); + try dependency.setContentIdentity(@splat(2)); + dependency.checking_dependency.published = true; + _ = try dependency.checking_dependency.references.append(allocator, 42); + var scope = try dependencies.Scope.init(allocator, @splat(1)); + defer scope.deinit(); + try scope.appendDependency(&dependency); + env.checking_dependency.owners = try scope.copyOwners(allocator); + + var references = [_]ForeignReference{.{ .owner = 1, .node = 17, .slot = 0 }}; + const output = Self{ .foreign_references = &references }; + var binding = try output.makeBinding(&env, &scope); + defer binding.references.deinit(allocator); + try std.testing.expectEqual(@as(u32, 42), try binding.node(foreign, 17)); + try std.testing.expectError(error.IncompatibleReferences, binding.node(foreign, 42)); + references[0].slot = 1; + try std.testing.expectError(error.IncompatibleReferences, output.makeBinding(&env, &scope)); + references[0] = .{ .owner = 2, .node = 17, .slot = 0 }; + try std.testing.expectError(error.IncompatibleReferences, output.makeBinding(&env, &scope)); + references[0] = .{ .owner = 1, .node = 17, .slot = 0 }; + const duplicates = Self{ .foreign_references = &.{ references[0], references[0] } }; + try std.testing.expectError(error.IncompatibleReferences, duplicates.makeBinding(&env, &scope)); +} diff --git a/src/check/problem/store.zig b/src/check/problem/store.zig index d69b56af242..0d25089b5d9 100644 --- a/src/check/problem/store.zig +++ b/src/check/problem/store.zig @@ -112,12 +112,12 @@ pub const Store = struct { /// Get a stored pattern string by its range pub fn getExtraString(self: *const Self, idx: ExtraStringIdx) []const u8 { - return self.extra_strings_backing.items[idx.start..][0..idx.count]; + return self.extra_strings_backing.items[@as(usize, @intCast(idx.start))..][0..@as(usize, @intCast(idx.count))]; } /// Get the slice of missing pattern indices by range pub fn getMissingPatterns(self: *const Self, range: MissingPatternsRange) []const ExtraStringIdx { - return self.missing_patterns_backing.items[range.start..][0..range.count]; + return self.missing_patterns_backing.items[@as(usize, @intCast(range.start))..][0..@as(usize, @intCast(range.count))]; } /// Create a deep snapshot from a Var, storing it in this SnapshotStore diff --git a/src/check/problem/types.zig b/src/check/problem/types.zig index fad3f66a198..cec5116b779 100644 --- a/src/check/problem/types.zig +++ b/src/check/problem/types.zig @@ -20,13 +20,13 @@ const SnapshotContentIdx = snapshot.SnapshotContentIdx; pub const Context = context_mod.Context; pub const Category = context_mod.Category; -const ByteListRange = struct { start: usize, count: usize }; +const ByteListRange = struct { start: u64, count: u64 }; /// Alias into the Store.extra_strings_backing array pub const ExtraStringIdx = ByteListRange; /// A range of patterns -pub const MissingPatternsRange = struct { start: usize, count: usize }; +pub const MissingPatternsRange = struct { start: u64, count: u64 }; /// The checker relation that recorded a problem, or whose unification queued /// a dispatch obligation whose problems then belong to it, unless the diff --git a/src/check/reference_binding.zig b/src/check/reference_binding.zig new file mode 100644 index 00000000000..9d9923e6cd2 --- /dev/null +++ b/src/check/reference_binding.zig @@ -0,0 +1,320 @@ +//! Relocate foreign source locators using a producer-owned correspondence. +//! Type variables, local nodes, solver relationships and dispatch decisions do +//! not change. The mapper rejects any reference outside its declared domain. +const std = @import("std"); +const base = @import("base"); +const can = @import("can"); +const CIR = can.CIR; +const ModuleEnv = can.ModuleEnv; + +/// Relocate all CIR and ModuleEnv source-reference storage, including nodes +/// retained as source provenance after checker replacement. +pub fn source(env: *ModuleEnv, mapper: anytype) @TypeOf(mapper.*).Error!void { + inline for (.{ &env.store.nodes, &env.store.replaced_source_nodes }) |nodes| { + for (0..nodes.len()) |i| { + const id: CIR.Node.Idx = @fromBackingInt(@as(u32, @intCast(i))); + var node = nodes.get(id); + switch (node.tag) { + inline .expr_external_lookup, .expr_nominal_external, .pattern_nominal_external => |tag| { + var payload = @field(node.getPayload(), @tagName(tag)); + payload.target_node_idx = try mapper.importNode(env, payload.module_idx, payload.target_node_idx); + node.setPayload(@unionInit(CIR.Node.Payload, @tagName(tag), payload)); + }, + .expr_associated_lookup => { + var payload = node.getPayload().expr_associated_lookup; + payload.type_node_idx = try mapper.importNode(env, payload.module_idx, payload.type_node_idx); + node.setPayload(.{ .expr_associated_lookup = payload }); + }, + .expr_associated_lookup_resolved => { + var payload = node.getPayload().expr_associated_lookup_resolved; + const owner: base.ModuleIdentity.Idx = @fromBackingInt(payload.module_identity); + payload.target_node_idx = try mapper.node(owner, payload.target_node_idx); + payload.target_def_idx = try mapper.node(owner, payload.target_def_idx); + node.setPayload(.{ .expr_associated_lookup_resolved = payload }); + }, + // Annotation payloads share side records. Those are handled + // below once, independently of live/replaced node aliases. + .statement_decl, + .statement_var, + .statement_var_uninitialized, + .statement_reassign, + .statement_crash, + .statement_dbg, + .statement_expr, + .statement_expect, + .statement_for, + .statement_while, + .statement_infinite_loop, + .statement_breakable_loop, + .statement_break, + .statement_return, + .statement_import, + .statement_alias_decl, + .statement_nominal_decl, + .statement_where_alias_decl, + .statement_type_anno, + .statement_type_var_alias, + .expr_var, + .expr_tuple, + .expr_tuple_access, + .expr_list, + .expr_empty_list, + .expr_call, + .expr_record, + .expr_empty_record, + .record_field, + .record_unset_field, + .record_destruct, + .expr_field_access, + .field_access_segment, + .expr_method_call, + .expr_dispatch_call, + .expr_interpolation, + .expr_structural_eq, + .expr_structural_hash, + .expr_method_eq, + .expr_type_method_call, + .expr_type_dispatch_call, + .expr_type_dispatch_call_dispatcher, + .expr_static_dispatch, + .expr_deferred_import_ref, + .expr_deferred_nominal_external, + .expr_associated_lookup_local, + .expr_required_lookup, + .expr_apply, + .expr_string, + .expr_string_segment, + .expr_bytes_literal, + .expr_num, + .expr_frac_f32, + .expr_frac_f64, + .expr_dec, + .expr_dec_small, + .expr_num_from_numeral, + .expr_typed_int, + .expr_typed_frac, + .expr_typed_num_from_numeral, + .expr_tag, + .expr_nominal, + .expr_zero_argument_tag, + .expr_closure, + .expr_lambda, + .expr_record_update, + .expr_bin_op, + .expr_unary_minus, + .expr_suffix_single_question, + .expr_if_then_else, + .expr_match, + .expr_dbg, + .expr_crash, + .expr_expect_err, + .expr_block, + .expr_ellipsis, + .expr_anno_only, + .expr_derived_method, + .expr_hosted_lambda, + .expr_low_level, + .expr_run_low_level, + .expr_expect, + .expr_for, + .expr_record_builder, + .expr_return, + .expr_break, + .match_branch, + .match_branch_pattern, + .type_header, + .annotation, + .ty_apply, + .ty_apply_external, + .ty_rigid_var, + .ty_rigid_var_lookup, + .ty_lookup, + .ty_underscore, + .ty_tag_union, + .ty_tag, + .ty_tuple, + .ty_record, + .ty_record_field, + .ty_record_field_defaulted, + .ty_fn, + .ty_parens, + .ty_lookup_external, + .ty_malformed, + .where_method, + .where_alias, + .where_malformed, + .pattern_identifier, + .pattern_var_identifier, + .pattern_as, + .pattern_applied_tag, + .pattern_nominal, + .pattern_deferred_import_ref, + .pattern_record_destructure, + .pattern_list, + .pattern_tuple, + .pattern_num_literal, + .pattern_dec_literal, + .pattern_num_from_numeral_literal, + .pattern_f32_literal, + .pattern_f64_literal, + .pattern_small_dec_literal, + .pattern_str_literal, + .pattern_str_interpolation, + .pattern_underscore, + .lambda_capture, + .def, + .exposed_item, + .if_branch, + .type_var_slot, + .malformed, + .diag_not_implemented, + .diag_invalid_num_literal, + .diag_empty_single_quote, + .diag_empty_tuple, + .diag_ident_already_in_scope, + .diag_ident_not_in_scope, + .diag_read_uninitialized_var, + .diag_self_referential_definition, + .diag_circular_value_definition, + .diag_local_reference_before_definition, + .diag_mutually_recursive_local_definitions, + .diag_erroneous_value_use, + .diag_erroneous_value_expr, + .diag_qualified_ident_does_not_exist, + .diag_invalid_top_level_statement, + .diag_invalid_associated_statement, + .diag_expr_not_canonicalized, + .diag_expr_syntax_error, + .diag_unreachable_string_pattern_capture, + .diag_pattern_arg_invalid, + .diag_pattern_not_canonicalized, + .diag_malformed_type_annotation, + .diag_malformed_where_clause, + .diag_where_clause_not_allowed_in_type_decl, + .diag_where_alias_constraint_not_on_receiver, + .diag_open_ext_not_allowed_in_type_decl, + .diag_unnamed_field_not_allowed_in_structural_record, + .diag_optional_field_cannot_have_default, + .diag_record_default_reference_cycle, + .diag_type_module_missing_matching_type, + .diag_type_module_has_alias_not_nominal, + .diag_default_app_missing_main, + .diag_default_app_wrong_arity, + .diag_cannot_import_default_app, + .diag_type_name_case_mismatch, + .diag_module_header_deprecated, + .diag_roc_version_mismatch, + .diag_redundant_expose_main_type, + .diag_invalid_main_type_rename_in_exposing, + .diag_var_across_function_boundary, + .diag_shadowing_warning, + .diag_binding_name_does_not_match_mutability, + .diag_var_in_binder_pattern, + .diag_type_redeclared, + .diag_undeclared_type, + .diag_undeclared_type_var, + .diag_type_alias_but_needed_nominal, + .diag_type_alias_redeclared, + .diag_file_import_not_found, + .diag_file_import_io_error, + .diag_file_import_absolute_path, + .diag_file_import_not_utf8, + .diag_module_not_found, + .diag_value_not_exposed, + .diag_type_not_exposed, + .diag_private_type_in_exposed_type, + .diag_private_type_in_exposed_field, + .diag_type_from_missing_module, + .diag_module_not_imported, + .diag_public_type_not_defined, + .diag_nested_type_not_found, + .diag_internal_builtin_type, + .diag_nested_value_not_found, + .diag_record_builder_map2_not_found, + .diag_too_many_exports, + .diag_nominal_type_redeclared, + .diag_type_shadowed_warning, + .diag_builtin_type_shadowed_warning, + .diag_type_parameter_conflict, + .diag_unused_variable, + .diag_used_underscore_variable, + .diag_duplicate_record_field, + .diag_duplicate_pattern_binder, + .diag_duplicate_tag, + .diag_crash_expects_string, + .diag_f64_pattern_literal, + .diag_type_var_starting_with_dollar, + .diag_underscore_in_type_declaration, + .diagnostic_exposed_but_not_implemented, + .diag_provided_value_is_required, + .diag_redundant_exposed, + .diag_if_expr_without_else, + .diag_break_outside_loop, + .diag_infinite_loop_never_exits, + .diag_trailing_try_suffix, + .diag_return_outside_fn, + .diag_redundant_return, + .diag_unreachable_code, + .diag_control_flow_in_expect, + .diag_var_reassigned_in_expect, + .diag_mutually_recursive_type_aliases, + .diag_deprecated_number_suffix, + .diag_range_op_chained, + .diag_unnamed_field_cannot_have_default, + .diag_default_not_allowed_in_structural_record, + .diag_default_not_allowed_on_local_type_decl, + => {}, + } + nodes.set(id, node); + } + } + for (env.store.type_apply_data.items.items) |*data| { + switch (@as(CIR.TypeAnno.LocalOrExternal.Tag, @fromBackingInt(@intCast(data.base_tag)))) { + .external => data.value2 = try mapper.importNode(env, data.value1, data.value2), + .external_identity => data.value2 = try mapper.node(@fromBackingInt(data.value1), data.value2), + .builtin, .local, .pending => {}, + } + } + // A lookup's span2 record is shared by its retained source node, if any. + var visited = @import("collections").DenseMap(enum(u32) { _ }, void).init(env.gpa); + defer visited.deinit(); + inline for (.{ &env.store.nodes, &env.store.replaced_source_nodes }) |nodes| { + for (0..nodes.len()) |i| { + const node = nodes.get(@fromBackingInt(@as(u32, @intCast(i)))); + if (node.tag != .ty_lookup) continue; + const payload = node.getPayload().ty_lookup; + if ((try visited.getOrPut(@fromBackingInt(payload.base_span2_idx))).found_existing) continue; + const data = &env.store.span2_data.items.items[payload.base_span2_idx]; + switch (@as(CIR.TypeAnno.LocalOrExternal.Tag, @fromBackingInt(@intCast(payload.base)))) { + .external => data.len = try mapper.importNode(env, data.start, data.len), + .external_identity => data.len = try mapper.node(@fromBackingInt(data.start), data.len), + .builtin, .local, .pending => {}, + } + } + } + inline for (.{ &env.method_defs, &env.method_idents }) |table| { + for (table.entries.items) |*entry| { + if (entry.key.ownerIdent().moduleIdentity()) |owner| entry.key.owner = @fromBackingInt(try mapper.node(owner, @backingInt(entry.key.owner))); + } + const Entry = @TypeOf(table.entries.items[0]); + std.mem.sort(Entry, table.entries.items, {}, struct { + fn lessThan(_: void, a: Entry, b: Entry) bool { + return ModuleEnv.MethodKey.order(a.key, b.key) == .lt; + } + }.lessThan); + } + for (env.numeric_suffix_targets.items.items) |*suffix| { + switch (suffix.target()) { + .external => |target| suffix.data2 = try mapper.importNode(env, @backingInt(target.import_idx), target.target_node_idx), + .external_identity => |target| suffix.data2 = try mapper.node(target.module_identity, target.target_node_idx), + .builtin, .local, .pending, .invalid => {}, + } + } + for (env.record_omitted_defaults.items.items) |*record| record.default_expr_node = try mapper.node(record.origin_module, record.default_expr_node); + for (env.hosted_entries.items.items) |*entry| { + if (entry.target_import) |import_idx| { + if (entry.target_def) |def| entry.target_def = @fromBackingInt(try mapper.importNode(env, @backingInt(import_idx), @backingInt(def))); + } + } + try env.types.rebindSourceReferences(mapper); +} diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index ef54ddc0ada..db17a5c6628 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -16772,7 +16772,7 @@ fn customIssue12592(io: std.Io, allocator: Allocator, env: *CaseEnv, timer: *har \\} )) |failure| return failure; const a_body = "A := {}.{ value : {} -> I64\n value = |_| 7\n}\n"; - const a_changed = "A := {}.{ value : {} -> I64\n value = |_| 8\n}\n"; + const a_changed = "A := {}.{ value : {} -> I64\n value = |_| { shifted = 8\n shifted }\n}\n"; const c_body = "import B\nC := {}.{ value : {} -> I64\n value = |arg| B.value(arg)\n}\n"; const edits = [_]struct { a: []const u8, c: []const u8, passes: bool, failed_expect: []const u8 = "" }{ .{ .a = a_body ++ "expect A.value({}) == 7\n", .c = c_body ++ "expect C.value({}) == 7\n", .passes = true }, @@ -16780,6 +16780,8 @@ fn customIssue12592(io: std.Io, allocator: Allocator, env: *CaseEnv, timer: *har .{ .a = a_body ++ "expect A.value({}) == 8\n", .c = c_body ++ "expect C.value({}) == 7\n", .passes = false, .failed_expect = "expect A.value({}) == 8" }, .{ .a = a_changed ++ "expect A.value({}) == 8\n", .c = c_body ++ "expect C.value({}) == 7\n", .passes = false, .failed_expect = "expect C.value({}) == 7" }, .{ .a = a_changed ++ "expect A.value({}) == 8\n", .c = c_body ++ "expect C.value({}) == 8\n", .passes = true }, + .{ .a = "expect A.value({}) == 8\n" ++ a_changed, .c = c_body ++ "expect C.value({}) == 7\n", .passes = false, .failed_expect = "expect C.value({}) == 7" }, + .{ .a = "expect A.value({}) == 8\n" ++ a_changed, .c = c_body ++ "expect C.value({}) == 8\n", .passes = true }, .{ .a = a_body ++ "expect A.value({}) == 7\n", .c = c_body ++ "expect C.value({}) == 8\n", .passes = false, .failed_expect = "expect C.value({}) == 8" }, .{ .a = a_body ++ "expect A.value({}) == 7\n", .c = c_body ++ "expect C.value({}) == 7\n", .passes = true }, }; diff --git a/src/compile/cache_config.zig b/src/compile/cache_config.zig index 53dc269d9f3..2aeee59297f 100644 --- a/src/compile/cache_config.zig +++ b/src/compile/cache_config.zig @@ -359,7 +359,8 @@ pub const Constants = struct { /// 161: generated codec derivations record the renaming their snapshot /// applied to every type that holds a variable. /// 162: modules publish checking interfaces and imported callable eligibility. - pub const CACHE_VERSION = 162; + /// 163: producer-owned declaration slots and stored checking diagnostics. + pub const CACHE_VERSION = 163; }; /// Configuration for the Roc cache system. diff --git a/src/compile/cache_versioning.zig b/src/compile/cache_versioning.zig index a00c8b9d769..9a1c947135f 100644 --- a/src/compile/cache_versioning.zig +++ b/src/compile/cache_versioning.zig @@ -160,8 +160,8 @@ test "module_env_layout golden value" { // an *intentional* layout change, bump `Constants.CACHE_VERSION` and replace the // golden bytes below with the ones this assertion prints. const golden: [32]u8 = .{ - 0x47, 0x4F, 0xC1, 0x2A, 0xB4, 0x15, 0x75, 0xF3, 0xBA, 0x46, 0x95, 0xCD, 0x5B, 0x06, 0x9F, 0x30, - 0x0A, 0x9D, 0x99, 0x64, 0x16, 0x55, 0xC0, 0xF8, 0xD2, 0x06, 0x9D, 0x97, 0x57, 0xFD, 0x33, 0x95, + 0x92, 0xF3, 0x46, 0x1E, 0x12, 0xD8, 0x9F, 0xA6, 0xD7, 0xEA, 0x0D, 0x77, 0x55, 0xDF, 0xDC, 0xAE, + 0x4F, 0x7F, 0x34, 0x68, 0x45, 0xDA, 0x3D, 0xA9, 0x4A, 0x34, 0xE6, 0x24, 0xF3, 0xC3, 0x7A, 0x4C, }; try std.testing.expectEqualSlices(u8, &golden, &module_env_layout); } diff --git a/src/compile/compile_build.zig b/src/compile/compile_build.zig index e1ff7205c70..e35d1876293 100644 --- a/src/compile/compile_build.zig +++ b/src/compile/compile_build.zig @@ -2520,6 +2520,8 @@ pub const BuildEnv = struct { checking_interfaces_built: u32 = 0, /// Dense owner identities explicitly bound when inference was reused. checking_owner_bindings: u32 = 0, + checking_reference_bindings: u32 = 0, + checking_diagnostics_replayed: u32 = 0, /// Module compile time tracking (for non-cached modules) /// Time is for full module compilation: parse -> canonicalize -> type-check diff --git a/src/compile/compile_package.zig b/src/compile/compile_package.zig index 28df4b7b709..d67a67690f9 100644 --- a/src/compile/compile_package.zig +++ b/src/compile/compile_package.zig @@ -176,6 +176,8 @@ pub const TypeCheckOutput = struct { publication_owned: bool = true, inference_reused: bool = false, owner_bindings: u32 = 0, + reference_bindings: u32 = 0, + diagnostics_replayed: u32 = 0, pub fn deinit(self: *TypeCheckOutput) void { if (self.publication_owned) self.publication.deinit(self.publication.canonical_names.allocator); @@ -567,10 +569,10 @@ pub fn typeCheckModule( // constrained by platform types (e.g., I64) before defaulting to Dec. // TODO: re-enable defer_numeric_defaults once ModuleEnv has the field if (loaded) |capsule| { - try capsule.metadata.install(&checker); + try capsule.metadata.install(&checker, &checking_scope); } else { try checker.checkFile(); - if (inference_cache) |cache| cache.store(inference_key, env, &checker); + if (inference_cache) |cache| cache.store(inference_key, env, &checker, &checking_scope); } module_envs_map.deinit(); @@ -605,6 +607,8 @@ pub fn typeCheckModule( .publication = checked_artifact, .inference_reused = loaded != null, .owner_bindings = if (loaded != null) @intCast(env.module_identities.count()) else 0, + .reference_bindings = if (loaded) |capsule| std.math.cast(u32, capsule.metadata.foreign_references.len) orelse return error.OutOfMemory else 0, + .diagnostics_replayed = if (loaded) |capsule| std.math.cast(u32, capsule.metadata.outcome.problems.len + env.diagnosticCount()) orelse return error.OutOfMemory else 0, }; } diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index 84bc4fc4a9f..c10d3cca0ef 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -706,6 +706,8 @@ pub const ModuleState = struct { inference_cache_hits: u32 = 0, checking_interfaces_built: u32 = 0, checking_owner_bindings: u32 = 0, + checking_reference_bindings: u32 = 0, + checking_diagnostics_replayed: u32 = 0, checked_artifacts_published: u32 = 0, /// Module name (e.g., "Main", "Foo") name: []const u8, @@ -1604,6 +1606,8 @@ pub const Coordinator = struct { inference_cache_hits: u32 = 0, checking_interfaces_built: u32 = 0, checking_owner_bindings: u32 = 0, + checking_reference_bindings: u32 = 0, + checking_diagnostics_replayed: u32 = 0, /// Source publications of the parametric platform. Session composition /// consumes its checked output without publishing another module. platform_root_publish_count: u32 = 0, @@ -5499,6 +5503,10 @@ pub const Coordinator = struct { mod.inference_cache_hits += 1; self.checking_owner_bindings += result.owner_bindings; mod.checking_owner_bindings += result.owner_bindings; + self.checking_reference_bindings += result.reference_bindings; + mod.checking_reference_bindings += result.reference_bindings; + self.checking_diagnostics_replayed += result.diagnostics_replayed; + mod.checking_diagnostics_replayed += result.diagnostics_replayed; } else { self.cache_misses += 1; self.modules_compiled += 1; @@ -6080,6 +6088,8 @@ pub const Coordinator = struct { .inference_cache_hits = self.inference_cache_hits, .checking_interfaces_built = self.checking_interfaces_built, .checking_owner_bindings = self.checking_owner_bindings, + .checking_reference_bindings = self.checking_reference_bindings, + .checking_diagnostics_replayed = self.checking_diagnostics_replayed, .module_time_min_ns = self.module_time_min_ns, .module_time_max_ns = self.module_time_max_ns, .module_time_sum_ns = self.module_time_sum_ns, @@ -6814,6 +6824,8 @@ pub const Coordinator = struct { .check_diagnostics_ns = diagnostics_ns, .inference_reused = typecheck_output.inference_reused, .owner_bindings = typecheck_output.owner_bindings, + .reference_bindings = typecheck_output.reference_bindings, + .diagnostics_replayed = typecheck_output.diagnostics_replayed, }, }; } @@ -7054,6 +7066,7 @@ const CompiledBuildFacts = struct { checking: []ModuleCheckingFacts, rejected_modules: usize, failed_modules: usize, + has_errors: bool, fn deinit(self: *CompiledBuildFacts, allocator: Allocator) void { allocator.free(self.reports); @@ -7071,6 +7084,8 @@ const ModuleCheckingFacts = struct { inference_hits: u32, interfaces_built: u32, owner_bindings: u32, + reference_bindings: u32, + diagnostics_replayed: u32, }; fn lessThanBytes(_: void, a: [32]u8, b: [32]u8) bool { @@ -7144,10 +7159,16 @@ fn compileAppFactsWithWorkers( var report_lines = std.ArrayList([]const u8).empty; defer report_lines.deinit(arena); + var report_positions = std.StringHashMap(u32).init(arena); + defer report_positions.deinit(); var report_it = coord.iterReports(); while (report_it.next()) |entry| { + const owner = try std.fmt.allocPrint(arena, "{s}|{s}", .{ entry.package_name, entry.module_name }); + const position = try report_positions.getOrPut(owner); + if (!position.found_existing) position.value_ptr.* = 0; var rendered = std.Io.Writer.Allocating.init(arena); - rendered.writer.print("{s}|{s}|", .{ entry.package_name, entry.module_name }) catch return error.OutOfMemory; + rendered.writer.print("{s}|{d}|", .{ owner, position.value_ptr.* }) catch return error.OutOfMemory; + position.value_ptr.* += 1; reporting.renderReport(entry.report, &rendered.writer, .markdown) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.WriteFailed => return error.OutOfMemory, @@ -7209,6 +7230,8 @@ fn compileAppFactsWithWorkers( .inference_hits = mod.inference_cache_hits, .interfaces_built = mod.checking_interfaces_built, .owner_bindings = mod.checking_owner_bindings, + .reference_bindings = mod.checking_reference_bindings, + .diagnostics_replayed = mod.checking_diagnostics_replayed, }); } } @@ -7222,6 +7245,7 @@ fn compileAppFactsWithWorkers( .artifact_keys = try keys.toOwnedSlice(allocator), .artifact_digests = try digests.toOwnedSlice(allocator), .checking = try checking.toOwnedSlice(allocator), + .has_errors = coord.hasUserErrors(), .rejected_modules = rejected_modules, .failed_modules = failed_modules, }; @@ -10881,6 +10905,114 @@ test "issue 12592: a dependency test edit preserves importer checking results" { try std.testing.expectEqualSlices([32]u8, fresh_repaired.artifact_digests, repaired.artifact_digests); } +test "issue 12611: declaration slots and diagnostic outcomes survive dependency edits" { + const allocator = std.testing.allocator; + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.createDirPath(std.testing.io, "cache"); + const cache_dir = try tmp_dir.dir.realPathFileAlloc(std.testing.io, "cache", allocator); + defer allocator.free(cache_dir); + try writeFileImportFixture(&tmp_dir, "inference_outcomes", + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\import C + \\main! = |_args| { _ = C.value({}) + \\ Ok({}) } + ); + const a_sources = [_][]const u8{ + "A := {}.{ value : {} -> I64\n value = |_| 7\n}\n", + "expect True\nA := {}.{ value : {} -> I64\n value = |_| { local = 8\n local }\n}\n", + "\n\nexpect 1 == 1\nA := {}.{ value : {} -> I64\n value = |_| 9\n}\n", + "A := {}.{ value : {} -> I64\n value = |_| 7\n}\n", + }; + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "inference_outcomes/app/C.roc", + .data = "import B\nC := {}.{ value : {} -> I64\n value = |arg| B.value(arg)\n carry = |item| B.id(item)\n}\n", + }); + const app_path = try tmp_dir.dir.realPathFileAlloc(std.testing.io, "inference_outcomes/app/main.roc", allocator); + defer allocator.free(app_path); + const outcomes = [_]struct { source: []const u8, reports: bool }{ + .{ .source = "import A\nB := {}.{ value : {} -> I64\n value = |arg| A.value(arg)\n id : A -> A\n id = |item| item\n}\n", .reports = false }, + .{ .source = "import A\nB := {}.{ value : {} -> I64\n value = |unused| A.value({})\n tag : {} -> [Ok, ..]\n tag = |_| Ok\n id : A -> A\n id = |item| item\n}\n", .reports = true }, + .{ .source = "import A\nB := {}.{ value : {} -> I64\n value = |arg| A.value(arg)\n bad : I64\n bad = \"wrong\"\n id : A -> A\n id = |item| item\n}\n", .reports = true }, + .{ .source = "import A\nB := {}.{ value : {} -> I64\n value = |arg| A.value(arg)\n bad : A\n bad = \"changed error\"\n id : A -> A\n id = |item| item\n}\n", .reports = true }, + .{ .source = "import A\nB := {}.{ value : {} -> I64\n value = |arg| A.value(arg)\n id : A -> A\n id = |item| item\n}\n", .reports = false }, + }; + var saw_reference_binding = false; + var saw_diagnostic_replay = false; + for (outcomes) |outcome| { + try tmp_dir.dir.writeFile(std.testing.io, .{ .sub_path = "inference_outcomes/app/B.roc", .data = outcome.source }); + for (a_sources, 0..) |a_source, edit| { + try tmp_dir.dir.writeFile(std.testing.io, .{ .sub_path = "inference_outcomes/app/A.roc", .data = a_source }); + var cached = try compileAppFacts(allocator, cache_dir, app_path); + defer cached.deinit(allocator); + var fresh = try compileAppFacts(allocator, null, app_path); + defer fresh.deinit(allocator); + try std.testing.expectEqual(outcome.reports, cached.reports.len != 0); + try std.testing.expectEqualStrings(fresh.reports, cached.reports); + try std.testing.expectEqual(fresh.failed_modules, cached.failed_modules); + try std.testing.expectEqual(fresh.has_errors, cached.has_errors); + try std.testing.expectEqualSlices([32]u8, fresh.artifact_keys, cached.artifact_keys); + try std.testing.expectEqualSlices([32]u8, fresh.artifact_digests, cached.artifact_digests); + if (edit == 0) continue; + for (cached.checking) |module| { + if (!std.mem.eql(u8, module.name, "B") and !std.mem.eql(u8, module.name, "C")) continue; + try std.testing.expectEqual(@as(u32, 0), module.inference_runs); + try std.testing.expectEqual(@as(u32, 0), module.interfaces_built); + if (module.inference_hits != 0 and std.mem.eql(u8, module.name, "B")) { + try std.testing.expectEqual(@as(u32, 1), module.inference_hits); + try std.testing.expect(module.reference_bindings > 0); + saw_reference_binding = true; + saw_diagnostic_replay = saw_diagnostic_replay or module.diagnostics_replayed > 0; + try std.testing.expectEqual(outcome.reports, module.diagnostics_replayed > 0); + } + } + } + } + try std.testing.expect(saw_reference_binding); + try std.testing.expect(saw_diagnostic_replay); + + // Dependency reports come from its current source, even when importer + // inference is reused. Shift a warning's snippet and then remove it. + const dependency_reports = [_][]const u8{ + "A := {}.{ value : {} -> I64\n value = |unused| 7\n}\n", + "\n\nexpect True\nA := {}.{ value : {} -> I64\n value = |unused| 7\n}\n", + a_sources[0], + }; + for (dependency_reports, 0..) |source, index| { + try tmp_dir.dir.writeFile(std.testing.io, .{ .sub_path = "inference_outcomes/app/A.roc", .data = source }); + var cached = try compileAppFacts(allocator, cache_dir, app_path); + defer cached.deinit(allocator); + var fresh = try compileAppFacts(allocator, null, app_path); + defer fresh.deinit(allocator); + try std.testing.expectEqual(index < 2, cached.reports.len != 0); + try std.testing.expectEqualStrings(fresh.reports, cached.reports); + try std.testing.expectEqual(fresh.has_errors, cached.has_errors); + for (cached.checking) |module| { + if (std.mem.eql(u8, module.name, "B") or std.mem.eql(u8, module.name, "C")) try std.testing.expectEqual(@as(u32, 0), module.inference_runs); + } + } + + // The same inference capsules must observe the current dependency bodies + // when evaluating a demanded compile-time constant. + try tmp_dir.dir.writeFile(std.testing.io, .{ + .sub_path = "inference_outcomes/app/main.roc", + .data = "app [main!] { pf: platform \"./.roc_echo_platform/main.roc\" }\nimport C\nimport pf.Echo\nmessage = if C.value({}) == 7 \"ok\" else crash \"current implementation\"\nmain! = |_args| { Echo.line!(message)\n Ok({}) }\n", + }); + for (a_sources, 0..) |a_source, edit| { + try tmp_dir.dir.writeFile(std.testing.io, .{ .sub_path = "inference_outcomes/app/A.roc", .data = a_source }); + var cached = try compileAppFacts(allocator, cache_dir, app_path); + defer cached.deinit(allocator); + var fresh = try compileAppFacts(allocator, null, app_path); + defer fresh.deinit(allocator); + try std.testing.expectEqualStrings(fresh.reports, cached.reports); + try std.testing.expectEqual(edit == 1 or edit == 2, cached.has_errors); + try std.testing.expectEqual(fresh.has_errors, cached.has_errors); + for (cached.checking) |module| { + if (std.mem.eql(u8, module.name, "B") or std.mem.eql(u8, module.name, "C")) try std.testing.expectEqual(@as(u32, 0), module.inference_runs); + } + } +} + test "canonicalized module cache shares one entry between identical modules in different packages" { const allocator = std.testing.allocator; diff --git a/src/compile/inference_cache.zig b/src/compile/inference_cache.zig index 3bfd3be907e..454be16c120 100644 --- a/src/compile/inference_cache.zig +++ b/src/compile/inference_cache.zig @@ -32,8 +32,8 @@ pub const Cache = struct { }; /// Cache decoding is optional and fully precedes replacement of current - /// source. Successful admission binds the dense owner table; it does not - /// rewrite source indices, solved types, or dispatch-plan records. + /// source. Successful admission relocates explicit foreign locators and + /// binds the dense owner table without changing solved relations. pub fn load(self: Cache, key: [32]u8, env: *ModuleEnv, scope: *const check.CheckingDependencies.Scope) Allocator.Error!?Loaded { const data = self.manager.loadRawBytesMappedIn(env.gpa, .inference, key, self.directory) orelse return null; var owned = true; @@ -42,10 +42,22 @@ pub const Cache = struct { self.manager.recordInvalidationFor(.inference); return null; }; + if (!bodies.metadata.outcome.sourcesMatch(scope)) { + self.manager.recordInvalidationFor(.inference); + return null; + } const serialized: *const ModuleEnv.Serialized = @ptrCast(@alignCast(bodies.env.ptr)); const cached = try serialized.deserializeOwned(@intFromPtr(bodies.env.ptr), env.gpa, env.common.source, env.module_name); defer env.gpa.destroy(cached); errdefer cached.deinit(); + bodies.metadata.bindReferences(cached, scope) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.IncompatibleReferences => { + cached.deinit(); + self.manager.recordInvalidationFor(.inference); + return null; + }, + }; try check.CheckingDependencies.bindOwners(cached, scope); cached.qualified_module_name = env.qualified_module_name; env.deinit(); @@ -54,19 +66,17 @@ pub const Cache = struct { return .{ .data = data, .metadata = bodies.metadata, .allocator = env.gpa }; } - /// Only diagnostic-free inference is stored. Source/canonicalization - /// reports have already run against current inputs, and evaluation reports - /// are generated anew after publication, including on inference hits. - pub fn store(self: Cache, key: [32]u8, env: *const ModuleEnv, checker: *const check.Check) void { - if (checker.problems.problems.items.len != 0 or env.diagnosticCount() != 0) return; - self.storeFallible(key, env, checker) catch self.manager.recordStoreFailureFor(.inference); + /// Store the complete pre-evaluation outcome. Evaluation reports are + /// generated anew after publication, including on inference hits. + pub fn store(self: Cache, key: [32]u8, env: *const ModuleEnv, checker: *const check.Check, scope: *const check.CheckingDependencies.Scope) void { + self.storeFallible(key, env, checker, scope) catch self.manager.recordStoreFailureFor(.inference); } - fn storeFallible(self: Cache, key: [32]u8, env: *const ModuleEnv, checker: *const check.Check) Allocator.Error!void { + fn storeFallible(self: Cache, key: [32]u8, env: *const ModuleEnv, checker: *const check.Check, scope: *const check.CheckingDependencies.Scope) Allocator.Error!void { var arena = base.SingleThreadArena.init(env.gpa); defer arena.deinit(); const allocator = arena.allocator(); - const output = try check.InferenceOutput.fromChecker(allocator, checker); + const output = try check.InferenceOutput.fromChecker(allocator, checker, scope); const buffer = try encode(allocator, key, env, &output); self.manager.storeRawBytesIn(env.gpa, .inference, key, buffer, self.directory, env.module_name); } diff --git a/src/compile/messages.zig b/src/compile/messages.zig index 37f8008a0e6..952306d4044 100644 --- a/src/compile/messages.zig +++ b/src/compile/messages.zig @@ -352,6 +352,8 @@ pub const TypeCheckedResult = struct { /// A hit republishes against current artifacts without invoking inference. inference_reused: bool = false, owner_bindings: u32 = 0, + reference_bindings: u32 = 0, + diagnostics_replayed: u32 = 0, /// Package this module belongs to package_name: []const u8, /// Module identifier diff --git a/src/types/store.zig b/src/types/store.zig index 820b25709ea..601999ed669 100644 --- a/src/types/store.zig +++ b/src/types/store.zig @@ -335,9 +335,29 @@ pub const Store = struct { /// rollback never has room to forget. static_dispatch_constraint_room: std.AutoHashMapUnmanaged(u32, u32) = .empty, + /// Relocate the self-contained type store and its explicit nominal lookup index. + pub fn rebindSourceReferences(store: *Store, mapper: anytype) @TypeOf(mapper.*).Error!void { + for (0..store.descs.backing.items.len) |i| { + var desc = store.descs.backing.items.get(i); + try rebindSourceReferenceValue(&desc.content, mapper); + store.descs.backing.items.set(i, desc); + } + for (store.nominal_decls.items.items) |*decl| try rebindSourceReferenceValue(decl, mapper); + for (store.nominal_decl_index.items.items) |*entry| entry.statement = try mapper.node(entry.origin_module, entry.statement); + const Entry = @TypeOf(store.nominal_decl_index.items.items[0]); + std.mem.sort(Entry, store.nominal_decl_index.items.items, {}, struct { + fn lessThan(_: void, a: Entry, b: Entry) bool { + return if (a.origin_module == b.origin_module) a.statement < b.statement else @backingInt(a.origin_module) < @backingInt(b.origin_module); + } + }.lessThan); + for (0..store.record_fields.items.len) |i| { + var field = store.record_fields.items.get(i); + try rebindSourceReferenceValue(&field, mapper); + store.record_fields.items.set(i, field); + } + } + /// Init the unification table with default capacity. - /// For production use with source files, prefer initFromSourceLen() which - /// computes capacity based on source file size. pub fn init(gpa: Allocator) std.mem.Allocator.Error!Self { return try Self.initCapacity(gpa, 1024, 512); } @@ -4449,3 +4469,40 @@ test "mayContainErrorState tracks error descriptors and invalid nominal declarat defer copy.deinit(); try std.testing.expect(copy.mayContainErrorState()); } + +/// Translate source locators without changing a solved type relationship. +pub fn rebindSourceReferenceValue(value: anytype, mapper: anytype) @TypeOf(mapper.*).Error!void { + const T = @typeInfo(@TypeOf(value)).pointer.child; + if (T == Alias) { + if (value.source_decl.present) value.source_decl.statement = @intCast(try mapper.node(value.origin_module, value.source_decl.statement)); + } else if (T == NominalType or T == NominalDecl) { + if (value.source.present) value.source.statement = @intCast(try mapper.node(value.origin_module, value.source.statement)); + } else if (T == types.DefaultId) { + value.expr_node = try mapper.node(value.origin_module, value.expr_node); + } else switch (@typeInfo(T)) { + .@"struct" => inline for (@typeInfo(T).@"struct".field_names) |field| try rebindSourceReferenceValue(&@field(value, field), mapper), + .@"union" => inline for (@typeInfo(T).@"union".field_names) |field| { + if (value.* == @field(std.meta.Tag(T), field)) try rebindSourceReferenceValue(&@field(value, field), mapper); + }, + .optional => if (value.*) |*present| try rebindSourceReferenceValue(present, mapper), + .array => for (value) |*item| try rebindSourceReferenceValue(item, mapper), + .bool, .int, .float, .@"enum", .void => {}, + .type, + .noreturn, + .pointer, + .error_union, + .error_set, + .@"fn", + .@"opaque", + .frame, + .@"anyframe", + .vector, + .enum_literal, + .comptime_int, + .comptime_float, + .undefined, + .null, + .spirv, + => @compileError("source-reference storage needs explicit relocation for " ++ @typeName(T)), + } +} From b487d6d7cf4d9ea6a33da323782c8f4b5455dbca Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 16:19:50 -0400 Subject: [PATCH 29/97] Point the Monotype counter test at the body counters' current positions --- src/cli/main.zig | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/cli/main.zig b/src/cli/main.zig index 1362fc267cc..e133ca63328 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -18472,16 +18472,16 @@ test "post-check diagnostics preserve labeled Monotype counts" { try std.testing.expectEqual(@as(u64, 206), graph[27].count); const body = monotypeBodyCounters(diagnostics); - try std.testing.expectEqualStrings("Type instantiation scopes", body[3].name); - try std.testing.expectEqual(@as(u64, 303), body[3].count); - try std.testing.expectEqualStrings("Checked node cache hits", body[5].name); - try std.testing.expectEqual(@as(u64, 301), body[5].count); - try std.testing.expectEqualStrings("Eager iterator template bodies lowered", body[12].name); - try std.testing.expectEqual(@as(u64, 304), body[12].count); - try std.testing.expectEqualStrings("Deferred template reuses", body[13].name); - try std.testing.expectEqual(@as(u64, 305), body[13].count); - try std.testing.expectEqualStrings("Nested closures prepared", body[22].name); - try std.testing.expectEqual(@as(u64, 302), body[22].count); + try std.testing.expectEqualStrings("Type instantiation scopes", body[8].name); + try std.testing.expectEqual(@as(u64, 303), body[8].count); + try std.testing.expectEqualStrings("Checked node cache hits", body[10].name); + try std.testing.expectEqual(@as(u64, 301), body[10].count); + try std.testing.expectEqualStrings("Eager iterator template bodies lowered", body[17].name); + try std.testing.expectEqual(@as(u64, 304), body[17].count); + try std.testing.expectEqualStrings("Deferred template reuses", body[18].name); + try std.testing.expectEqual(@as(u64, 305), body[18].count); + try std.testing.expectEqualStrings("Nested closures prepared", body[27].name); + try std.testing.expectEqual(@as(u64, 302), body[27].count); const parallel = monotypeParallelCounters(.{ .worker_work_ns = 401, From 34f706c439ec54f525c86bc5be67921019b0d81f Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 16:34:09 -0400 Subject: [PATCH 30/97] Reuse settled recursive type-analysis proofs with exact invalidation --- design.md | 134 ++++-- src/base/dev_diagnostics.zig | 3 + src/cli/main.zig | 12 +- src/cli/test/parallel_cli_runner.zig | 14 +- src/collections/any_all.zig | 3 +- src/postcheck/fixed_point.zig | 156 +++++++ src/postcheck/inhabitation.zig | 85 ++++ src/postcheck/lambda_solved/solve.zig | 242 +++++++++-- src/postcheck/lambda_solved/type.zig | 18 +- src/postcheck/monotype/lower.zig | 588 +++++++++++++------------- src/postcheck/monotype/solve.zig | 60 +-- src/postcheck/proofs.zig | 292 +++++++++++++ 12 files changed, 1197 insertions(+), 410 deletions(-) create mode 100644 src/postcheck/fixed_point.zig create mode 100644 src/postcheck/inhabitation.zig create mode 100644 src/postcheck/proofs.zig diff --git a/design.md b/design.md index 38e7c458b79..558a32fd394 100644 --- a/design.md +++ b/design.md @@ -15633,38 +15633,37 @@ data, that is a compiler bug. ### Monotype completion and inhabitation analysis reuse -Each body context owns request-completion answers keyed by the exact ordered -pair of resolved graph nodes, and uninhabitedness answers keyed by resolved -node in separate inspectable-only and runtime-layout maps. These identities -belong to that context's one instantiation graph; answers never cross a graph -reset, another body, or a worker boundary. Memo capacity is proportional to the -analysis states queried in the current graph epoch, not one graph per query. -Completion path, operation, and frame storage and inhabitation evaluation -storage are reused between queries and released with the context. - -Answers are valid only at the graph's structure epoch. Every class union, -content replacement (including backing authority/use changes), and lazy leaf -expansion advances that epoch. Tag-head reordering also advances it: the -completion relation compares entries in stored order, even though sorting -preserves type meaning. Marking an already sorted head, allocating an unconnected -node, and path compression do not change existing answers. Evidence matters here -only through the explicit node content/backing it produces; analysis does not -read external evidence tables. Immutable finished-type inhabitation answers -remain owned by the graph's type analysis and survive graph mutations. - -Every completion child entry consults the same memo as top-level queries. -Lazy expansion can change the epoch during a query: an answer is recorded only -if its entire traversal used one epoch, and every lookup first retires answers -from older epochs. Invalidated queries may revisit structure; diagnostics count -those invalidations separately from graph visits and memo hits. - -Both walkers preserve their existing active-path cycle rule. A completion -cycle assumes `unchanged`; an inhabitation cycle supplies no proof of emptiness. -Each frame records the cycle counter at entry. Only a frame that finishes -without a new cycle assumption may publish a reusable answer. Cycle-dependent -frames are deliberately revisited, with those assumptions counted explicitly; -no provisional recursive answer becomes a durable fact. This changes analysis -reuse only, never typing, relation production, row policy, or reachability. +Completion is a directed relation on a request and produced node in one body +instantiation graph. Its ordered outcomes are `unchanged < completed < mismatch`. +Ordinary children take the maximum; a row extension must stay unchanged; +an inspectable checked-public backing maps either successful outcome to +completed. A finite system of these monotone equations is solved from unchanged +to its least fixed point. A back edge alone therefore contributes unchanged, +but a later completion or mismatch propagates to every dependent pair, including +pairs already encountered through the back edge. Provisional answers are never +published. Expansion and propagation use explicit worklists. + +Uninhabitedness is a separate least Boolean fixed point, initially false. +Tuples, required record fields and box contents use OR of emptiness; unions use +AND across alternatives, each of which uses OR across payloads. Empty AND is +true and empty OR is false. Inspectable-only and runtime-layout queries remain +separate, as do immutable finished types and mutable graph nodes. Settling a +recursive component publishes every node's converged answer, including false. + +Proofs are graph-owned and retain explicit dependencies on the canonical nodes +read to establish them and observe explicit producer mutation events. +Unrelated writes preserve them; a changed input or canonical representative +retires its dependent proofs. Path compression +preserves root identity and meaning. Lazy expansion, backing policy changes, +field changes and class joins are observable mutations. Completion also observes +tag-head order. A traversal that crosses an observable mutation does not publish +its previous assumptions as facts about the resulting graph. + +Proof ownership ends with the body context; no facts cross a graph reset, +worker boundary or specialization. Shared subproofs are retained once, and +scratch is reused between queries. Lambda Solved compatibility has its own +truth conditions and lifetime described below; no completion or inhabitation +truth is inferred from a compatibility proof. ### Row, Nominal, Alias, And Opaque Authority @@ -17596,16 +17595,65 @@ pre-evaluation solving and normalization from stored values remain separate invocations. No diagnostic state or solved compatibility proofs cross those boundaries. -The solver retains the structural pairs of its last completed, successful -compatibility traversal. They are reusable only at the type store's recorded -mutation epoch; any write that changes meaning invalidates them, including -lazy expansion, while path compression preserves meaning. A query that expands -leaves retains no proven pairs. Pairs visited during a query are provisional -until the entire query succeeds, so a cycle cut followed by a conflicting -sibling never retains a cyclic assumption as a completed proof. A successful -traversal replaces the previous proven set instead of accumulating every -queried pair; retained capacity is bounded by the largest traversal in this -invocation. +Compatibility means that structural unification would encounter no conflict; +it is neither type equality nor a transitive equivalence relation. Unbound +variables and compatible nominal views are not permission to equate arbitrary +third parties. Function compatibility checks arguments and results, while +callable slots still flow through the subsequent unification. + +Compatibility proofs belong to the function currently being solved and are +released at its completion, including on failure. Queries outside a function +belong to the enclosing solver operation and end with it. Within that scope, +interleaved queries retain disjoint completed proofs, including counterexamples. +Each completed traversal publishes only newly examined pairs in one certificate, +referring to earlier +certificates it consumed rather than copying their subgraphs. Storage is +O(P + E) for the distinct demanded pairs and dependency edges of the live +producer scope, never the Cartesian product of all types of the invocation. +The same bound applies to Monotype's body-owned relation graph. Certificates +and dependency arrays are freed on invalidation or scope exit; hash/worklist +capacity is reused. This is an ownership boundary, not a timed or size-based +eviction policy. Pair relations can inherently require quadratic work inside +one scope; the implementation does not retain those pairs across completed +function bodies or specializations. + +A certificate records every canonical node it reads and references to earlier +certificates. Each dependency owns a reverse link. Producers record semantic +writes in a graph-owned mutation index: one slot per node in a list ordered by +its latest write. Repeated writes move that slot to the front, so recording +never allocates and history cannot grow beyond the live graph. An observer +visits only nodes written since its last epoch, then follows reverse links to +retire affected proofs and their dependents. The mutation index occupies 16 +bytes per allocated node slot, independent of the number of writes. Removing a reverse link is constant +work using its recorded slot; invalidation frees certificates only after all +references have been detached. Unrelated writes neither inspect nor retire +unaffected proofs. Validation costs changed nodes plus affected proof edges, +not retained graph size or the number of intervening mutations. Path compression +directly shortens existing links without recording a semantic mutation. Lazy expansion or joining representatives during a +query prevents publication of that traversal. The special untouched-leaf +shortcut also reads mutable clone-context ownership; it remains a direct +answer and does not publish a structural certificate with missing dependencies. +No other traversal publishes provisional cycle assumptions. A late conflict +prevents all successful pairs of that walk from being published; only the +requested false result may be retained, with its counterexample dependencies. + +Counters separate queries, structural walks, pair expansions, repeat visits, +reuse hits, invalidations, lazy expansion and unification. Peak fact counts and +payload bytes account for certificate arrays and table payload capacity (allocator +metadata and table control bytes are excluded); allocator tests separately +assert that warmed repetition performs no allocation. +The issue 12616 interleaving fixtures settle five completion pairs and six +inhabitation nodes into 12 facts (4,176 accounted proof bytes; 6,600 total +query-owned bytes including scratch, across 49 initial allocations), and three +compatibility pairs into three facts (1,928 accounted proof bytes; 2,500 total +query-owned bytes across 20 initial allocations). Repeating A/B 512 times adds +no expansions, allocations or retained bytes. These are small deterministic +graph measurements, not an attribution of the entire compiler phase's time. +`ROC_FRESH_TYPE_ANALYSIS`, available only through developer diagnostics, starts +each top-level completion, inhabitation and compatibility query with empty +proofs. CLI differential tests compare observations, diagnostics and failures +with retained and fresh analyses, cold and warm persistent caches, after an +edit, and with one or four workers. It does not change persistence policy. Compatibility still only chooses which payloads can relate. Unification runs every required payload relation and propagates callable members and erasure. @@ -25526,7 +25574,7 @@ each phase's report. ### Developer Diagnostics The compiler's developer-only environment variables (`ROC_LIR_DUMP`, -`ROC_SPEC_CENSUS`, `ROC_PACK_TRACE`, `ROC_PACK_STATS`, `ROC_CTFE_DEMAND_TRACE`, +`ROC_SPEC_CENSUS`, `ROC_FRESH_TYPE_ANALYSIS`, `ROC_PACK_TRACE`, `ROC_PACK_STATS`, `ROC_CTFE_DEMAND_TRACE`, `ROC_CTFE_REUSE_TRACE`, `ROC_DEV_PACK_OBJECTS`, `ROC_DEV_PACK_HITS`, the object-compile round-trip variable and `ROC_TEST_LLVM_OBJECT_COMPILE_COUNT_FILE`) are honored only by a compiler for which `build_options.dev_diagnostics` is true: every Debug compiler, and any diff --git a/src/base/dev_diagnostics.zig b/src/base/dev_diagnostics.zig index 3d9e37d21a4..96cd3ce666a 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -24,6 +24,8 @@ pub const Var = enum { spec_census, /// Print deterministic Lambda Solved work once per required invocation. lambda_solve_stats, + /// Start each top-level recursive type analysis with empty proof tables. + fresh_type_analysis, /// Print every object cache lookup and offer. pack_trace, /// Print how many procedures a dev build served from cached packs. @@ -48,6 +50,7 @@ pub const Var = enum { .lir_dump => "ROC_LIR_DUMP", .spec_census => "ROC_SPEC_CENSUS", .lambda_solve_stats => "ROC_LAMBDA_SOLVE_STATS", + .fresh_type_analysis => "ROC_FRESH_TYPE_ANALYSIS", .pack_trace => "ROC_PACK_TRACE", .pack_stats => "ROC_PACK_STATS", .ctfe_demand_trace => "ROC_CTFE_DEMAND_TRACE", diff --git a/src/cli/main.zig b/src/cli/main.zig index 39a4571e7b8..61ecbe3a941 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -18068,7 +18068,7 @@ fn monotypeSpecializationCounters(diagnostics: postcheck.Monotype.Lower.Diagnost }; } -fn monotypeGraphCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [40]progress.Counter { +fn monotypeGraphCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [42]progress.Counter { const graph = diagnostics.graph; return .{ .{ .name = "Graphs created", .count = diagnostics.body.graphs_created }, @@ -18103,14 +18103,16 @@ fn monotypeGraphCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [40] .{ .name = "Completion pair entries", .count = graph.completion_pair_entries }, .{ .name = "Completion pair expansions", .count = graph.completion_pair_expansions }, .{ .name = "Completion memo hits", .count = graph.completion_memo_hits }, - .{ .name = "Completion cycle assumptions", .count = graph.completion_cycle_assumptions }, - .{ .name = "Completion invalidations", .count = graph.completion_invalidations }, + .{ .name = "Completion repeated pairs", .count = graph.completion_repeated_pairs }, .{ .name = "Inhabitation queries", .count = graph.inhabitation_queries }, .{ .name = "Inhabitation node entries", .count = graph.inhabitation_node_entries }, .{ .name = "Inhabitation node expansions", .count = graph.inhabitation_node_expansions }, .{ .name = "Inhabitation memo hits", .count = graph.inhabitation_memo_hits }, - .{ .name = "Inhabitation cycle assumptions", .count = graph.inhabitation_cycle_assumptions }, - .{ .name = "Inhabitation invalidations", .count = graph.inhabitation_invalidations }, + .{ .name = "Analysis changed nodes", .count = graph.analysis_changed_nodes }, + .{ .name = "Analysis invalidation edges", .count = graph.analysis_invalidation_edges }, + .{ .name = "Analysis peak facts", .count = graph.analysis_peak_facts }, + .{ .name = "Analysis peak payload bytes", .count = graph.analysis_peak_payload_bytes }, + .{ .name = "Analysis invalidated groups", .count = graph.analysis_invalidated_groups }, }; } diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 45e7a1dfa6e..4a9abc66333 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -3227,7 +3227,7 @@ const subcommand_cases = [_]CliCase{ .{ .id = 0, .suite = .subcommands, .name = "roc build native dev output assembled from its own procedure artifacts is identical", .timeout_ms = 600_000, .body = .{ .custom = .native_build_artifact_round_trip } }, .{ .id = 0, .suite = .subcommands, .name = "roc build native dev pack programs are deterministic and round-trip through artifacts", .timeout_ms = 600_000, .body = .{ .custom = .native_build_pack_objects } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11673: imported callable identity survives cold warm and sibling builds", .timeout_ms = 600_000, .body = .{ .custom = .issue_11673_callable_cache } }, - .{ .id = 0, .suite = .subcommands, .name = "issue 12595: lambda solving preserves test observations across cache states and workers", .backend = .dev, .body = .{ .custom = .issue_12595_lambda_solved } }, + .{ .id = 0, .suite = .subcommands, .name = "issue 12595/12616: lambda solving preserves test observations across cache states and workers", .backend = .dev, .body = .{ .custom = .issue_12595_lambda_solved } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11678: cached recursive callbacks retain method result rows", .timeout_ms = 600_000, .body = .{ .custom = .issue_11678_recursive_callback_cache } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11678: dev builds whose code reaches the Boxy runtime are identical whatever the dev cache holds, including another app's packs", .timeout_ms = 600_000, .body = .{ .custom = .issue_11678_dev_cache_boxy_tables } }, .{ .id = 0, .suite = .subcommands, .name = "issue 11710: shared object cache serves a second app's dev build", .timeout_ms = 600_000, .body = .{ .custom = .issue_11710_shared_object_cache } }, @@ -10030,10 +10030,20 @@ fn customIssue12595LambdaSolved( } var observations: ?[]const u8 = null; var failure_observations: ?[]const u8 = null; - for ([_][]const u8{ "--jobs=1", "--jobs=4" }, 0..) |jobs, index| { + const AnalysisCase = struct { jobs: []const u8, fresh: bool }; + for ([_]AnalysisCase{ + .{ .jobs = "--jobs=1", .fresh = false }, + .{ .jobs = "--jobs=4", .fresh = false }, + .{ .jobs = "--jobs=1", .fresh = true }, + .{ .jobs = "--jobs=4", .fresh = true }, + }, 0..) |analysis_case, index| { + if (analysis_case.fresh and !build_options.dev_diagnostics) continue; + const jobs = analysis_case.jobs; var isolated = CaseEnv{ .dirs = env.dirs, .env_map = env.env_map.clone(allocator) catch |err| return customInfraFailure(allocator, timer, "clone environment: {}", .{err}) }; defer isolated.env_map.deinit(); + if (analysis_case.fresh) isolated.env_map.put("ROC_FRESH_TYPE_ANALYSIS", "1") catch |err| + return customInfraFailure(allocator, timer, "enable fresh analysis: {}", .{err}); const cache = std.fmt.allocPrint(allocator, "{s}/cache_{d}", .{ env.dirs.work_dir, index }) catch |err| return customInfraFailure(allocator, timer, "cache path: {}", .{err}); isolated.env_map.put("ROC_CACHE_DIR", cache) catch |err| diff --git a/src/collections/any_all.zig b/src/collections/any_all.zig index 47896285b1d..bd0633d783d 100644 --- a/src/collections/any_all.zig +++ b/src/collections/any_all.zig @@ -31,7 +31,8 @@ pub fn Evaluation(comptime Leaf: type, comptime Context: type) type { group: Op, }; - const Item = union(enum) { + /// One producer-authored leaf or nested Boolean group. + pub const Item = union(enum) { leaf: Leaf, /// A nested group of the `count` leaves after it. group: struct { op: Op, count: usize }, diff --git a/src/postcheck/fixed_point.zig b/src/postcheck/fixed_point.zig new file mode 100644 index 00000000000..693a61b201d --- /dev/null +++ b/src/postcheck/fixed_point.zig @@ -0,0 +1,156 @@ +//! Finite monotone equations, solved iteratively from their least element. +//! Completion uses 0/1/2; emptiness uses 0/1. No provisional value escapes. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Self = @This(); + +/// How a child outcome contributes to its parent equation. +pub const Transform = enum { identity, unchanged, backing }; +const none = std.math.maxInt(usize); +const Node = struct { + value: u2 = 0, + all: bool = false, + remaining: usize = 0, + first: usize = none, +}; +const Edge = struct { parent: usize, next: usize, transform: Transform }; +nodes: std.ArrayList(Node) = .empty, +edges: std.ArrayList(Edge) = .empty, +work: std.ArrayList(usize) = .empty, +/// Release all equation and worklist storage. +pub fn deinit(self: *Self, gpa: Allocator) void { + self.nodes.deinit(gpa); + self.edges.deinit(gpa); + self.work.deinit(gpa); +} +/// Discard equations while retaining reusable scratch capacity. +pub fn clear(self: *Self) void { + self.nodes.clearRetainingCapacity(); + self.edges.clearRetainingCapacity(); + self.work.clearRetainingCapacity(); +} +/// Reserve one equation, initially at the least element. +pub fn add(self: *Self, gpa: Allocator) Allocator.Error!usize { + const index = self.nodes.items.len; + try self.nodes.append(gpa, .{}); + return index; +} +/// Add an exact dependency from a parent equation to a child. +pub fn edge(self: *Self, gpa: Allocator, parent: usize, child: usize, transform: Transform) Allocator.Error!void { + const index = self.edges.items.len; + try self.edges.append(gpa, .{ .parent = parent, .next = self.nodes.items[child].first, .transform = transform }); + self.nodes.items[child].first = index; + self.nodes.items[parent].remaining += 1; + if (transform == .backing) self.nodes.items[parent].value = @max(1, self.nodes.items[parent].value); +} +/// Read an outcome after solving has converged. +pub fn value(self: *const Self, index: usize) u2 { + return self.nodes.items[index].value; +} +/// Set the local constant before propagation begins. +pub fn set(self: *Self, index: usize, result: u2) void { + self.nodes.items[index].value = result; +} +/// Make a Boolean equation a conjunction instead of a maximum. +pub fn all(self: *Self, index: usize) void { + self.nodes.items[index].all = true; +} +/// Converge all equations by propagating monotone promotions. +pub fn solve(self: *Self, gpa: Allocator) Allocator.Error!void { + // At most two promotions per node. Reserve before propagation so an OOM + // cannot leave a partially converged answer looking completed. + try self.work.ensureTotalCapacity(gpa, self.nodes.items.len * 2); + for (self.nodes.items, 0..) |*node, index| { + if (node.all and node.remaining == 0) node.value = 1; + if (node.value != 0) self.work.appendAssumeCapacity(index); + } + var cursor: usize = 0; + while (cursor < self.work.items.len) : (cursor += 1) { + const child = self.nodes.items[self.work.items[cursor]]; + var link = child.first; + while (link != none) { + const item = self.edges.items[link]; + link = item.next; + const parent = &self.nodes.items[item.parent]; + const result: u2 = switch (item.transform) { + .identity => child.value, + .unchanged => if (child.value == 0) 0 else 2, + .backing => if (child.value == 2) 2 else 1, + }; + if (parent.all) { + std.debug.assert(child.value == 1 and item.transform == .identity); + parent.remaining -= 1; + if (parent.remaining != 0) continue; + } + if (result > parent.value) { + parent.value = result; + self.work.appendAssumeCapacity(item.parent); + } + } + } +} + +test "issue 12616 fixed points agree with independent exhaustive iteration" { + const gpa = std.testing.allocator; + // Exhaust every three-node Boolean graph and AND/OR choice. The oracle + // scans whole equations synchronously; the implementation uses counters + // and reverse edges. Neither oracle state nor provisional values are shared. + for (0..512) |links| { + for (0..8) |kinds| { + var equations: Self = .{}; + defer equations.deinit(gpa); + for (0..3) |_| _ = try equations.add(gpa); + for (0..3) |parent| { + if (kinds & (@as(usize, 1) << @intCast(parent)) != 0) equations.all(parent); + for (0..3) |child| { + if (links & (@as(usize, 1) << @intCast(parent * 3 + child)) != 0) try equations.edge(gpa, parent, child, .identity); + } + } + var oracle = [_]bool{ false, false, false }; + while (true) { + var next = oracle; + for (0..3) |parent| { + const conjunction = kinds & (@as(usize, 1) << @intCast(parent)) != 0; + next[parent] = conjunction; + for (0..3) |child| { + if (links & (@as(usize, 1) << @intCast(parent * 3 + child)) == 0) continue; + next[parent] = if (conjunction) next[parent] and oracle[child] else next[parent] or oracle[child]; + } + } + if (std.mem.eql(bool, &next, &oracle)) break; + oracle = next; + } + try equations.solve(gpa); + for (oracle, 0..) |expected, index| try std.testing.expectEqual(@intFromBool(expected), equations.value(index)); + } + } + // A back edge is followed by a completing or mismatching leaf. Completion + // and row-tail transformations must propagate through the whole cycle. + for (0..3) |leaf| { + inline for (std.meta.tags(Transform)) |transform| { + var equations: Self = .{}; + defer equations.deinit(gpa); + for (0..3) |_| _ = try equations.add(gpa); + try equations.edge(gpa, 0, 1, transform); + try equations.edge(gpa, 1, 0, .identity); + try equations.edge(gpa, 1, 2, .identity); + equations.set(2, @intCast(leaf)); + var oracle = [_]u2{ 0, 0, @intCast(leaf) }; + while (true) { + const next = [_]u2{ + switch (transform) { + .identity => oracle[1], + .unchanged => if (oracle[1] == 0) 0 else 2, + .backing => if (oracle[1] == 2) 2 else 1, + }, + @max(oracle[0], oracle[2]), + oracle[2], + }; + if (std.mem.eql(u2, &next, &oracle)) break; + oracle = next; + } + try equations.solve(gpa); + for (oracle, 0..) |expected, index| try std.testing.expectEqual(expected, equations.value(index)); + } + } +} diff --git a/src/postcheck/inhabitation.zig b/src/postcheck/inhabitation.zig new file mode 100644 index 00000000000..f98e45f4b52 --- /dev/null +++ b/src/postcheck/inhabitation.zig @@ -0,0 +1,85 @@ +//! Least fixed point of the producer's emptiness AND/OR equations. +const std = @import("std"); +const AnyAll = @import("collections").AnyAll; +const FixedPoint = @import("fixed_point.zig"); +const Allocator = std.mem.Allocator; + +/// Evaluate producer-authored emptiness equations and publish converged leaves. +pub fn Evaluation(comptime Leaf: type, comptime Context: type) type { + return struct { + const Tree = AnyAll.Evaluation(Leaf, Context); + /// The same explicit AND/OR item format used by tree evaluation. + pub const Items = Tree.Items; + /// A constant or the Boolean operator combining the emitted children. + pub const Expansion = Tree.Expansion; + /// Reusable graph and worklist storage for one non-reentrant evaluation. + pub const Scratch = struct { + leaves: std.ArrayList(Leaf) = .empty, + indices: std.AutoHashMapUnmanaged(Leaf, usize) = .empty, + equations: FixedPoint = .{}, + items: std.ArrayList(Tree.Item) = .empty, + /// Release every scratch allocation. + pub fn deinit(self: *Scratch, gpa: Allocator) void { + self.leaves.deinit(gpa); + self.indices.deinit(gpa); + self.equations.deinit(gpa); + self.items.deinit(gpa); + } + fn intern(self: *Scratch, gpa: Allocator, leaf: Leaf) Allocator.Error!usize { + const entry = try self.indices.getOrPut(gpa, leaf); + if (entry.found_existing) return entry.value_ptr.*; + const index = try self.equations.add(gpa); + entry.value_ptr.* = index; + try self.leaves.append(gpa, leaf); + return index; + } + }; + /// Expand each leaf once, converge, then deliver settled outcomes to exit. + pub fn runWith(gpa: Allocator, scratch: *Scratch, context: *Context, root: Leaf) Allocator.Error!bool { + defer { + scratch.leaves.clearRetainingCapacity(); + scratch.indices.clearRetainingCapacity(); + scratch.equations.clear(); + scratch.items.clearRetainingCapacity(); + } + const root_index = try scratch.intern(gpa, context.canonical(root)); + var cursor: usize = 0; + while (cursor < scratch.leaves.items.len) : (cursor += 1) { + const leaf = scratch.leaves.items[cursor]; + const index = scratch.indices.get(leaf).?; + scratch.items.clearRetainingCapacity(); + const expansion = try context.enter(.{ .allocator = gpa, .list = &scratch.items }, leaf); + switch (expansion) { + .value => |value| scratch.equations.set(index, @intFromBool(value)), + .group => |op| { + if (op == .all) scratch.equations.all(index); + var item_index: usize = 0; + while (item_index < scratch.items.items.len) : (item_index += 1) { + switch (scratch.items.items[item_index]) { + .leaf => |child| { + const child_index = try scratch.intern(gpa, context.canonical(child)); + try scratch.equations.edge(gpa, index, child_index, .identity); + }, + .group => |group| { + const nested = try scratch.equations.add(gpa); + if (group.op == .all) scratch.equations.all(nested); + try scratch.equations.edge(gpa, index, nested, .identity); + for (scratch.items.items[item_index + 1 ..][0..group.count]) |item| { + const child_index = try scratch.intern(gpa, context.canonical(item.leaf)); + try scratch.equations.edge(gpa, nested, child_index, .identity); + } + item_index += group.count; + }, + } + } + }, + } + } + try scratch.equations.solve(gpa); + for (scratch.leaves.items) |leaf| { + try context.exit(leaf, scratch.equations.value(scratch.indices.get(leaf).?) != 0); + } + return scratch.equations.value(root_index) != 0; + } + }; +} diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index 18d60339025..a321b81a474 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -79,6 +79,8 @@ const UnifyPairContext = struct { } }; +const CompatibilityProofs = @import("../proofs.zig").Cache(UnifyPair, Type.TypeVarId); + const UnifyPairSet = std.HashMap(UnifyPair, void, UnifyPairContext, std.hash_map.default_max_load_percentage); /// The pairs a unification walk has begun and not yet retired. A pair begun @@ -197,7 +199,11 @@ pub const WorkCounters = struct { compatibility_cycles: u64 = 0, compatibility_hits: u64 = 0, compatibility_invalidations: u64 = 0, - compatibility_peak_proven: u64 = 0, + compatibility_peak_facts: u64 = 0, + compatibility_peak_payload_bytes: u64 = 0, + compatibility_walks: u64 = 0, + compatibility_changed_nodes: u64 = 0, + compatibility_invalidation_edges: u64 = 0, /// Input member counts of rows offered to merging. row_members: u64 = 0, lambda_members: u64 = 0, @@ -239,12 +245,13 @@ pub fn run( const Solver = struct { counters: ?*WorkCounters = null, - /// Scratch and the last completed successful compatibility traversal. - /// Replacing the proven pairs bounds retained state by one traversal, not all queries. + /// Scratch and completed proofs owned by the function being solved. + /// Disjoint queries share certificates until this producer scope ends. compatibility_work: std.ArrayList(UnifyPair) = .empty, compatibility_visited: UnifyPairSet.Unmanaged = .empty, - compatibility_proven: UnifyPairSet.Unmanaged = .empty, - compatibility_epoch: u64 = 0, + compatibility_proofs: CompatibilityProofs = .{}, + compatibility_dependencies: std.ArrayList(CompatibilityProofs.Dependency) = .empty, + compatibility_entries: std.ArrayList(CompatibilityProofs.Entry) = .empty, allocator: Allocator, program: *Ast.Program, @@ -520,7 +527,9 @@ const Solver = struct { fn deinit(self: *Solver) void { self.compatibility_work.deinit(self.allocator); self.compatibility_visited.deinit(self.allocator); - self.compatibility_proven.deinit(self.allocator); + self.compatibility_proofs.deinit(self.allocator); + self.compatibility_dependencies.deinit(self.allocator); + self.compatibility_entries.deinit(self.allocator); self.tag_row_indexes.deinit(self.allocator); self.tag_row_index_entries.deinit(self.allocator); var set_indexes = self.lambda_set_indexes.valueIterator(); @@ -872,6 +881,7 @@ const Solver = struct { } fn solveFn(self: *Solver, fn_id: Lifted.FnId, fn_: Lifted.Fn) Allocator.Error!void { + defer self.compatibility_proofs.clear(self.allocator); const fn_ty = self.program.fn_tys.items[@backingInt(fn_id)]; const fn_content = self.program.types.rootContentCompressed(fn_ty); if (std.meta.activeTag(fn_content) != .func) Common.invariant("Lambda Solved function table contains a non-function type"); @@ -2248,16 +2258,21 @@ const Solver = struct { /// types with their own relations—is compatible. Preserve lazy identity /// proofs before materializing shapes that require a structural comparison. fn typesCompatible(self: *Solver, a: Type.TypeVarId, b: Type.TypeVarId) Allocator.Error!bool { + if (diagnostics.isSet(.fresh_type_analysis)) self.compatibility_proofs.clear(self.allocator); self.countWork(.compatibility_queries, 1); const root_a = self.program.types.rootCompressed(a); const root_b = self.program.types.rootCompressed(b); if (root_a == root_b) return true; self.invalidateCompatibilityProofs(); - if (self.compatibility_proven.contains(UnifyPair.init(root_a, root_b))) { + if (self.compatibility_proofs.get(UnifyPair.init(root_a, root_b))) |fact| { self.countWork(.compatibility_hits, 1); - return true; + return fact.value != 0; } - const epoch = self.program.types.mutation_epoch; + const epoch = self.program.types.analysis_changes.epoch; + const dependencies = &self.compatibility_dependencies; + dependencies.clearRetainingCapacity(); + self.countWork(.compatibility_walks, 1); + var publishable = true; const work = &self.compatibility_work; work.clearRetainingCapacity(); const visited = &self.compatibility_visited; @@ -2267,24 +2282,35 @@ const Solver = struct { const left = self.program.types.rootCompressed(pair.first); const right = self.program.types.rootCompressed(pair.second); if (left == right) continue; + try dependencies.append(self.allocator, .{ .node = left }); + try dependencies.append(self.allocator, .{ .node = right }); const raw_left = self.program.types.get(left); const raw_right = self.program.types.get(right); // Keep the same lazy relations that processUnifyPair uses. A // compatibility query must not expand these clones and thereby // destroy the proof that unification can join them directly. if (raw_left == .mono and raw_right == .mono and raw_left.mono.id == raw_right.mono.id) continue; - if (try self.canLinkUntouchedLeafToInstance(left, raw_left, pair.second, right)) continue; - if (try self.canLinkUntouchedLeafToInstance(right, raw_right, pair.first, left)) continue; - self.invalidateCompatibilityProofs(); + if (try self.canLinkUntouchedLeafToInstance(left, raw_left, pair.second, right)) { + // This answer also observes clone-context ownership. Keep its + // direct lazy proof, but do not publish a structural certificate. + publishable = false; + continue; + } + if (try self.canLinkUntouchedLeafToInstance(right, raw_right, pair.first, left)) { + publishable = false; + continue; + } const key = UnifyPair.init(left, right); if ((try visited.getOrPut(self.allocator, key)).found_existing) { self.countWork(.compatibility_cycles, 1); continue; } - if (self.compatibility_proven.contains(key)) { + if (epoch == self.program.types.analysis_changes.epoch) if (self.compatibility_proofs.get(key)) |fact| { + try dependencies.append(self.allocator, .{ .proof = fact.proof }); self.countWork(.compatibility_hits, 1); + if (fact.value == 0) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); continue; - } + }; self.countWork(.compatibility_pairs, 1); const left_content = try self.resolvedContentAt(left); const right_content = try self.resolvedContentAt(right); @@ -2301,35 +2327,35 @@ const Solver = struct { if (right_content.named.backing) |backing| try work.append(self.allocator, .{ .first = left, .second = backing.ty }); continue; } - if (left_tag != right_tag) return false; + if (left_tag != right_tag) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); switch (left_content) { .link, .mono, .unbound, .forall => unreachable, // resolvedContentAt follows links and monos, and unbound/forall pairs continued above - .primitive => |prim| if (prim != right_content.primitive) return false, + .primitive => |prim| if (prim != right_content.primitive) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable), .zst, .hole, .lambda_set, .erased, .tag_union => {}, .list => |elem| try work.append(self.allocator, .{ .first = elem, .second = right_content.list }), .box => |elem| try work.append(self.allocator, .{ .first = elem, .second = right_content.box }), .tuple => |items| { const right_items = right_content.tuple; - if (items.count() != right_items.count()) return false; + if (items.count() != right_items.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); for (0..items.count()) |index| { try work.append(self.allocator, .{ .first = self.program.types.spanItem(items, index), .second = self.program.types.spanItem(right_items, index) }); } }, .record => |fields| { const right_fields = right_content.record; - if (fields.count() != right_fields.count()) return false; + if (fields.count() != right_fields.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); for (0..fields.count()) |index| { const field = self.program.types.fieldItem(fields, index); const right_field = self.program.types.fieldItem(right_fields, index); - if (field.name != right_field.name) return false; - if ((field.value_ty == null) != (right_field.value_ty == null)) return false; + if (field.name != right_field.name) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); + if ((field.value_ty == null) != (right_field.value_ty == null)) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); try work.append(self.allocator, .{ .first = field.ty, .second = right_field.ty }); if (field.value_ty) |value_ty| try work.append(self.allocator, .{ .first = value_ty, .second = right_field.value_ty.? }); } }, .func => |func| { const right_func = right_content.func; - if (func.args.count() != right_func.args.count()) return false; + if (func.args.count() != right_func.args.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); for (0..func.args.count()) |index| { try work.append(self.allocator, .{ .first = self.program.types.spanItem(func.args, index), .second = self.program.types.spanItem(right_func.args, index) }); } @@ -2341,7 +2367,7 @@ const Solver = struct { // of their own (iterator views, opaque views); only a pair // of one declaration is examined further. if (!std.meta.eql(named.def, right_named.def) or named.kind != right_named.kind or named.builtin_owner != right_named.builtin_owner) continue; - if (named.args.count() != right_named.args.count()) return false; + if (named.args.count() != right_named.args.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); for (0..named.args.count()) |index| { try work.append(self.allocator, .{ .first = self.program.types.spanItem(named.args, index), .second = self.program.types.spanItem(right_named.args, index) }); } @@ -2350,22 +2376,45 @@ const Solver = struct { } // All visited pairs now hold, including mutually recursive ones. // A failed or graph-mutating walk never retains its assumptions. - if (epoch == self.program.types.mutation_epoch) { - std.mem.swap(UnifyPairSet.Unmanaged, &self.compatibility_proven, visited); - self.compatibility_epoch = epoch; - if (comptime @import("builtin").is_test or diagnostics.available) { - if (self.counters) |counters| counters.compatibility_peak_proven = @max(counters.compatibility_peak_proven, self.compatibility_proven.count()); + if (publishable and epoch == self.program.types.analysis_changes.epoch) { + self.compatibility_entries.clearRetainingCapacity(); + var entries = visited.keyIterator(); + while (entries.next()) |key| { + if (!self.compatibility_proofs.facts.contains(key.*)) try self.compatibility_entries.append(self.allocator, .{ .key = key.*, .value = 1 }); } + try self.compatibility_proofs.publish(self.allocator, self.compatibility_entries.items, dependencies.items); + self.recordCompatibilityStats(); } return true; } + fn recordCompatibilityStats(self: *Solver) void { + if (comptime @import("builtin").is_test or diagnostics.available) { + if (self.counters) |counters| { + counters.compatibility_peak_facts = @max(counters.compatibility_peak_facts, self.compatibility_proofs.facts.count()); + counters.compatibility_peak_payload_bytes = @max(counters.compatibility_peak_payload_bytes, self.compatibility_proofs.retainedPayloadBytes()); + } + } + } + + /// A failed walk proves only the requested failure, never the provisional + /// successes on its visited set. Retain that counterexample as one fact. + fn rejectCompatibility(self: *Solver, key: UnifyPair, epoch: u64, publishable: bool) Allocator.Error!bool { + if (publishable and epoch == self.program.types.analysis_changes.epoch) { + try self.compatibility_proofs.publish(self.allocator, &.{.{ .key = key, .value = 0 }}, self.compatibility_dependencies.items); + self.recordCompatibilityStats(); + } + return false; + } + fn invalidateCompatibilityProofs(self: *Solver) void { - const epoch = self.program.types.mutation_epoch; - if (self.compatibility_epoch == epoch) return; - if (self.compatibility_proven.count() != 0) self.countWork(.compatibility_invalidations, 1); - self.compatibility_proven.clearRetainingCapacity(); - self.compatibility_epoch = epoch; + const before = self.compatibility_proofs.invalidations; + const nodes_before = self.compatibility_proofs.changed_nodes; + const edges_before = self.compatibility_proofs.invalidation_edges; + self.compatibility_proofs.refresh(self.allocator, self.program.types.analysis_changes.epoch, &self.program.types); + self.countWork(.compatibility_invalidations, self.compatibility_proofs.invalidations - before); + self.countWork(.compatibility_changed_nodes, self.compatibility_proofs.changed_nodes - nodes_before); + self.countWork(.compatibility_invalidation_edges, self.compatibility_proofs.invalidation_edges - edges_before); } fn namedBacking(self: *Solver, ty: Type.TypeVarId) Allocator.Error!?Type.TypeVarId { @@ -5801,7 +5850,7 @@ test "issue 12595 shared recursive row payload compatibility work" { try std.testing.expectEqual(depth + 1, counts.compatibility_pairs); try std.testing.expectEqual(width - 1, counts.compatibility_hits); try std.testing.expectEqual(@as(u64, 1), counts.compatibility_cycles); - try std.testing.expectEqual(depth + 1, counts.compatibility_peak_proven); + try std.testing.expectEqual(depth + 1, counts.compatibility_peak_facts); } } } @@ -5838,13 +5887,13 @@ test "issue 12595 compatibility proofs invalidate on mutation but not compressio try types.set(right, .{ .primitive = .str }); try std.testing.expect(!try solver.typesCompatible(a, b)); try std.testing.expectEqual(@as(u64, 1), counts.compatibility_invalidations); - try std.testing.expectEqual(@as(usize, 0), solver.compatibility_proven.count()); + try std.testing.expectEqual(@as(u2, 0), solver.compatibility_proofs.get(UnifyPair.init(a, b)).?.value); try types.set(right, .{ .primitive = .u64 }); try std.testing.expect(try solver.typesCompatible(a, b)); const string = try types.add(.{ .primitive = .str }); types.setLink(right, string); try std.testing.expect(!try solver.typesCompatible(a, b)); - try std.testing.expectEqual(@as(u64, 2), counts.compatibility_invalidations); + try std.testing.expectEqual(@as(u64, 3), counts.compatibility_invalidations); } test "issue 12595 failed cyclic compatibility retains no assumptions" { @@ -5867,9 +5916,11 @@ test "issue 12595 failed cyclic compatibility retains no assumptions" { // The LIFO walk cuts the cycle before finding the conflicting sibling. try std.testing.expect(!try solver.typesCompatible(a, b)); try std.testing.expectEqual(@as(u64, 1), counts.compatibility_cycles); - try std.testing.expectEqual(@as(usize, 0), solver.compatibility_proven.count()); + try std.testing.expectEqual(@as(usize, 1), solver.compatibility_proofs.facts.count()); + try std.testing.expectEqual(@as(u2, 0), solver.compatibility_proofs.get(UnifyPair.init(a, b)).?.value); + try std.testing.expect(!solver.compatibility_proofs.facts.contains(UnifyPair.init(a_list, b_list))); try std.testing.expect(!try solver.typesCompatible(a_list, b_list)); - try std.testing.expectEqual(@as(u64, 0), counts.compatibility_hits); + try std.testing.expectEqual(@as(u64, 1), counts.compatibility_hits); } test "issue 12595 compatibility that expands lazy types retains no proofs" { @@ -5888,12 +5939,12 @@ test "issue 12595 compatibility that expands lazy types retains no proofs" { const lazy = try solver.lowerTypeFresh(mono_list); try std.testing.expect(try solver.typesCompatible(lazy, materialized)); try std.testing.expect(counts.expansions > 0); - try std.testing.expectEqual(@as(usize, 0), solver.compatibility_proven.count()); + try std.testing.expectEqual(@as(usize, 0), solver.compatibility_proofs.facts.count()); try std.testing.expect(try solver.typesCompatible(lazy, materialized)); - try std.testing.expect(solver.compatibility_proven.count() > 0); + try std.testing.expect(solver.compatibility_proofs.facts.count() > 0); } -test "issue 12595 completed compatibility replaces proofs and still propagates erasure" { +test "issue 12616 completed compatibility retains disjoint proofs and still propagates erasure" { const allocator = std.testing.allocator; var program = Ast.Program.init(allocator, emptyLiftedProgramForTest(allocator)); defer program.deinit(); @@ -5901,12 +5952,12 @@ test "issue 12595 completed compatibility replaces proofs and still propagates e defer solver.deinit(); const types = &program.types; const number = try types.add(.{ .primitive = .u64 }); - // Disjoint queries in one epoch cannot accumulate retained proofs. - for (0..100) |_| { + // Independent live queries retain their own proofs within the function scope. + for (0..100) |index| { const a = try types.add(.{ .list = number }); const b = try types.add(.{ .list = number }); try std.testing.expect(try solver.typesCompatible(a, b)); - try std.testing.expectEqual(@as(usize, 1), solver.compatibility_proven.count()); + try std.testing.expectEqual(index + 1, solver.compatibility_proofs.facts.count()); } // Erasure digests the capture symbol, so it is distinct from the lambda symbol. const captured_local: Lifted.LocalId = @fromBackingInt(1); @@ -6259,3 +6310,108 @@ test "issue 12505 scoped positional payloads only expand for reachable argument } } } + +test "issue 12616 compatibility retains interleaved recursive proofs across unrelated writes" { + var allocations = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const gpa = allocations.allocator(); + var program = Ast.Program.init(gpa, emptyLiftedProgramForTest(gpa)); + defer program.deinit(); + var solver = try Solver.init(gpa, &program); + defer solver.deinit(); + var counters: WorkCounters = .{}; + solver.counters = &counters; + const types = &program.types; + const a = try types.add(.unbound); + const b = try types.add(.unbound); + const left_leaf = try types.add(.{ .primitive = .u64 }); + const right_leaf = try types.add(.{ .primitive = .u64 }); + try types.set(a, .{ .tuple = try types.addSpan(&.{ left_leaf, a }) }); + try types.set(b, .{ .tuple = try types.addSpan(&.{ right_leaf, b }) }); + const c = try types.add(.{ .box = left_leaf }); + const d = try types.add(.{ .box = left_leaf }); + const live_before = allocations.allocated_bytes - allocations.freed_bytes; + const allocations_before = allocations.allocations; + try std.testing.expect(try solver.typesCompatible(a, b)); + try std.testing.expect(try solver.typesCompatible(c, d)); + const initial = counters.compatibility_pairs; + const bytes = solver.compatibility_proofs.retainedPayloadBytes(); + const allocated = allocations.allocations; + const total_allocated_bytes = allocations.allocated_bytes; + std.debug.print("12616 lambda solved: pairs={d} facts={d} payload_bytes={d} query_owned_bytes={d} query_allocations={d}; 512 A/B repetitions allocate and expand nothing\n", .{ initial, solver.compatibility_proofs.facts.count(), bytes, allocations.allocated_bytes - allocations.freed_bytes - live_before, allocated - allocations_before }); + for (0..512) |_| { + try std.testing.expect(try solver.typesCompatible(a, b)); + try std.testing.expect(try solver.typesCompatible(c, d)); + } + try std.testing.expectEqual(initial, counters.compatibility_pairs); + try std.testing.expectEqual(bytes, solver.compatibility_proofs.retainedPayloadBytes()); + try std.testing.expectEqual(allocated, allocations.allocations); + try std.testing.expectEqual(total_allocated_bytes, allocations.allocated_bytes); + const unrelated = try types.add(.unbound); + try types.set(unrelated, .{ .primitive = .str }); + try std.testing.expect(try solver.typesCompatible(a, b)); + try std.testing.expectEqual(initial, counters.compatibility_pairs); + try types.set(right_leaf, .{ .primitive = .str }); + try std.testing.expect(!try solver.typesCompatible(a, b)); + const after = counters.compatibility_pairs; + for (0..512) |_| try std.testing.expect(!try solver.typesCompatible(a, b)); + try std.testing.expect(try solver.typesCompatible(c, d)); + try std.testing.expectEqual(after, counters.compatibility_pairs); + solver.compatibility_proofs.clear(gpa); + try std.testing.expect(!try solver.typesCompatible(a, b)); + try std.testing.expect(try solver.typesCompatible(c, d)); +} + +test "issue 12616 compatibility publication is atomic on allocation failure" { + var offset: usize = 0; + while (true) : (offset += 1) { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + const gpa = failing.allocator(); + var program = Ast.Program.init(gpa, emptyLiftedProgramForTest(gpa)); + defer program.deinit(); + var solver = try Solver.init(gpa, &program); + defer solver.deinit(); + const types = &program.types; + const scalar = try types.add(.{ .primitive = .u64 }); + const old_a = try types.add(.{ .list = scalar }); + const old_b = try types.add(.{ .list = scalar }); + try std.testing.expect(try solver.typesCompatible(old_a, old_b)); + const a = try types.add(.unbound); + const b = try types.add(.unbound); + try types.set(a, .{ .tuple = try types.addSpan(&.{ scalar, a }) }); + try types.set(b, .{ .tuple = try types.addSpan(&.{ scalar, b }) }); + const retained = solver.compatibility_proofs.facts.count(); + failing.fail_index = failing.alloc_index + offset; + _ = solver.typesCompatible(a, b) catch |err| { + try std.testing.expectEqual(error.OutOfMemory, err); + try std.testing.expectEqual(retained, solver.compatibility_proofs.facts.count()); + failing.fail_index = std.math.maxInt(usize); + try std.testing.expect(try solver.typesCompatible(old_a, old_b)); + try std.testing.expect(try solver.typesCompatible(a, b)); + continue; + }; + try std.testing.expect(offset > 0); + break; + } +} + +test "issue 12616 field presence changes retire compatibility proofs" { + const gpa = std.testing.allocator; + var program = Ast.Program.init(gpa, emptyLiftedProgramForTest(gpa)); + defer program.deinit(); + var solver = try Solver.init(gpa, &program); + defer solver.deinit(); + const types = &program.types; + const name = try program.lifted.names.internRecordFieldLabel("value"); + const scalar = try types.add(.{ .primitive = .u64 }); + const required = try types.addFields(&.{.{ .name = name, .ty = scalar, .value_ty = null, .default = null }}); + const a = try types.add(.{ .record = required }); + const b = try types.add(.{ .record = required }); + try std.testing.expect(try solver.typesCompatible(a, b)); + const optional = try types.addFields(&.{.{ .name = name, .ty = scalar, .value_ty = scalar, .default = null }}); + try types.set(b, .{ .record = optional }); + try std.testing.expect(!try solver.typesCompatible(a, b)); + solver.compatibility_proofs.clear(gpa); + try std.testing.expect(!try solver.typesCompatible(a, b)); + try types.set(b, .{ .record = required }); + try std.testing.expect(try solver.typesCompatible(a, b)); +} diff --git a/src/postcheck/lambda_solved/type.zig b/src/postcheck/lambda_solved/type.zig index f2b7c53bfcb..5e894be0a40 100644 --- a/src/postcheck/lambda_solved/type.zig +++ b/src/postcheck/lambda_solved/type.zig @@ -241,6 +241,8 @@ pub const Store = struct { /// Advances on every write that can change a variable's meaning, so a /// result derived from the store can tell whether it is still current. mutation_epoch: u64 = 0, + /// Exact semantic writes; compression preserves every dependency. + analysis_changes: @import("../proofs.zig").Changes(TypeVarId) = .{}, pub fn init(allocator: std.mem.Allocator) Store { return .{ @@ -269,6 +271,7 @@ pub const Store = struct { self.erased_payloads.deinit(self.allocator); self.named_payloads.deinit(self.allocator); self.vars.deinit(self.allocator); + self.analysis_changes.deinit(self.allocator); } /// The node storing `content`, appending a named or erased payload to its @@ -320,9 +323,11 @@ pub const Store = struct { if (self.vars.items.len >= @backingInt(MaybeTypeVarId.none)) return error.OutOfMemory; const id: TypeVarId = @fromBackingInt(@as(u32, @intCast(self.vars.items.len))); try self.vars.ensureUnusedCapacity(self.allocator, 1); + try self.analysis_changes.ensureUnusedCapacity(self.allocator, 1); try self.owned_named_backings.ensureUnusedCapacity(self.allocator, 1); self.vars.appendAssumeCapacity(try self.storeNode(content, null)); self.owned_named_backings.appendAssumeCapacity(false); + self.analysis_changes.appendAssumeCapacity(); return id; } @@ -330,12 +335,14 @@ pub const Store = struct { if (content == .link) return self.setLink(id, content.link); const node = try self.storeNode(content, self.vars.items[@backingInt(id)]); self.mutation_epoch +%= 1; + self.analysis_changes.mark(id); self.vars.items[@backingInt(id)] = node; } /// Link `id` to `target`, carrying an owned named backing to its root. pub fn setLink(self: *Store, id: TypeVarId, target: TypeVarId) void { self.mutation_epoch +%= 1; + self.analysis_changes.mark(id); if (self.owned_named_backings.items[@backingInt(id)]) { const target_root = self.root(target); self.owned_named_backings.items[@backingInt(target_root)] = true; @@ -343,6 +350,11 @@ pub const Store = struct { self.vars.items[@backingInt(id)] = .{ .link = target }; } + /// Visit exactly the identities changed since a proof observer last ran. + pub fn analysisChangesSince(self: *const Store, epoch: u64) @import("../proofs.zig").Changes(TypeVarId).Iterator { + return self.analysis_changes.since(epoch); + } + pub fn markNamedBacking(self: *Store, id: TypeVarId) void { const root_id = self.root(id); self.owned_named_backings.items[@backingInt(root_id)] = true; @@ -375,15 +387,15 @@ pub const Store = struct { const root_id = self.root(id); // Path compression preserves every variable's meaning, so it is not a // mutation observers must see. - const epoch = self.mutation_epoch; - defer self.mutation_epoch = epoch; var current = id; while (current != root_id) { const next = switch (self.vars.items[@backingInt(current)]) { .link => |target| target, .unbound, .forall, .primitive, .named, .record, .tuple, .tag_union, .list, .box, .func, .lambda_set, .erased, .zst, .hole, .mono => break, }; - self.setLink(current, root_id); + // Existing links already transferred backing ownership. Compression + // only shortens that exact path, without changing any dependency. + self.vars.items[@backingInt(current)] = .{ .link = root_id }; current = next; } diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 0542bb87d79..e91f6849125 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -1,6 +1,7 @@ //! Checked modules to Monotype IR. const std = @import("std"); +const FixedPoint = @import("../fixed_point.zig"); const TypeDigestHasher = @import("base").TypeDigestHasher; const check = @import("check"); const can = @import("can"); @@ -22215,17 +22216,7 @@ const BodyContext = struct { /// This specialization's type solver, shared by every instantiation /// context created while lowering the same specialization. graph: *InstGraph, - /// Reusable recursion-path scratch for exact graph inhabitation walks. - /// Node IDs are dense, and each walk unsets on exit, so this replaces a - /// fresh visitation hash table per query. - inhabitation_visiting: std.bit_set.DynamicBitSetUnmanaged, - /// Each node being expanded by the running uninhabitedness scan, with the - /// scan's count of active-path cycle assumptions when it was entered. - inhabitation_entered: collections.DenseMapUnmanaged(NodeId, usize) = .empty, - /// Cycle-independent answers at `inhabitation_epoch`, per backing access. - node_uninhabited_memo: [2]collections.DenseMapUnmanaged(NodeId, bool) = .{ .empty, .empty }, - inhabitation_epoch: u32 = 0, - completion_memo: CompletionMemo = .{}, + analysis_memo: AnalysisMemo = .{}, completion_scratch: CompletionScratch = .{}, /// The stacks the uninhabitedness scans run on, kept between scans. node_uninhabited_scratch: NodeUninhabitedScan.Evaluation.Scratch = .{}, @@ -23133,7 +23124,6 @@ const BodyContext = struct { .typed_binders = TypedBinders.init(allocator), .local_proc_contexts = LocalProcContexts.init(allocator), .graph = graph, - .inhabitation_visiting = .{}, .draft = draft, .evidence = rootEvidence(owner_template, &.{}), .restore_evidence = rootEvidence(owner_template, &.{}), @@ -23178,9 +23168,8 @@ const BodyContext = struct { self.pattern_literal_guards.deinit(self.allocator); self.optional_destruct_binds.deinit(self.allocator); self.loop_contexts.deinit(self.allocator); - self.completion_memo.deinit(self.allocator); + self.analysis_memo.deinit(self.allocator); self.completion_scratch.deinit(self.allocator); - self.inhabitation_visiting.deinit(self.allocator); self.releaseMemos(); self.node_uninhabited_scratch.deinit(self.allocator); self.pattern_uninhabited_scratch.deinit(self.allocator); @@ -26412,8 +26401,6 @@ const BodyContext = struct { /// The per-body memo maps the builder pools across body contexts. const Memos = struct { - inhabitation_entered: collections.DenseMapUnmanaged(NodeId, usize) = .empty, - node_uninhabited_memo: [2]collections.DenseMapUnmanaged(NodeId, bool) = .{ .empty, .empty }, explicit_binding_memo: collections.DenseMapUnmanaged(checked.CheckedPatternId, bool) = .empty, /// The chunk-directory span past which a released map gives up its @@ -26421,30 +26408,22 @@ const BodyContext = struct { const retained_chunks = 256; fn deinit(self: *Memos, allocator: Allocator) void { - self.inhabitation_entered.deinit(allocator); - for (&self.node_uninhabited_memo) |*settled| settled.deinit(allocator); self.explicit_binding_memo.deinit(allocator); } }; fn installMemos(self: *BodyContext, memos: Memos) void { - self.inhabitation_entered = memos.inhabitation_entered; - self.node_uninhabited_memo = memos.node_uninhabited_memo; self.explicit_binding_memo = memos.explicit_binding_memo; } /// Empty this body's memo maps and return them to the builder's pool. fn releaseMemos(self: *BodyContext) void { var memos: Memos = .{ - .inhabitation_entered = self.inhabitation_entered, - .node_uninhabited_memo = self.node_uninhabited_memo, .explicit_binding_memo = self.explicit_binding_memo, }; // A pooled map keeps the pages of a body-sized key range, not every // page any body ever touched. const allocator = self.builder.allocator; - memos.inhabitation_entered.clearRetainingAtMost(allocator, Memos.retained_chunks); - for (&memos.node_uninhabited_memo) |*settled| settled.clearRetainingAtMost(allocator, Memos.retained_chunks); memos.explicit_binding_memo.clearRetainingAtMost(allocator, Memos.retained_chunks); self.builder.body_memo_pool.append(self.builder.allocator, memos) catch memos.deinit(self.builder.allocator); } @@ -28596,86 +28575,96 @@ const BodyContext = struct { produced: NodeId, }; - /// Completion revisits many siblings while keeping only the current path. - /// Tombstone deletion makes that workload saturate a probing hash table; - /// dense-map deletion repairs its index instead, without memoizing the - /// provisional answers used to cut recursive cycles. - const RequestCompletionPath = struct { - const Map = std.array_hash_map.Auto(RequestCompletionPair, void); - - map: Map = .empty, - allocator: Allocator, - - fn init(allocator: Allocator) RequestCompletionPath { - return .{ .allocator = allocator }; - } - - fn deinit(self: *RequestCompletionPath) void { - self.map.deinit(self.allocator); - } - - fn enter(self: *RequestCompletionPath, pair: RequestCompletionPair) Allocator.Error!bool { - return !(try self.map.getOrPut(self.allocator, pair)).found_existing; - } - - fn leave(self: *RequestCompletionPath, pair: RequestCompletionPair) void { - const removed = self.map.swapRemove(pair); - std.debug.assert(removed); - } - }; - const ProducedValuePair = struct { request: NodeId, produced: NodeId, }; - /// Answers belong to this body and this graph epoch. No cycle-dependent - /// relation is published, even when a later sibling decides the result. - const CompletionMemo = struct { - epoch: u32 = 0, - relations: std.AutoHashMapUnmanaged(RequestCompletionPair, RequestCompletion) = .empty, + const AnalysisKey = union(enum) { + completion: RequestCompletionPair, + inhabitation: struct { node: NodeId, access: UninhabitedBackingAccess }, + }; + const AnalysisProofs = @import("../proofs.zig").Cache(AnalysisKey, NodeId); + + /// Distinct analyses share dependency ownership, never truth conditions. + const AnalysisMemo = struct { + proofs: AnalysisProofs = .{}, + dependencies: std.ArrayList(AnalysisProofs.Dependency) = .empty, + entries: std.ArrayList(AnalysisProofs.Entry) = .empty, + depth: usize = 0, + + fn deinit(self: *AnalysisMemo, allocator: Allocator) void { + self.proofs.deinit(allocator); + self.dependencies.deinit(allocator); + self.entries.deinit(allocator); + } + + fn begin(self: *AnalysisMemo, ctx: *BodyContext) usize { + if (self.depth == 0) { + if (dev_diagnostics.isSet(.fresh_type_analysis)) self.proofs.clear(ctx.allocator); + const before = self.proofs.invalidations; + const nodes_before = self.proofs.changed_nodes; + const edges_before = self.proofs.invalidation_edges; + self.proofs.refresh(ctx.allocator, ctx.graph.analysis_changes.epoch, ctx.graph); + if (ctx.graph.diagnostics) |stats| { + stats.analysis_invalidated_groups += self.proofs.invalidations - before; + stats.analysis_changed_nodes += self.proofs.changed_nodes - nodes_before; + stats.analysis_invalidation_edges += self.proofs.invalidation_edges - edges_before; + } + } + self.depth += 1; + return self.dependencies.items.len; + } - fn deinit(self: *CompletionMemo, allocator: Allocator) void { - self.relations.deinit(allocator); + fn end(self: *AnalysisMemo, start: usize) void { + self.depth -= 1; + self.dependencies.shrinkRetainingCapacity(start); } - fn get(self: *CompletionMemo, graph: *InstGraph, pair: RequestCompletionPair) ?RequestCompletion { - if (self.epoch != graph.structure_epoch) { - graph.countDiagnostic("completion_invalidations"); - self.relations.clearRetainingCapacity(); - self.epoch = graph.structure_epoch; + fn recordStats(self: *AnalysisMemo, ctx: *BodyContext) void { + if (ctx.graph.diagnostics) |stats| { + stats.analysis_peak_facts = @max(stats.analysis_peak_facts, self.proofs.facts.count()); + stats.analysis_peak_payload_bytes = @max(stats.analysis_peak_payload_bytes, self.proofs.retainedPayloadBytes()); } - return self.relations.get(pair); } - fn put(self: *CompletionMemo, ctx: *BodyContext, pair: RequestCompletionPair, relation: RequestCompletion, epoch: u32) Allocator.Error!void { - // Reading a leaf may expand it and join its component classes. - // A traversal spanning that change cannot certify an epoch fact. - if (epoch != ctx.graph.structure_epoch) return; - _ = self.get(ctx.graph, pair); - try self.relations.put(ctx.allocator, pair, relation); + fn read(self: *AnalysisMemo, ctx: *BodyContext, node: NodeId) Allocator.Error!void { + try self.dependencies.append(ctx.allocator, .{ .node = ctx.graph.rootNode(node) }); } - }; - const CompletionFrame = struct { - pair: RequestCompletionPair, - ops_start: usize, - next: usize, - relation: RequestCompletion, - cycles_at_entry: usize, - epoch: u32, + fn lookup(self: *AnalysisMemo, ctx: *BodyContext, key: AnalysisKey) Allocator.Error!?u2 { + if (self.proofs.epoch != ctx.graph.analysis_changes.epoch) return null; + const fact = self.proofs.get(key) orelse return null; + if (self.depth > 0) try self.dependencies.append(ctx.allocator, .{ .proof = fact.proof }); + return fact.value; + } + + fn get(self: *AnalysisMemo, ctx: *BodyContext, pair: RequestCompletionPair) Allocator.Error!?RequestCompletion { + return if (try self.lookup(ctx, .{ .completion = pair })) |value| @fromBackingInt(@intCast(value)) else null; + } }; const CompletionScratch = struct { - path: RequestCompletionPath.Map = .empty, - frames: std.ArrayList(CompletionFrame) = .empty, + pairs: std.ArrayList(RequestCompletionPair) = .empty, + indices: std.AutoHashMapUnmanaged(RequestCompletionPair, usize) = .empty, + equations: FixedPoint = .{}, ops: std.ArrayList(RequestCompletionOp) = .empty, fn deinit(self: *CompletionScratch, allocator: Allocator) void { - self.path.deinit(allocator); - self.frames.deinit(allocator); + self.pairs.deinit(allocator); + self.indices.deinit(allocator); + self.equations.deinit(allocator); self.ops.deinit(allocator); } + + fn intern(self: *CompletionScratch, allocator: Allocator, pair: RequestCompletionPair) Allocator.Error!usize { + const entry = try self.indices.getOrPut(allocator, pair); + if (entry.found_existing) return entry.value_ptr.*; + const index = try self.equations.add(allocator); + try self.pairs.append(allocator, pair); + entry.value_ptr.* = index; + return index; + } }; const ProducedValueRowKind = enum { @@ -28690,9 +28679,7 @@ const BodyContext = struct { ) Allocator.Error!bool { var timing_scope = BodyWorkTimingScope.begin(self.builder.timing, .type_graph); defer timing_scope.end(); - var visiting = RequestCompletionPath{ .allocator = self.allocator, .map = self.completion_scratch.path }; - defer self.completion_scratch.path = visiting.map; - return (try self.requestCompletionRelation(request_ret_node, produced_ret_node, &visiting, &self.completion_memo)) == .completed; + return (try self.requestCompletionRelation(request_ret_node, produced_ret_node, &self.analysis_memo)) == .completed; } fn relateCheckedNodeToProducedValue( @@ -29264,125 +29251,82 @@ const BodyContext = struct { }, }; - /// Whether a produced value's graph completes a request's. Composite - /// relations are explicit frames over their child relations, so type - /// depth never becomes native call depth; a pair already on the current - /// path is unchanged by assumption. With `memo`, every relation decided - /// without such an assumption is recorded for its pair. + /// Solve the finite completion equations from unchanged. Shared pairs are + /// expanded once, and recursive dependencies settle before publication. fn requestCompletionRelation( self: *BodyContext, request_node: NodeId, produced_node: NodeId, - visiting: *RequestCompletionPath, - memo: ?*CompletionMemo, + memo: ?*AnalysisMemo, ) Allocator.Error!RequestCompletion { self.graph.countDiagnostic("completion_queries"); - const frames = &self.completion_scratch.frames; - const ops = &self.completion_scratch.ops; - std.debug.assert(frames.items.len == 0 and ops.items.len == 0 and visiting.map.count() == 0); + const recording = self.analysis_memo.begin(self); + defer self.analysis_memo.end(recording); + const scratch = &self.completion_scratch; + std.debug.assert(scratch.pairs.items.len == 0); defer { - frames.clearRetainingCapacity(); - ops.clearRetainingCapacity(); - visiting.map.clearRetainingCapacity(); - } - var cycles: usize = 0; - const root_epoch = self.graph.structure_epoch; - - var delivered: RequestCompletion = undefined; - var has_delivery = false; - var assumed = false; - if (try self.enterRequestCompletion(request_node, produced_node, visiting, ops, &assumed, memo)) |relation| { - if (memo) |m| try m.put(self, .{ .request = self.graph.rootNode(request_node), .produced = self.graph.rootNode(produced_node) }, relation, root_epoch); - return relation; - } - try frames.append(self.allocator, .{ - .pair = .{ .request = self.graph.rootNode(request_node), .produced = self.graph.rootNode(produced_node) }, - .ops_start = 0, - .next = 0, - .relation = .unchanged, - .cycles_at_entry = cycles, - .epoch = root_epoch, - }); - while (true) { - const frame = &frames.items[frames.items.len - 1]; - var finished: ?RequestCompletion = null; - if (has_delivery) { - has_delivery = false; - const op = ops.items[frame.next - 1]; - switch (op.combine) { - .fold => switch (delivered) { - .unchanged => {}, - .completed => frame.relation = .completed, - .mismatch => finished = .mismatch, - }, - .unchanged_extension => if (delivered != .unchanged) { - finished = .mismatch; - }, - .backing => frame.relation = switch (delivered) { - .unchanged, .completed => .completed, - .mismatch => .mismatch, - }, - } + scratch.pairs.clearRetainingCapacity(); + scratch.indices.clearRetainingCapacity(); + scratch.equations.clear(); + scratch.ops.clearRetainingCapacity(); + } + const epoch = self.graph.analysis_changes.epoch; + const root_pair: RequestCompletionPair = .{ .request = self.graph.rootNode(request_node), .produced = self.graph.rootNode(produced_node) }; + if (memo) |m| if (try m.get(self, root_pair)) |answer| { + self.graph.countDiagnostic("completion_memo_hits"); + return answer; + }; + _ = try scratch.intern(self.allocator, root_pair); + var index: usize = 0; + while (index < scratch.pairs.items.len) : (index += 1) { + const pair = scratch.pairs.items[index]; + scratch.ops.clearRetainingCapacity(); + if (try self.enterRequestCompletion(pair.request, pair.produced, &scratch.ops, memo)) |answer| { + scratch.equations.set(index, @backingInt(answer)); + continue; } - if (finished == null) { - if (frame.next < ops.items.len) { - const op = ops.items[frame.next]; - frame.next += 1; - const ops_start = ops.items.len; - assumed = false; - const child_epoch = self.graph.structure_epoch; - if (try self.enterRequestCompletion(op.request, op.produced, visiting, ops, &assumed, memo)) |relation| { - if (assumed) { - cycles += 1; - } else if (memo) |m| { - try m.put(self, .{ .request = self.graph.rootNode(op.request), .produced = self.graph.rootNode(op.produced) }, relation, child_epoch); - } - delivered = relation; - has_delivery = true; - } else { - try frames.append(self.allocator, .{ - .pair = .{ .request = self.graph.rootNode(op.request), .produced = self.graph.rootNode(op.produced) }, - .ops_start = ops_start, - .next = ops_start, - .relation = .unchanged, - .cycles_at_entry = cycles, - .epoch = child_epoch, - }); - } - continue; - } - finished = frame.relation; + for (scratch.ops.items) |op| { + const child_pair: RequestCompletionPair = .{ .request = self.graph.rootNode(op.request), .produced = self.graph.rootNode(op.produced) }; + if (scratch.indices.contains(child_pair)) self.graph.countDiagnostic("completion_repeated_pairs"); + const child = try scratch.intern(self.allocator, child_pair); + try scratch.equations.edge(self.allocator, index, child, switch (op.combine) { + .fold => .identity, + .unchanged_extension => .unchanged, + .backing => .backing, + }); } - - const done = frames.pop().?; - visiting.leave(done.pair); - ops.shrinkRetainingCapacity(done.ops_start); - if (memo) |m| if (done.cycles_at_entry == cycles) try m.put(self, done.pair, finished.?, done.epoch); - if (frames.items.len == 0) return finished.?; - delivered = finished.?; - has_delivery = true; } + try scratch.equations.solve(self.allocator); + if (memo) |m| if (epoch == self.graph.analysis_changes.epoch and m.proofs.epoch == epoch) { + m.entries.clearRetainingCapacity(); + for (scratch.pairs.items, 0..) |pair, i| { + const key: AnalysisKey = .{ .completion = pair }; + if (!m.proofs.facts.contains(key)) try m.entries.append(self.allocator, .{ .key = key, .value = scratch.equations.value(i) }); + } + try m.proofs.publish(self.allocator, m.entries.items, m.dependencies.items[recording..]); + m.recordStats(self); + }; + return @fromBackingInt(@intCast(scratch.equations.value(0))); } - /// A pair's relation when it is decided at once; otherwise mark it on the - /// current path, list its child relations, and return null. + /// Produce one local equation, or a directly decided relation. fn enterRequestCompletion( self: *BodyContext, request_node: NodeId, produced_node: NodeId, - visiting: *RequestCompletionPath, ops: *std.ArrayListUnmanaged(RequestCompletionOp), - assumed: *bool, - memo: ?*CompletionMemo, + memo: ?*AnalysisMemo, ) Allocator.Error!?RequestCompletion { const gpa = self.allocator; self.graph.countDiagnostic("completion_pair_entries"); const request_root = self.graph.rootNode(request_node); const produced_root = self.graph.rootNode(produced_node); - if (memo) |m| if (m.get(self.graph, .{ .request = request_root, .produced = produced_root })) |relation| { + if (memo) |m| if (try m.get(self, .{ .request = request_root, .produced = produced_root })) |relation| { self.graph.countDiagnostic("completion_memo_hits"); return relation; }; + try self.analysis_memo.read(self, request_root); + try self.analysis_memo.read(self, produced_root); if (request_root == produced_root) return .unchanged; if (try self.nodeIsProvenUninhabited(request_root) and !try self.nodeIsProvenUninhabited(produced_root)) @@ -29390,13 +29334,6 @@ const BodyContext = struct { return .completed; } - const pair = RequestCompletionPair{ .request = request_root, .produced = produced_root }; - if (!try visiting.enter(pair)) { - self.graph.countDiagnostic("completion_cycle_assumptions"); - assumed.* = true; - return .unchanged; - } - errdefer visiting.leave(pair); self.graph.countDiagnostic("completion_pair_expansions"); const start = ops.items.len; const relation: ?RequestCompletion = relation: { @@ -29441,6 +29378,8 @@ const BodyContext = struct { .func => |produced_fn| blk: { if (request_fn.args.len != produced_fn.args.len) break :blk .mismatch; for (request_fn.args, produced_fn.args) |request_arg, produced_arg| { + try self.analysis_memo.read(self, request_arg); + try self.analysis_memo.read(self, produced_arg); if (!self.graph.sameClass(request_arg, produced_arg)) break :blk .mismatch; } try ops.append(gpa, .{ .request = request_fn.ret, .produced = produced_fn.ret, .combine = .fold }); @@ -29504,7 +29443,6 @@ const BodyContext = struct { }; if (relation) |decided| { ops.shrinkRetainingCapacity(start); - visiting.leave(pair); return decided; } return null; @@ -37119,14 +37057,32 @@ const BodyContext = struct { backing_access: UninhabitedBackingAccess, ) Allocator.Error!bool { self.graph.countDiagnostic("inhabitation_queries"); - if (self.inhabitation_epoch != self.graph.structure_epoch) { - self.graph.countDiagnostic("inhabitation_invalidations"); - for (&self.node_uninhabited_memo) |*memo| memo.clearRetainingCapacity(); - self.inhabitation_epoch = self.graph.structure_epoch; - } - std.debug.assert(self.inhabitation_entered.count() == 0); + const memo = &self.analysis_memo; + const recording = memo.begin(self); + const epoch = self.graph.analysis_changes.epoch; + var ended = false; + defer if (!ended) memo.end(recording); + const key: AnalysisKey = .{ .inhabitation = .{ .node = self.graph.rootNode(node), .access = backing_access } }; + if (try memo.lookup(self, key)) |answer| { + self.graph.countDiagnostic("inhabitation_memo_hits"); + const fact = memo.proofs.get(key).?; + memo.end(recording); + ended = true; + if (memo.depth > 0) try memo.dependencies.append(self.allocator, .{ .proof = fact.proof }); + return answer != 0; + } + memo.entries.clearRetainingCapacity(); var scan = NodeUninhabitedScan{ .body = self, .backing_access = backing_access }; - return try NodeUninhabitedScan.Evaluation.runWith(self.allocator, &self.node_uninhabited_scratch, &scan, node); + const answer = try NodeUninhabitedScan.Evaluation.runWith(self.allocator, &self.node_uninhabited_scratch, &scan, node); + if (epoch == self.graph.analysis_changes.epoch and memo.proofs.epoch == epoch) { + try memo.proofs.publish(self.allocator, memo.entries.items, memo.dependencies.items[recording..]); + memo.recordStats(self); + const fact = memo.proofs.get(key).?; + memo.end(recording); + ended = true; + if (memo.depth > 0) try memo.dependencies.append(self.allocator, .{ .proof = fact.proof }); + } + return answer; } fn typeIsProvenUninhabited(self: *BodyContext, ty: Type.TypeId) Allocator.Error!bool { @@ -66523,31 +66479,6 @@ fn numeralTargetFromPrimitive(primitive: Type.Primitive) exact_numeral.Target { }; } -test "completion path retains ancestors across distinct sibling visits" { - var path = BodyContext.RequestCompletionPath.init(std.testing.allocator); - defer path.deinit(); - const ancestor: BodyContext.RequestCompletionPair = .{ - .request = @fromBackingInt(1), - .produced = @fromBackingInt(2), - }; - try std.testing.expect(try path.enter(ancestor)); - for (3..4096) |index| { - const sibling: BodyContext.RequestCompletionPair = .{ - .request = @fromBackingInt(@intCast(index)), - .produced = @fromBackingInt(@intCast(index + 4096)), - }; - try std.testing.expect(try path.enter(sibling)); - try std.testing.expect(!try path.enter(ancestor)); - try std.testing.expect(!try path.enter(sibling)); - path.leave(sibling); - try std.testing.expectEqual(@as(usize, 1), path.map.count()); - } - path.leave(ancestor); - try std.testing.expectEqual(@as(usize, 0), path.map.count()); - try std.testing.expect(try path.enter(ancestor)); - path.leave(ancestor); -} - /// Owns exactly the state used by the two graph analyses; no checked program /// or backend is needed to exercise their identity and mutation contracts. const CompletionAnalysisFixture = struct { @@ -66575,42 +66506,42 @@ const CompletionAnalysisFixture = struct { self.ctx.allocator = allocator; self.ctx.builder = &self.builder; self.ctx.graph = self.graph; - self.ctx.inhabitation_visiting = .{}; - self.ctx.inhabitation_entered = .empty; - self.ctx.node_uninhabited_memo = .{ .empty, .empty }; self.ctx.node_uninhabited_scratch = .{}; - self.ctx.inhabitation_epoch = 0; - self.ctx.completion_memo = .{}; + self.ctx.analysis_memo = .{}; self.ctx.completion_scratch = .{}; } fn deinit(self: *@This()) void { self.ctx.completion_scratch.deinit(self.allocator); - self.ctx.completion_memo.deinit(self.allocator); + self.ctx.analysis_memo.deinit(self.allocator); self.ctx.node_uninhabited_scratch.deinit(self.allocator); - for (&self.ctx.node_uninhabited_memo) |*memo| memo.deinit(self.allocator); - self.ctx.inhabitation_entered.deinit(self.allocator); - self.ctx.inhabitation_visiting.deinit(self.allocator); self.graph.destroy(); self.name_store.deinit(); self.types.deinit(); } fn expectRelation(self: *@This(), expected: BodyContext.RequestCompletion, request: NodeId, produced: NodeId) !void { - var path = BodyContext.RequestCompletionPath.init(self.allocator); - defer path.deinit(); for (0..2) |_| { - try std.testing.expectEqual(expected, try self.ctx.requestCompletionRelation(request, produced, &path, &self.ctx.completion_memo)); - try std.testing.expectEqual(@as(usize, 0), path.map.count()); + try std.testing.expectEqual(expected, try self.ctx.requestCompletionRelation(request, produced, &self.ctx.analysis_memo)); + } + // Compare against a new dependency scope. + var fresh: BodyContext.AnalysisMemo = .{}; + std.mem.swap(BodyContext.AnalysisMemo, &fresh, &self.ctx.analysis_memo); + defer { + std.mem.swap(BodyContext.AnalysisMemo, &fresh, &self.ctx.analysis_memo); + fresh.deinit(self.allocator); } - // The uncached relation and a fresh inhabitation scope must agree. - for (&self.ctx.node_uninhabited_memo) |*memo| memo.clearRetainingCapacity(); - try std.testing.expectEqual(expected, try self.ctx.requestCompletionRelation(request, produced, &path, null)); + try std.testing.expectEqual(expected, try self.ctx.requestCompletionRelation(request, produced, null)); } fn expectUninhabited(self: *@This(), node: NodeId, access: BodyContext.UninhabitedBackingAccess, expected: bool) !void { for (0..2) |_| try std.testing.expectEqual(expected, try self.ctx.nodeIsProvenUninhabitedInner(node, access)); - for (&self.ctx.node_uninhabited_memo) |*memo| memo.clearRetainingCapacity(); + var fresh: BodyContext.AnalysisMemo = .{}; + std.mem.swap(BodyContext.AnalysisMemo, &fresh, &self.ctx.analysis_memo); + defer { + std.mem.swap(BodyContext.AnalysisMemo, &fresh, &self.ctx.analysis_memo); + fresh.deinit(self.allocator); + } try std.testing.expectEqual(expected, try self.ctx.nodeIsProvenUninhabitedInner(node, access)); } }; @@ -66644,10 +66575,10 @@ test "completion analysis shared subgraph work" { } try std.testing.expectEqual(@as(u64, 17), diagnostics.completion_pair_expansions); try std.testing.expectEqual(@as(u64, 18), diagnostics.inhabitation_node_expansions); - try std.testing.expectEqual(@as(usize, 18), ctx.completion_memo.relations.count()); - try std.testing.expectEqual(@as(usize, 18), ctx.node_uninhabited_memo[0].count()); - try std.testing.expectEqual(@as(u64, 158), diagnostics.completion_memo_hits); - try std.testing.expect(counting.allocations - allocations_before <= 20); + try std.testing.expectEqual(@as(usize, 36), ctx.analysis_memo.proofs.facts.count()); + + try std.testing.expectEqual(@as(u64, 127), diagnostics.completion_memo_hits); + try std.testing.expect(counting.allocations > allocations_before); const allocations_warm = counting.allocations; const work_warm = diagnostics.*; @@ -66693,10 +66624,10 @@ test "completion relation preserves recursive and shared DAG outcomes" { const request = try graph.newNode(.{ .tuple = request_siblings }); const produced = try graph.newNode(.{ .tuple = produced_siblings }); try fixture.expectRelation(expected, request, produced); - // Every enclosing relation observed a cycle and must remain absent. - try std.testing.expect(!fixture.ctx.completion_memo.relations.contains(.{ .request = request, .produced = produced })); - try std.testing.expect(!fixture.ctx.completion_memo.relations.contains(.{ .request = request_tuple, .produced = produced_tuple })); - try std.testing.expect(fixture.diagnostics.completion_cycle_assumptions > 0); + // Every recursive equation is settled together, including enclosing pairs. + try std.testing.expect(fixture.ctx.analysis_memo.proofs.facts.contains(.{ .completion = .{ .request = request, .produced = produced } })); + try std.testing.expect(fixture.ctx.analysis_memo.proofs.facts.contains(.{ .completion = .{ .request = request_tuple, .produced = produced_tuple } })); + try std.testing.expect(fixture.diagnostics.completion_repeated_pairs > 0); } } @@ -66715,8 +66646,7 @@ test "completion analysis invalidates joins and keeps unrelated allocation and p try graph.unify(variable, empty); try fixture.expectRelation(.completed, request, produced); try fixture.expectUninhabited(request, .inspectable_only, true); - try std.testing.expect(fixture.diagnostics.completion_invalidations > 0); - try std.testing.expect(fixture.diagnostics.inhabitation_invalidations > 0); + try std.testing.expect(fixture.diagnostics.analysis_invalidated_groups > 0); const epoch = graph.structure_epoch; const work = fixture.diagnostics; @@ -66740,15 +66670,13 @@ test "completion analysis retires facts across lazy expansion and tag reordering const request = try graph.importMonoIndependent(boxed); const produced = try graph.newNode(.{ .box = try graph.newNode(.{ .primitive = .i64 }) }); const epoch = graph.structure_epoch; - var path = BodyContext.RequestCompletionPath.init(std.testing.allocator); - defer path.deinit(); - try std.testing.expectEqual(.unchanged, try fixture.ctx.requestCompletionRelation(request, produced, &path, &fixture.ctx.completion_memo)); + try std.testing.expectEqual(.unchanged, try fixture.ctx.requestCompletionRelation(request, produced, &fixture.ctx.analysis_memo)); try std.testing.expect(graph.structure_epoch != epoch); // This traversal expanded leaves. Its outer answer cannot be published // as though the whole walk had observed the final epoch. - try std.testing.expect(!fixture.ctx.completion_memo.relations.contains(.{ .request = request, .produced = produced })); + try std.testing.expect(!fixture.ctx.analysis_memo.proofs.facts.contains(.{ .completion = .{ .request = request, .produced = produced } })); try fixture.expectRelation(.unchanged, request, produced); - try std.testing.expect(fixture.ctx.completion_memo.relations.contains(.{ .request = request, .produced = produced })); + try std.testing.expect(fixture.ctx.analysis_memo.proofs.facts.contains(.{ .completion = .{ .request = request, .produced = produced } })); const first = try fixture.name_store.internTagLabel("A"); const last = try fixture.name_store.internTagLabel("Z"); @@ -66838,11 +66766,10 @@ test "completion analysis does not publish a recursive inhabitation assumption" components[0] = inner; components[1] = try graph.newNode(.empty_tag_union); try std.testing.expect(try fixture.ctx.nodeIsProvenUninhabited(outer)); - // While outer was active, inner provisionally answered false. A later - // query of inner must discover the empty sibling rather than reuse it. - try std.testing.expect(!fixture.ctx.node_uninhabited_memo[0].contains(inner)); + // The empty sibling propagates through the back edge before either + // recursive node becomes a reusable fact. + try std.testing.expectEqual(true, (fixture.ctx.analysis_memo.proofs.get(.{ .inhabitation = .{ .node = inner, .access = .inspectable_only } }).?.value != 0)); try fixture.expectUninhabited(inner, .inspectable_only, true); - try std.testing.expect(fixture.diagnostics.inhabitation_cycle_assumptions > 0); } test "completion analysis scratch and partial memos survive every allocation failure" { @@ -66873,11 +66800,8 @@ test "completion analysis scratch and partial memos survive every allocation fai failing.fail_index = failing.alloc_index + fail_offset; const result = Probe.run(&fixture.ctx, inhabited, request, produced) catch |err| { try std.testing.expectEqual(error.OutOfMemory, err); - try std.testing.expectEqual(@as(usize, 0), fixture.ctx.completion_scratch.path.count()); - try std.testing.expectEqual(@as(usize, 0), fixture.ctx.completion_scratch.frames.items.len); + try std.testing.expectEqual(@as(usize, 0), fixture.ctx.completion_scratch.pairs.items.len); try std.testing.expectEqual(@as(usize, 0), fixture.ctx.completion_scratch.ops.items.len); - try std.testing.expectEqual(@as(usize, 0), fixture.ctx.inhabitation_entered.count()); - try std.testing.expectEqual(@as(usize, 0), fixture.ctx.inhabitation_visiting.count()); failing.fail_index = std.math.maxInt(usize); try std.testing.expect(try Probe.run(&fixture.ctx, inhabited, request, produced)); continue; @@ -69285,43 +69209,33 @@ fn methodOwnerFromType(types: *const Type.Store, ty: Type.TypeId) ?static_dispat }; } -/// Decides whether a graph node is proven uninhabited. A node on the active -/// path is not. +/// Produces the exact Boolean emptiness equations for a graph node. const NodeUninhabitedScan = struct { body: *BodyContext, backing_access: BodyContext.UninhabitedBackingAccess, - /// Active-path assumptions cannot become reusable answers. - cycle_hits: usize = 0, - const Evaluation = AnyAll.Evaluation(NodeId, NodeUninhabitedScan); + const Evaluation = @import("../inhabitation.zig").Evaluation(NodeId, NodeUninhabitedScan); - fn memo(self: *NodeUninhabitedScan) *collections.DenseMapUnmanaged(NodeId, bool) { - return &self.body.node_uninhabited_memo[@backingInt(self.backing_access)]; + /// Graph aliases name one equation and one eventual proof entry. + pub fn canonical(self: *NodeUninhabitedScan, node: NodeId) NodeId { + return self.body.graph.rootNode(node); } pub fn enter(self: *NodeUninhabitedScan, items: Evaluation.Items, node: NodeId) Allocator.Error!Evaluation.Expansion { const body = self.body; const root = body.graph.rootNode(node); - const root_index = @backingInt(root); - if (body.inhabitation_visiting.bit_length <= root_index) { - try body.inhabitation_visiting.resize(body.allocator, root_index + 1, false); - } body.graph.countDiagnostic("inhabitation_node_entries"); - if (body.inhabitation_visiting.isSet(root_index)) { - body.graph.countDiagnostic("inhabitation_cycle_assumptions"); - self.cycle_hits += 1; - return .{ .value = false }; - } - if (self.memo().get(root)) |answer| { + const key: BodyContext.AnalysisKey = .{ .inhabitation = .{ .node = root, .access = self.backing_access } }; + if (try body.analysis_memo.lookup(body, key)) |answer| { body.graph.countDiagnostic("inhabitation_memo_hits"); - return .{ .value = answer }; + return .{ .value = answer != 0 }; } + try body.analysis_memo.read(body, root); body.graph.countDiagnostic("inhabitation_node_expansions"); // An unread leaf is exactly its immutable finished type. This query // does not expand graph content or change the epoch. if (body.graph.leafType(root)) |ty| { const answer = try body.typeIsProvenUninhabitedWith(ty, self.backing_access); - try self.memo().put(body.allocator, root, answer); return .{ .value = answer }; } @@ -69353,6 +69267,9 @@ const NodeUninhabitedScan = struct { // Uninhabited when its extension is and every tag has an // uninhabited payload. .tag_union => |tag_union| blk: { + // A nullary alternative is an unconditional value witness. + // Unlike a cycle cut, it can discard every payload dependency. + for (tag_union.tags) |tag| if (tag.payloads.len == 0) return .{ .value = false }; try items.add(tag_union.ext); for (tag_union.tags) |tag| { try items.group(.any, tag.payloads.len); @@ -69370,22 +69287,16 @@ const NodeUninhabitedScan = struct { .hole, => .{ .value = false }, }; - if (expansion == .group) { - try body.inhabitation_entered.put(body.allocator, root, self.cycle_hits); - body.inhabitation_visiting.set(root_index); - } else { - try self.memo().put(body.allocator, root, expansion.value); - } return expansion; } pub fn exit(self: *NodeUninhabitedScan, node: NodeId, result: ?bool) std.mem.Allocator.Error!void { const body = self.body; const root = body.graph.rootNode(node); - body.inhabitation_visiting.unset(@backingInt(root)); - const hits_before = (body.inhabitation_entered.fetchRemove(root) orelse return).value; - const answer = result orelse return; - if (self.cycle_hits == hits_before) try self.memo().put(body.allocator, root, answer); + const key: BodyContext.AnalysisKey = .{ .inhabitation = .{ .node = root, .access = self.backing_access } }; + if (result) |answer| if (!body.analysis_memo.proofs.facts.contains(key)) { + try body.analysis_memo.entries.append(body.allocator, .{ .key = key, .value = @intFromBool(answer) }); + }; } }; @@ -72412,3 +72323,102 @@ fn writeActiveVariant(comptime U: type, dest: *U, src: U) void { } unreachable; // the inline loop covers every variant of U } + +test "issue 12616 recursive completion and inhabitation survive interleaving and unrelated mutation" { + var allocations = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(allocations.allocator()); + defer fixture.deinit(); + const graph = fixture.graph; + const request_items = try graph.arena().alloc(NodeId, 2); + const produced_items = try graph.arena().alloc(NodeId, 2); + const request = try graph.newNode(.{ .tuple = request_items }); + const produced = try graph.newNode(.{ .tuple = produced_items }); + request_items[0] = try graph.newNode(.{ .list = request }); + produced_items[0] = try graph.newNode(.{ .list = produced }); + const variable = try graph.newNode(.{ .unresolved = InstVariable.placeholder() }); + const scalar = try graph.newNode(.{ .primitive = .i64 }); + request_items[1] = try graph.newNode(.{ .list = variable }); + produced_items[1] = try graph.newNode(.{ .list = scalar }); + const other_a = try graph.newNode(.{ .box = scalar }); + const other_b = try graph.newNode(.{ .box = scalar }); + const live_before = allocations.allocated_bytes - allocations.freed_bytes; + const allocations_before = allocations.allocations; + try std.testing.expectEqual(.mismatch, try fixture.ctx.requestCompletionRelation(request, produced, &fixture.ctx.analysis_memo)); + try std.testing.expectEqual(.unchanged, try fixture.ctx.requestCompletionRelation(other_a, other_b, &fixture.ctx.analysis_memo)); + const initial = fixture.diagnostics; + const bytes = fixture.ctx.analysis_memo.proofs.retainedPayloadBytes(); + const allocated = allocations.allocations; + const total_allocated_bytes = allocations.allocated_bytes; + std.debug.print("12616 monotype: pairs={d} inhabitation_nodes={d} facts={d} payload_bytes={d} query_owned_bytes={d} query_allocations={d}; 512 A/B repetitions allocate and expand nothing\n", .{ initial.completion_pair_expansions, initial.inhabitation_node_expansions, fixture.ctx.analysis_memo.proofs.facts.count(), bytes, allocations.allocated_bytes - allocations.freed_bytes - live_before, allocated - allocations_before }); + for (0..512) |_| { + try std.testing.expect(!try fixture.ctx.resultCompletesRequest(request, produced)); + try std.testing.expect(!try fixture.ctx.resultCompletesRequest(other_a, other_b)); + try std.testing.expect(!try fixture.ctx.nodeIsProvenUninhabited(request)); + } + try std.testing.expectEqual(initial.completion_pair_expansions, fixture.diagnostics.completion_pair_expansions); + try std.testing.expectEqual(initial.inhabitation_node_expansions, fixture.diagnostics.inhabitation_node_expansions); + try std.testing.expectEqual(bytes, fixture.ctx.analysis_memo.proofs.retainedPayloadBytes()); + try std.testing.expectEqual(allocated, allocations.allocations); + try std.testing.expectEqual(total_allocated_bytes, allocations.allocated_bytes); + const unrelated = try graph.newNode(.{ .unresolved = InstVariable.placeholder() }); + try graph.unify(unrelated, try graph.newNode(.{ .primitive = .str })); + try std.testing.expect(!try fixture.ctx.resultCompletesRequest(request, produced)); + try std.testing.expectEqual(initial.completion_pair_expansions, fixture.diagnostics.completion_pair_expansions); + try graph.unify(variable, try graph.newNode(.empty_tag_union)); + try fixture.expectRelation(.completed, request, produced); + const after = fixture.diagnostics.completion_pair_expansions; + try std.testing.expect(!try fixture.ctx.resultCompletesRequest(other_a, other_b)); + try std.testing.expectEqual(after, fixture.diagnostics.completion_pair_expansions); +} + +test "issue 12616 deep completion and emptiness are iterative" { + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(std.testing.allocator); + defer fixture.deinit(); + const graph = fixture.graph; + var request = try graph.newNode(.{ .primitive = .i64 }); + var produced = try graph.newNode(.{ .primitive = .i64 }); + for (0..20_000) |_| { + request = try graph.newNode(.{ .box = request }); + produced = try graph.newNode(.{ .box = produced }); + } + try fixture.expectRelation(.unchanged, request, produced); + try fixture.expectUninhabited(request, .inspectable_only, false); + try std.testing.expectEqual(@as(usize, 0), fixture.ctx.completion_scratch.pairs.items.len); +} + +test "issue 12616 aliased inhabitation children publish one canonical fact" { + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(std.testing.allocator); + defer fixture.deinit(); + const graph = fixture.graph; + const empty = try graph.newNode(.empty_tag_union); + const alias = try graph.newNode(.{ .unresolved = InstVariable.placeholder() }); + try graph.unify(alias, empty); + const tuple = try graph.newNode(.{ .tuple = try graph.arena().dupe(NodeId, &.{ empty, alias }) }); + try std.testing.expect(try fixture.ctx.nodeIsProvenUninhabited(tuple)); + try std.testing.expectEqual(@as(usize, 2), fixture.ctx.analysis_memo.proofs.facts.count()); + try std.testing.expect(try fixture.ctx.nodeIsProvenUninhabited(alias)); +} + +test "issue 12616 completion watches canonical function arguments through old aliases" { + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(std.testing.allocator); + defer fixture.deinit(); + const graph = fixture.graph; + const left = try graph.newNode(.{ .primitive = .i64 }); + const right = try graph.newNode(.{ .primitive = .i64 }); + const left_alias = try graph.newNode(.{ .unresolved = InstVariable.placeholder() }); + const right_alias = try graph.newNode(.{ .unresolved = InstVariable.placeholder() }); + try graph.unify(left_alias, left); + try graph.unify(right_alias, right); + try std.testing.expect(graph.rootNode(left_alias) != left_alias); + try std.testing.expect(graph.rootNode(right_alias) != right_alias); + const request = try graph.newNode(.{ .func = .{ .args = try graph.arena().dupe(NodeId, &.{left_alias}), .ret = left } }); + const produced = try graph.newNode(.{ .func = .{ .args = try graph.arena().dupe(NodeId, &.{right_alias}), .ret = left } }); + try fixture.expectRelation(.mismatch, request, produced); + // Neither old alias is written by this join, but both now denote one class. + try graph.unify(left, right); + try fixture.expectRelation(.unchanged, request, produced); +} diff --git a/src/postcheck/monotype/solve.zig b/src/postcheck/monotype/solve.zig index ea0e801a6b8..c3fa9526e1a 100644 --- a/src/postcheck/monotype/solve.zig +++ b/src/postcheck/monotype/solve.zig @@ -1914,14 +1914,16 @@ pub const GraphDiagnostics = struct { completion_pair_entries: u64 = 0, completion_pair_expansions: u64 = 0, completion_memo_hits: u64 = 0, - completion_cycle_assumptions: u64 = 0, - completion_invalidations: u64 = 0, + completion_repeated_pairs: u64 = 0, inhabitation_queries: u64 = 0, inhabitation_node_entries: u64 = 0, inhabitation_node_expansions: u64 = 0, inhabitation_memo_hits: u64 = 0, - inhabitation_cycle_assumptions: u64 = 0, - inhabitation_invalidations: u64 = 0, + analysis_changed_nodes: u64 = 0, + analysis_invalidation_edges: u64 = 0, + analysis_peak_facts: u64 = 0, + analysis_peak_payload_bytes: u64 = 0, + analysis_invalidated_groups: u64 = 0, /// Node identities retained by argument-class snapshots. argument_class_members_snapshotted: u64 = 0, @@ -2418,6 +2420,8 @@ pub const InstGraph = struct { parents: std.ArrayList(NodeId), field_kinds: std.ArrayList(FieldKindNode), versions: std.ArrayList(u32), + /// Analysis observes content writes and order-sensitive row normalization. + analysis_changes: @import("../proofs.zig").Changes(NodeId) = .{}, /// Intrusive chain of permanent node ids in each live union class. Draft /// request lookup indexes an open function under one permanent interface /// node and probes the current class members, so later unions never stale @@ -2707,6 +2711,7 @@ pub const InstGraph = struct { self.parents.clearRetainingCapacity(); self.field_kinds.clearRetainingCapacity(); self.versions.clearRetainingCapacity(); + self.analysis_changes.clearRetainingCapacity(); self.class_member_next.clearRetainingCapacity(); self.class_member_head.clearRetainingCapacity(); self.class_member_tail.clearRetainingCapacity(); @@ -2878,6 +2883,7 @@ pub const InstGraph = struct { self.class_member_head.deinit(allocator); self.class_member_next.deinit(allocator); self.versions.deinit(allocator); + self.analysis_changes.deinit(allocator); self.parents.deinit(allocator); self.nodes.deinit(allocator); self.field_kinds.deinit(allocator); @@ -3939,10 +3945,12 @@ pub const InstGraph = struct { /// Whether a finished type is proven uninhabited under `policy`. pub fn typeProvenUninhabited(self: *InstGraph, ty: Type.TypeId, policy: UninhabitedPolicy) Allocator.Error!bool { + if (base.dev_diagnostics.isSet(.fresh_type_analysis)) { + for (&self.proven_uninhabited_types) |*memo| memo.clearRetainingCapacity(); + } const answers = &self.proven_uninhabited_types[@backingInt(policy)]; if (answers.get(ty)) |cached| return cached; var scan = TypeUninhabitedScan{ .graph = self, .policy = policy }; - defer scan.visiting.deinit(self.allocator); const result = try TypeUninhabitedScan.Eval.runWith(self.allocator, &self.type_uninhabited_scratch, &scan, ty); try answers.put(self.allocator, ty, result); return result; @@ -4018,7 +4026,7 @@ pub const InstGraph = struct { /// leaf's structure after relations freeze adds nodes that denote parts /// of an already-imported finished type; that relates nothing. /// The per-node columns that grow with `nodes`, one entry per node. - const node_columns = .{ "parents", "versions", "class_member_next", "class_member_head", "class_member_tail", "containment_visit_epochs", "request_source_interfaces", "constructor_evidence_requests", "private_backing_roots", "representation_membership", "class_finished_monos" }; + const node_columns = .{ "parents", "versions", "analysis_changes", "class_member_next", "class_member_head", "class_member_tail", "containment_visit_epochs", "request_source_interfaces", "constructor_evidence_requests", "private_backing_roots", "representation_membership", "class_finished_monos" }; /// Room for one more node in `nodes` and every per-node column. Each /// column's capacity is kept at least `nodes`'s, so a node fitting in @@ -4043,6 +4051,7 @@ pub const InstGraph = struct { self.nodes.appendAssumeCapacity(node_content); self.parents.appendAssumeCapacity(id); self.versions.appendAssumeCapacity(0); + self.analysis_changes.appendAssumeCapacity(); self.class_member_next.appendAssumeCapacity(.none); self.class_member_head.appendAssumeCapacity(id); self.class_member_tail.appendAssumeCapacity(id); @@ -4339,6 +4348,7 @@ pub const InstGraph = struct { self.nodes.items[@backingInt(root)] = expanded; self.markPrivateBacking(expanded); self.versions.items[@backingInt(root)] +%= 1; + self.analysis_changes.mark(root); self.structure_epoch +%= 1; if (self.leaf_alias_origins.fetchRemove(root)) |aliases| { var alias_origins = aliases.value; @@ -4939,6 +4949,11 @@ pub const InstGraph = struct { return node; } + /// Exact changed identities for dependency invalidation; compression is absent. + pub fn analysisChangesSince(self: *const InstGraph, epoch: u64) @import("../proofs.zig").Changes(NodeId).Iterator { + return self.analysis_changes.since(epoch); + } + /// Current root for the node's union-find class. pub fn rootNode(self: *InstGraph, id: NodeId) NodeId { return self.find(id); @@ -6852,6 +6867,8 @@ pub const InstGraph = struct { self.nodes.items[@backingInt(loser)] = .{ .redirect = winner }; self.parents.items[@backingInt(loser)] = winner; self.versions.items[@backingInt(winner)] +%= 1; + self.analysis_changes.mark(winner); + self.analysis_changes.mark(loser); self.structure_epoch +%= 1; self.countDiagnostic("class_unions"); // An active snapshot exists only for a resolved class and reaches @@ -6902,6 +6919,7 @@ pub const InstGraph = struct { self.nodes.items[@backingInt(root)] = new_content; self.markPrivateBacking(new_content); self.versions.items[@backingInt(root)] +%= 1; + self.analysis_changes.mark(root); self.structure_epoch +%= 1; return true; } @@ -7847,8 +7865,9 @@ pub const InstGraph = struct { std.mem.sortUnstable(InstTag, tags, self.name_store, instTagLessThan); // Completion compares row entries in their stored order. Its // answers must not survive a reordering, although type meaning, - // containment versions, and resolvedness are unchanged. + // solver versions and resolvedness are unchanged. self.structure_epoch +%= 1; + self.analysis_changes.mark(root); } for (tags, 0..) |tag, index| { if (index != 0) std.debug.assert(tags[index - 1].name != tag.name); @@ -8938,25 +8957,18 @@ pub const UninhabitedPolicy = enum(u2) { const TypeUninhabitedScan = struct { graph: *InstGraph, policy: UninhabitedPolicy, - /// Each type being expanded, with the number of cycle hits seen before - /// it was entered. - visiting: collections.DenseMapUnmanaged(Type.TypeId, usize) = .empty, - /// Re-entries of a type still being expanded. A type whose expansion saw - /// none answers independently of the types enclosing it, so its answer - /// is cached for every later scan. - cycle_hits: usize = 0, - - const Eval = AnyAll.Evaluation(Type.TypeId, TypeUninhabitedScan); + const Eval = @import("../inhabitation.zig").Evaluation(Type.TypeId, TypeUninhabitedScan); fn answers(self: *TypeUninhabitedScan) *collections.DenseMapUnmanaged(Type.TypeId, bool) { return &self.graph.proven_uninhabited_types[@backingInt(self.policy)]; } + /// Immutable finished types already carry their canonical identity. + pub fn canonical(_: *TypeUninhabitedScan, ty: Type.TypeId) Type.TypeId { + return ty; + } + pub fn enter(self: *TypeUninhabitedScan, items: Eval.Items, ty: Type.TypeId) Allocator.Error!Eval.Expansion { - if (self.visiting.contains(ty)) { - self.cycle_hits += 1; - return .{ .value = false }; - } if (self.answers().get(ty)) |cached| return .{ .value = cached }; const types = self.graph.types; const expansion: Eval.Expansion = switch (types.get(ty)) { @@ -8983,6 +8995,9 @@ const TypeUninhabitedScan = struct { // Uninhabited when every tag has an uninhabited payload. .tag_union => |tags| blk: { const tag_span = types.tagSpan(tags); + for (0..GuardedList.borrowLen(tag_span)) |tag_index| { + if (GuardedList.borrowLen(types.span(GuardedList.at(tag_span, tag_index).payloads)) == 0) return .{ .value = false }; + } for (0..GuardedList.borrowLen(tag_span)) |tag_index| { const payloads = types.span(GuardedList.at(tag_span, tag_index).payloads); const payload_count = GuardedList.borrowLen(payloads); @@ -8994,14 +9009,11 @@ const TypeUninhabitedScan = struct { // A hole stands for every type, so it is not provably uninhabited. .primitive, .list, .func, .erased, .zst, .hole => .{ .value = false }, }; - if (expansion == .group) try self.visiting.put(self.graph.allocator, ty, self.cycle_hits); return expansion; } pub fn exit(self: *TypeUninhabitedScan, ty: Type.TypeId, result: ?bool) Allocator.Error!void { - const hits_before = (self.visiting.fetchRemove(ty) orelse return).value; - const decided = result orelse return; - if (self.cycle_hits == hits_before) try self.answers().put(self.graph.allocator, ty, decided); + if (result) |decided| try self.answers().put(self.graph.allocator, ty, decided); } }; diff --git a/src/postcheck/proofs.zig b/src/postcheck/proofs.zig new file mode 100644 index 00000000000..82ceab51d7f --- /dev/null +++ b/src/postcheck/proofs.zig @@ -0,0 +1,292 @@ +//! Graph-owned proof certificates and exact, allocation-free mutation recording. +//! Reverse dependencies retire only proofs reached from changed producer nodes. +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// A bounded mutation index for a graph with dense u32-backed node identities. +/// Each node occurs once in a list ordered by its most recent mutation. Moving +/// it to the front records another write without accumulating mutation history. +pub fn Changes(comptime Node: type) type { + return struct { + const Self = @This(); + const none = std.math.maxInt(u32); + const Record = struct { previous: u32 = none, next: u32 = none, version: u64 = 0 }; + records: std.ArrayList(Record) = .empty, + head: u32 = none, + epoch: u64 = 0, + + /// Reserve node slots before the producer publishes their identities. + pub fn ensureTotalCapacityPrecise(self: *Self, gpa: Allocator, count: usize) Allocator.Error!void { + try self.records.ensureTotalCapacityPrecise(gpa, count); + } + /// Reserve additional node slots before a potentially failing write. + pub fn ensureUnusedCapacity(self: *Self, gpa: Allocator, count: usize) Allocator.Error!void { + try self.records.ensureUnusedCapacity(gpa, count); + } + /// A new disconnected node changes no existing proof. + pub fn appendAssumeCapacity(self: *Self) void { + self.records.appendAssumeCapacity(.{}); + } + /// Begin an unrelated graph; all old identities and observers are gone. + pub fn clearRetainingCapacity(self: *Self) void { + self.records.clearRetainingCapacity(); + self.head = none; + self.epoch = 0; + } + /// Release the per-node index. + pub fn deinit(self: *Self, gpa: Allocator) void { + self.records.deinit(gpa); + } + /// Record a producer-authored change. This cannot allocate or fail. + pub fn mark(self: *Self, node: Node) void { + const index = @backingInt(node); + const record = &self.records.items[index]; + if (record.version != 0) { + if (record.previous == none) self.head = record.next else self.records.items[record.previous].next = record.next; + if (record.next != none) self.records.items[record.next].previous = record.previous; + } + self.epoch += 1; + record.* = .{ .next = self.head, .version = self.epoch }; + if (self.head != none) self.records.items[self.head].previous = index; + self.head = index; + } + /// Visits each node changed since an observer's epoch at most once. + pub const Iterator = struct { + records: []const Record, + cursor: u32, + epoch: u64, + /// The next changed identity; iteration stops before older nodes. + pub fn next(self: *Iterator) ?Node { + if (self.cursor == none) return null; + const index = self.cursor; + const record = self.records[index]; + if (record.version <= self.epoch) return null; + self.cursor = record.next; + return @fromBackingInt(index); + } + }; + /// Read the exact mutation suffix without changing another observer. + pub fn since(self: *const Self, epoch: u64) Iterator { + return .{ .records = self.records.items, .cursor = self.head, .epoch = epoch }; + } + }; +} + +/// Own completed certificates for one graph and one producer scope. Every +/// dependency has a reverse link, removed in O(1) when its proof is retired. +pub fn Cache(comptime Key: type, comptime Node: type) type { + return struct { + const Self = @This(); + /// A directly read canonical node or an earlier completed certificate. + pub const Dependency = union(enum) { node: Node, proof: *Proof }; + const Link = struct { dependency: Dependency, owner: *Proof, slot: usize }; + /// A group settled together, with its facts and reverse dependencies. + pub const Proof = struct { + valid: bool = true, + slot: usize, + dependencies: []Link, + keys: []Key, + dependents: std.ArrayList(*Link) = .empty, + }; + /// One settled outcome and its justification. + pub const Fact = struct { value: u2, proof: *Proof }; + /// An outcome prepared for atomic publication. + pub const Entry = struct { key: Key, value: u2 }; + facts: std.AutoHashMapUnmanaged(Key, Fact) = .empty, + watchers: std.AutoHashMapUnmanaged(Node, std.ArrayList(*Link)) = .empty, + groups: std.ArrayList(*Proof) = .empty, + pending: std.ArrayList(*Proof) = .empty, + epoch: u64 = 0, + invalidations: usize = 0, + changed_nodes: usize = 0, + invalidation_edges: usize = 0, + dependency_bytes: usize = 0, + dependent_capacity_bytes: usize = 0, + watcher_capacity_bytes: usize = 0, + + /// Release certificates, tables and retained capacity. + pub fn deinit(self: *Self, gpa: Allocator) void { + self.clear(gpa); + self.facts.deinit(gpa); + self.watchers.deinit(gpa); + self.groups.deinit(gpa); + self.pending.deinit(gpa); + } + /// End the producer scope and release every certificate and reverse list. + pub fn clear(self: *Self, gpa: Allocator) void { + for (self.groups.items) |proof| self.freeProof(gpa, proof); + var lists = self.watchers.valueIterator(); + while (lists.next()) |list| list.deinit(gpa); + self.groups.clearRetainingCapacity(); + self.pending.clearRetainingCapacity(); + self.watchers.clearRetainingCapacity(); + self.facts.clearRetainingCapacity(); + self.dependency_bytes = 0; + self.dependent_capacity_bytes = 0; + self.watcher_capacity_bytes = 0; + } + fn freeProof(_: *Self, gpa: Allocator, proof: *Proof) void { + proof.dependents.deinit(gpa); + gpa.free(proof.dependencies); + gpa.free(proof.keys); + gpa.destroy(proof); + } + fn invalidate(self: *Self, proof: *Proof) void { + if (!proof.valid) return; + proof.valid = false; + self.pending.appendAssumeCapacity(proof); + self.invalidations += 1; + } + /// Call only outside a query: a running recorder may borrow proofs. + /// Work is proportional to changed nodes and affected proof edges. + pub fn refresh(self: *Self, gpa: Allocator, epoch: u64, graph: anytype) void { + if (self.epoch == epoch) return; + if (self.groups.items.len == 0) { + self.epoch = epoch; + return; + } + var changes = graph.analysisChangesSince(self.epoch); + self.epoch = epoch; + while (changes.next()) |node| { + self.changed_nodes += 1; + if (self.watchers.get(node)) |links| for (links.items) |link| { + self.invalidation_edges += 1; + self.invalidate(link.owner); + }; + } + var cursor: usize = 0; + while (cursor < self.pending.items.len) : (cursor += 1) { + for (self.pending.items[cursor].dependents.items) |link| { + self.invalidation_edges += 1; + self.invalidate(link.owner); + } + } + // Unlink everything before freeing any referenced proof. + for (self.pending.items) |proof| for (proof.dependencies) |*link| self.unlink(gpa, link); + for (self.pending.items) |proof| { + for (proof.keys) |key| { + const removed = self.facts.remove(key); + std.debug.assert(removed); + } + _ = self.groups.swapRemove(proof.slot); + if (proof.slot < self.groups.items.len) self.groups.items[proof.slot].slot = proof.slot; + self.dependency_bytes -= proof.dependencies.len * @sizeOf(Link) + proof.keys.len * @sizeOf(Key); + self.dependent_capacity_bytes -= proof.dependents.capacity * @sizeOf(*Link); + self.freeProof(gpa, proof); + } + self.pending.clearRetainingCapacity(); + } + fn unlink(self: *Self, gpa: Allocator, link: *Link) void { + const list = switch (link.dependency) { + .node => |node| self.watchers.getPtr(node).?, + .proof => |proof| &proof.dependents, + }; + _ = list.swapRemove(link.slot); + if (link.slot < list.items.len) list.items[link.slot].slot = link.slot; + if (link.dependency == .node and list.items.len == 0) { + self.watcher_capacity_bytes -= list.capacity * @sizeOf(*Link); + list.deinit(gpa); + const removed = self.watchers.remove(link.dependency.node); + std.debug.assert(removed); + } + } + /// Find a fact after the caller has refreshed dependencies. + pub fn get(self: *Self, key: Key) ?Fact { + return self.facts.get(key); + } + /// Publish only after convergence. Allocation failure rolls back every + /// reverse link and exposes none of the new facts. + pub fn publish(self: *Self, gpa: Allocator, entries: []const Entry, dependencies: []const Dependency) Allocator.Error!void { + if (entries.len == 0) return; + try self.facts.ensureUnusedCapacity(gpa, @intCast(entries.len)); + try self.groups.ensureUnusedCapacity(gpa, 1); + try self.pending.ensureTotalCapacity(gpa, self.groups.items.len + 1); + const proof = try gpa.create(Proof); + errdefer gpa.destroy(proof); + const links = try gpa.alloc(Link, dependencies.len); + errdefer gpa.free(links); + const keys = try gpa.alloc(Key, entries.len); + errdefer gpa.free(keys); + proof.* = .{ .slot = self.groups.items.len, .dependencies = links, .keys = keys }; + var linked: usize = 0; + errdefer for (links[0..linked]) |*link| self.unlink(gpa, link); + for (dependencies, links) |dependency, *link| { + const list = switch (dependency) { + .node => |node| blk: { + const entry = try self.watchers.getOrPut(gpa, node); + if (!entry.found_existing) entry.value_ptr.* = .empty; + break :blk entry.value_ptr; + }, + .proof => |earlier| &earlier.dependents, + }; + const capacity = list.capacity; + link.* = .{ .dependency = dependency, .owner = proof, .slot = list.items.len }; + list.append(gpa, link) catch |err| { + if (dependency == .node and list.items.len == 0) { + list.deinit(gpa); + _ = self.watchers.remove(dependency.node); + } + return err; + }; + const growth = (list.capacity - capacity) * @sizeOf(*Link); + switch (dependency) { + .node => self.watcher_capacity_bytes += growth, + .proof => self.dependent_capacity_bytes += growth, + } + linked += 1; + } + for (entries, keys) |entry, *key| { + std.debug.assert(!self.facts.contains(entry.key)); + key.* = entry.key; + self.facts.putAssumeCapacity(entry.key, .{ .value = entry.value, .proof = proof }); + } + self.groups.appendAssumeCapacity(proof); + self.dependency_bytes += links.len * @sizeOf(Link) + keys.len * @sizeOf(Key); + } + /// Table payload and certificate bytes, excluding allocator metadata, + /// alignment padding and hash-table control bytes. + pub fn retainedPayloadBytes(self: *const Self) usize { + return self.facts.capacity() * (@sizeOf(Key) + @sizeOf(Fact)) + + self.watchers.capacity() * (@sizeOf(Node) + @sizeOf(std.ArrayList(*Link))) + + (self.groups.capacity + self.pending.capacity) * @sizeOf(*Proof) + + self.groups.items.len * @sizeOf(Proof) + self.dependency_bytes + + self.dependent_capacity_bytes + self.watcher_capacity_bytes; + } + }; +} + +test "issue 12616 proof dependencies invalidate transitively without dropping disjoint facts" { + const Node = enum(u32) { a, b, c }; + const Store = struct { + changes: Changes(Node) = .{}, + /// Read exact test mutation events, independent of proof state. + pub fn analysisChangesSince(self: *const @This(), epoch: u64) Changes(Node).Iterator { + return self.changes.since(epoch); + } + }; + const gpa = std.testing.allocator; + const Proofs = Cache(u32, Node); + var graph: Store = .{}; + defer graph.changes.deinit(gpa); + try graph.changes.ensureUnusedCapacity(gpa, 3); + for (0..3) |_| graph.changes.appendAssumeCapacity(); + var proofs: Proofs = .{}; + defer proofs.deinit(gpa); + try proofs.publish(gpa, &.{.{ .key = 0, .value = 1 }}, &.{.{ .node = .a }}); + try proofs.publish(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .proof = proofs.get(0).?.proof }}); + try proofs.publish(gpa, &.{.{ .key = 2, .value = 0 }}, &.{.{ .node = .c }}); + for (0..100) |_| graph.changes.mark(.b); + proofs.refresh(gpa, graph.changes.epoch, &graph); + try std.testing.expectEqual(@as(usize, 3), proofs.facts.count()); + try std.testing.expectEqual(@as(usize, 1), proofs.changed_nodes); + try std.testing.expectEqual(@as(usize, 0), proofs.invalidation_edges); + graph.changes.mark(.a); + proofs.refresh(gpa, graph.changes.epoch, &graph); + try std.testing.expect(proofs.get(0) == null and proofs.get(1) == null); + try std.testing.expectEqual(@as(u2, 0), proofs.get(2).?.value); + try std.testing.expectEqual(@as(usize, 1), proofs.groups.items.len); + try std.testing.expectEqual(@as(usize, 2), proofs.changed_nodes); + proofs.clear(gpa); + try std.testing.expectEqual(@as(usize, 0), proofs.facts.count()); + try std.testing.expectEqual(@as(usize, 0), proofs.groups.items.len); +} From cab189e6c8dd983449401bbcd1d1c0f29b3c1445 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 16:44:30 -0400 Subject: [PATCH 31/97] Identify the target of proof-storage measurements --- design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/design.md b/design.md index 558a32fd394..75096d17516 100644 --- a/design.md +++ b/design.md @@ -17642,7 +17642,7 @@ reuse hits, invalidations, lazy expansion and unification. Peak fact counts and payload bytes account for certificate arrays and table payload capacity (allocator metadata and table control bytes are excluded); allocator tests separately assert that warmed repetition performs no allocation. -The issue 12616 interleaving fixtures settle five completion pairs and six +On x86_64, the issue 12616 interleaving fixtures settle five completion pairs and six inhabitation nodes into 12 facts (4,176 accounted proof bytes; 6,600 total query-owned bytes including scratch, across 49 initial allocations), and three compatibility pairs into three facts (1,928 accounted proof bytes; 2,500 total From a63dd9391e6af961a00cc4415a06f46aad3ad255 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 16:46:30 -0400 Subject: [PATCH 32/97] Persist composite Monotype interface results and defaults --- design.md | 55 +- src/cli/main.zig | 22 +- src/cli/pack_store.zig | 16 +- src/cli/progress.zig | 14 +- src/cli/test/parallel_cli_runner.zig | 97 +- src/lir/checked_pipeline.zig | 1 + src/postcheck/monotype/interface_summary.zig | 913 ++++++++++++++++-- src/postcheck/monotype/lower.zig | 66 +- src/postcheck/monotype/specialize.zig | 8 + src/postcheck/monotype/type.zig | 5 + test/cli/interface_cache_bench.py | 2 +- .../persistent_interface_cache/CacheTests.roc | 13 + .../persistent_interface_cache/Defaults.roc | 18 + .../persistent_interface_cache/Generic.roc | 9 +- test/cli/persistent_interface_cache/main.roc | 4 +- 15 files changed, 1102 insertions(+), 141 deletions(-) create mode 100644 test/cli/persistent_interface_cache/CacheTests.roc create mode 100644 test/cli/persistent_interface_cache/Defaults.roc diff --git a/design.md b/design.md index ad4b66bee42..17a283b01f0 100644 --- a/design.md +++ b/design.md @@ -693,15 +693,52 @@ interface replay. A multi-request recursive component with parametric holes has no independently reusable summary. An interface summary is either an explicit unchanged result or immutable -constraints over locally numbered nodes and field-kind cells. Persistent -constraints refer to settled types through the current request's leaf positions -or explicit primitive types; they do not serialize solver stores or retain -producer-owned type ids. A producer whose result requires another settled type -publishes no persistent summary; checked default-expression addresses likewise -remain unrepresented. Named checked provenance in open constraints -must still name a currently present exact checked module. Consumers validate -all indices, tags, lengths and provenance before instantiating fresh nodes. -Missing, incompatible, corrupt or unrepresentable optional records are misses. +constraints over locally numbered nodes and field-kind cells. A settled relation +may still contain checked variables, row extensions, recursive links, field-kind +cells, and provisional field views: these are relation data, not completed runtime +types. Compiler placeholders, redirects and lazy solver leaves are not publishable. +The capture producer supplies all open-node witnesses, related groups, generated +iterator provenance, and field-presence evidence explicitly. + +Build and test runtime lowering share the checked session's interface provider +with compile-time evaluation. Test roots retain source-body code provision; +interface replay does not grant machine-code splice capability. Successful +compile-time publication and test lowering flush the provider's batched records. + +Payload version 2 addresses immutable types through captured request leaves or a +summary-owned node table. The table covers primitives, lists, boxes, tuples, +records, tag unions, functions, named nominal/alias/opaque types, erased digests, +zero-sized types and demand holes. Spans become counted sequences; names become +content. Named nodes retain declaration and occurrence owners, checked occurrence, +arguments, generated identity, iterator topology/tier, backing visibility and +authority, and declared field order. Fields retain slot and value types, provisional +state and default identity; tags retain both semantic and checked labels. Table +indices are assigned in request traversal order, with a work queue and memoized +references preserving sharing and recursion without recursive graph expansion. +Leaf lookup uses representation digest buckets and exact equality; semantic +equality alone never erases an alias or generated backing. + +A field default's durable address is its declaring module content identity, exact +checked artifact key, and producer-authored CIR expression ordinal. The producer +resolves this address through the checked body's archived default-expression map. +The consumer requires that same artifact and mapping; it neither evaluates the +default nor searches by spelling, source text or shape. Changing or removing that +dependency invalidates the summary. These addresses apply equally to immutable +record fields and open field-kind constraints. + +Decoding owns temporary storage until every length, tag, graph reference, row and +checked provenance has been validated. Only then does it reserve a private type +transaction, fill all nodes, intern the complete group, and relocate the returned +constraints. Allocation failure aborts the transaction; no request relation sees +partial types. Each replay instantiates fresh mutable solver nodes. Names are +monotonically interned in the receiving store, and no producer arena is borrowed. +Replayed entries retain their publication origin across worker relocation and are +not re-encoded for publication. Only counts exceeding the format's u32 address +capacity remain unrepresentable; no type shape is excluded. +Payload and family envelope versions change together; old, missing, incompatible +or corrupt optional records are misses. Encoding and publication retain the +existing batched, atomic family store. Counters distinguish misses, serialization +failures, records and bytes, replay, invalid records and actual relation expansion. The provider key includes the exact checked callee code-generation key, the method scope's checked value context and ordered diff --git a/src/cli/main.zig b/src/cli/main.zig index 8caee2f07a1..5356200206f 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -9217,6 +9217,10 @@ fn publishDiscoverySummaries(build_env: *BuildEnv, root_artifact: *const check.C fn publishCompileTimeResults(ctx: *CliCtx, build_env: *BuildEnv, root_artifact: *const check.CheckedArtifact.CheckedModuleArtifact) Allocator.Error!void { try publishCompileTimePack(ctx, build_env); try publishDiscoverySummaries(build_env, root_artifact); + if (build_env.compile_time_object_cache_owner) |owner| { + const cache: *CompileTimeObjectCache = @ptrCast(@alignCast(owner.context)); + try cache.packs.publishInterfaces(); + } } /// Whether `root`'s code reaches a procedure a linked object-cache object @@ -16144,6 +16148,13 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr false, ); runtime_config.explicit_roots = runtime_roots; + // Test roots still compile their own code, but share the checked session's + // settled interface provider with compile-time evaluation. + const interface_packs: ?*pack_store.LoadedPacks = if (build_env.compile_time_object_cache_owner) |owner| blk: { + const cache: *CompileTimeObjectCache = @ptrCast(@alignCast(owner.context)); + break :blk &cache.packs; + } else null; + if (interface_packs) |packs| runtime_config.target.spec_cache = packs.specCacheLookup(); if (runtime_requests.items.len != 0) { runtime_config.root_module = test_plan.modules[0].artifact; build_env.setRuntimeLowering(runtime_config); @@ -16207,6 +16218,8 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr shared_test_program.?.frozen_static_data = .{ .allocator = ctx.gpa, .exports = test_static_data }; } + if (interface_packs) |packs| try packs.publishInterfaces(); + const test_mode = cliTestExecutionMode(args.opt); const use_live_optimized_output = runtime_requests.items.len != 0 and switch (test_mode) { .llvm_size, .llvm_speed => true, @@ -17910,13 +17923,20 @@ fn monotypeGraphCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [28] }; } -fn monotypeBodyCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [31]progress.Counter { +fn monotypeBodyCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [38]progress.Counter { const body = diagnostics.body; return .{ .{ .name = "Interface summary hits", .count = diagnostics.specialization.interface_summary_hits }, .{ .name = "Persistent interface hits", .count = diagnostics.specialization.interface_persistent_hits }, .{ .name = "Persistent interface misses", .count = diagnostics.specialization.interface_persistent_misses }, .{ .name = "Persistent interface invalid", .count = diagnostics.specialization.interface_persistent_invalid }, + .{ .name = "Persistent interface types", .count = diagnostics.specialization.interface_persistent_types }, + .{ .name = "Persistent interface references", .count = diagnostics.specialization.interface_persistent_references }, + .{ .name = "Persistent interface composite records", .count = diagnostics.specialization.interface_persistent_composite_records }, + .{ .name = "Persistent interface default records", .count = diagnostics.specialization.interface_persistent_default_records }, + .{ .name = "Persistent interface composite hits", .count = diagnostics.specialization.interface_persistent_composite_hits }, + .{ .name = "Persistent interface default hits", .count = diagnostics.specialization.interface_persistent_default_hits }, + .{ .name = "Persistent interface bytes", .count = diagnostics.specialization.interface_persistent_bytes }, .{ .name = "Persistent interface records", .count = diagnostics.specialization.interface_persistent_records }, .{ .name = "Persistent interface unrepresentable", .count = diagnostics.specialization.interface_persistent_unrepresentable }, .{ .name = "Interface summary expansions", .count = diagnostics.specialization.interface_summary_expansions }, diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index e1d466548d5..a78460be828 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -1029,6 +1029,7 @@ pub const LoadedPacks = struct { interface_mutex: std.Io.Mutex = .init, /// Worker reads update this under interface_mutex; reported after joining. interface_bytes_loaded: u64 = 0, + interface_invalid_families: u64 = 0, interface_families: std.AutoHashMapUnmanaged([32]u8, *InterfaceFamily) = .empty, interface_store: ?*const Store = null, allocator: Allocator, @@ -1770,6 +1771,7 @@ pub const LoadedPacks = struct { const stats = self.index_stats; std.debug.print("pack index: index_bytes={d} bucket_opens={d} bucket_absent={d} negative_hits={d} metadata_opens={d} metadata_bytes={d} candidates={d} duplicates={d} payload_opens={d} payload_files={d} payload_bytes={d} decoded_artifacts={d} requested_artifacts={d} served_artifacts={d} allocation_failures={d} requested={d} served={d} misses={d} source_ineligible={d} linked_ineligible={d} corrupt={d} missing={d}\n", .{ stats.index_bytes, stats.bucket_opens, stats.bucket_absent, stats.negative_hits, stats.metadata_opens, stats.metadata_bytes, stats.candidates, stats.duplicates, stats.payload_opens, stats.payload_files, stats.payload_bytes, stats.decoded_artifacts, stats.requested_artifacts, stats.served_artifacts, stats.allocation_failures, stats.requested, stats.served, stats.misses, stats.source_ineligible, stats.linked_ineligible, stats.corrupt, stats.missing }); } + if (base.dev_diagnostics.isSet(.pack_stats)) std.debug.print("pack interfaces: loaded_bytes={d} invalid_families={d}\n", .{ self.interface_bytes_loaded, self.interface_invalid_families }); var interface_families = self.interface_families.valueIterator(); while (interface_families.next()) |family| { for (family.*.records.values()) |bytes| self.allocator.free(bytes); @@ -1858,7 +1860,11 @@ pub const LoadedPacks = struct { self.interface_bytes_loaded +|= bytes.len; self.readInterfaceFamily(family, key, bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, - error.InvalidSummary => return family, + error.InvalidSummary => { + self.interface_invalid_families += 1; + family.loaded = true; + return family; + }, }; family.loaded = true; return family; @@ -1867,7 +1873,7 @@ pub const LoadedPacks = struct { const InterfaceDecodeError = Allocator.Error || error{InvalidSummary}; fn readInterfaceFamily(self: *LoadedPacks, family: *InterfaceFamily, key: [32]u8, bytes: []const u8) InterfaceDecodeError!void { - if (bytes.len < 76 or !std.mem.eql(u8, bytes[0..8], "RIFACE01") or !std.mem.eql(u8, bytes[8..40], &key)) return error.InvalidSummary; + if (bytes.len < 76 or !std.mem.eql(u8, bytes[0..8], "RIFACE02") or !std.mem.eql(u8, bytes[8..40], &key)) return error.InvalidSummary; if (!std.mem.eql(u8, &Index.digest(bytes[72..]), bytes[40..72])) return error.InvalidSummary; const count = std.mem.readInt(u32, bytes[72..76], .little); var offset: usize = 76; @@ -1934,7 +1940,7 @@ pub const LoadedPacks = struct { if (!family.dirty) continue; var bytes = std.ArrayList(u8).empty; defer bytes.deinit(self.allocator); - try bytes.appendSlice(self.allocator, "RIFACE01"); + try bytes.appendSlice(self.allocator, "RIFACE02"); try bytes.appendSlice(self.allocator, entry.key_ptr); try bytes.appendNTimes(self.allocator, 0, 32); var number: [4]u8 = undefined; @@ -2762,11 +2768,13 @@ test "persistent interface families invalidate misses, share records, and reject try std.testing.expectEqualStrings("three", three); const path = try LoadedPacks.interfacePath(&fixture.store, allocator, family); defer allocator.free(path); - try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = path, .data = "RIFACE01truncated" }); + try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = path, .data = "RIFACE02truncated" }); var corrupt = LoadedPacks.init(allocator); defer corrupt.deinit(); corrupt.interface_store = &fixture.store; try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&corrupt, allocator, family, first)); + try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&corrupt, allocator, family, second)); + try std.testing.expectEqual(@as(u64, 1), corrupt.interface_invalid_families); try LoadedPacks.recordInterface(&corrupt, family, first, "repaired"); try corrupt.publishInterfaces(); var repaired = LoadedPacks.init(allocator); diff --git a/src/cli/progress.zig b/src/cli/progress.zig index 8ea4d441e4f..3a3a5503923 100644 --- a/src/cli/progress.zig +++ b/src/cli/progress.zig @@ -54,7 +54,7 @@ const max_subphases: usize = 25; // Shared lowering and a runtime continuation each report eight lowering groups // and native emission; tests also report result-cache counts. const max_counter_groups: usize = 2 * 9 + 1; -const max_counters_per_group: usize = 32; +const max_counters_per_group: usize = 38; /// Rows in the ranked timing list printed after the phases. const max_ranked_timings: usize = 10; /// Bytes reserved for copies of the ranked rows' names, which the caller's @@ -924,7 +924,7 @@ test "ranked timings are omitted without --timings" { try testing.expect(std.mem.find(u8, buf.written(), "pdf.Slow") == null); } -test "timing counter groups retain every Monotype graph diagnostic" { +test "timing counter groups retain every Monotype body diagnostic" { var buf: std.Io.Writer.Allocating = .init(testing.allocator); defer buf.deinit(); var reporter = Reporter.init(.{ @@ -938,14 +938,14 @@ test "timing counter groups retain every Monotype graph diagnostic" { reporter.start(); var counters = repeated: { const pattern = [_]Counter{.{ .name = "Graph diagnostic", .count = 0 }}; - var result: [pattern.len * (27)]@TypeOf(pattern[0]) = undefined; - for (0..(27)) |i| @memcpy(result[i * pattern.len ..][0..pattern.len], &pattern); + var result: [pattern.len * (38)]@TypeOf(pattern[0]) = undefined; + for (0..(38)) |i| @memcpy(result[i * pattern.len ..][0..pattern.len], &pattern); break :repeated result; }; - counters[26] = .{ .name = "Generated-private guard returns", .count = 12345 }; - reporter.recordCounters("Monotype type graph", &counters); + counters[37] = .{ .name = "Direct call request reuses", .count = 12345 }; + reporter.recordCounters("Monotype body + dispatch", &counters); reporter.finish(); - try testing.expect(std.mem.find(u8, buf.written(), "Generated-private guard returns") != null); + try testing.expect(std.mem.find(u8, buf.written(), "Direct call request reuses") != null); try testing.expect(std.mem.find(u8, buf.written(), "12345") != null); } diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index e0cda3a5f14..ac5742b49c2 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -9485,7 +9485,7 @@ fn customPersistentInterfaceCache(io: std.Io, allocator: Allocator, env: *const .dir_name = "persistent_interface_cache", .platform = "test/fx-open/platform/main.roc", .platform_spelling = "../../fx-open/platform/main.roc", - .sources = &.{ "test/cli/persistent_interface_cache/Generic.roc", "test/cli/persistent_interface_cache/main.roc" }, + .sources = &.{ "test/cli/persistent_interface_cache/Generic.roc", "test/cli/persistent_interface_cache/Defaults.roc", "test/cli/persistent_interface_cache/CacheTests.roc", "test/cli/persistent_interface_cache/main.roc" }, .app_name = "main.roc", }, &app)) |failure| return failure; if (storeBuildsBehaveIdentically(io, allocator, env, timer, timeout_ms, app, env.dirs.work_dir, "interfaces", .{ @@ -9498,13 +9498,88 @@ fn customPersistentInterfaceCache(io: std.Io, allocator: Allocator, env: *const })) |failure| return failure; // Now force fresh compile-time evaluation and normalization too. The // unchanged callees must reuse their settled interfaces in both paths. - return storeBuildsBehaveIdentically(io, allocator, env, timer, timeout_ms, app, env.dirs.work_dir, "runtime_interfaces", .{ + if (storeBuildsBehaveIdentically(io, allocator, env, timer, timeout_ms, app, env.dirs.work_dir, "runtime_interfaces", .{ .edit_between_builds = true, .source_edit = .{ .from = "Generic.f7(2)", .to = "Generic.f7(1) + 1" }, .persistent_interface_reuse = true, + .uncached_baseline = true, .compile_debug = "[dbg] \"persistent interface observation\"", .stdout = "29\n", - }); + })) |failure| return failure; + if (persistentInterfaceTests(io, allocator, env, timer, timeout_ms, app)) |failure| return failure; + // The durable expression address belongs to this exact checked artifact. + // Changing its body must change both the compile-time and runtime result. + const defaults_path = std.fs.path.join(allocator, &.{ std.fs.path.dirname(app).?, "Defaults.roc" }) catch |err| + return customInfraFailure(allocator, timer, "default dependency path: {}", .{err}); + const original = std.Io.Dir.cwd().readFileAlloc(io, defaults_path, allocator, .limited(1024 * 1024)) catch |err| + return customInfraFailure(allocator, timer, "default dependency read: {}", .{err}); + const changed = std.mem.replaceOwned(u8, allocator, original, "?? 0", "?? 3") catch |err| + return customInfraFailure(allocator, timer, "default dependency edit: {}", .{err}); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = defaults_path, .data = changed }) catch |err| + return customInfraFailure(allocator, timer, "default dependency write: {}", .{err}); + if (storeBuildsBehaveIdentically(io, allocator, env, timer, timeout_ms, app, env.dirs.work_dir, "changed_default", .{ + .uncached_baseline = true, + .compile_debug = "[dbg] \"persistent interface observation\"", + .stdout = "35\n", + })) |failure| return failure; + const failing = std.mem.replaceOwned(u8, allocator, original, "?? 0", "?? { crash \"changed interface default\" }") catch |err| + return customInfraFailure(allocator, timer, "failing default dependency edit: {}", .{err}); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = defaults_path, .data = failing }) catch |err| + return customInfraFailure(allocator, timer, "failing default dependency write: {}", .{err}); + const remaining = childCommandTimeoutMs(timer, timeout_ms) orelse return timeoutFailure(allocator, timer, .run, "case timeout exhausted before default rejection"); + const rejected = runRocInEnv(io, allocator, env, &.{ "build", "--opt=dev" }, app, .relative, &.{}, null, remaining) catch |err| + return customInfraFailure(allocator, timer, "failing default build: {}", .{err}); + if (processSucceeded(rejected.term) or std.mem.find(u8, rejected.stderr, "changed interface default") == null) + return failureFromRun(allocator, timer, rejected, "persistent interface reuse hid a changed default's failure"); + return null; +} + +/// Test roots also need the intermediate relation when their caller changes. +fn persistentInterfaceTests(io: std.Io, allocator: Allocator, env: *const CaseEnv, timer: *harness.Timer, timeout_ms: u64, app: []const u8) ?TestResult { + const path = std.fs.path.join(allocator, &.{ std.fs.path.dirname(app).?, "CacheTests.roc" }) catch |err| + return customInfraFailure(allocator, timer, "interface test path: {}", .{err}); + var isolated = CaseEnv{ + .dirs = env.dirs, + .env_map = env.env_map.clone(allocator) catch |err| + return customInfraFailure(allocator, timer, "interface test environment: {}", .{err}), + }; + defer isolated.env_map.deinit(); + const cache = std.fs.path.join(allocator, &.{ env.dirs.work_dir, "interface-test-cache" }) catch |err| + return customInfraFailure(allocator, timer, "interface test cache path: {}", .{err}); + isolated.env_map.put("ROC_CACHE_DIR", cache) catch |err| + return customInfraFailure(allocator, timer, "interface test cache environment: {}", .{err}); + var cold_expansions: u64 = 0; + for (0..3) |iteration| { + if (iteration == 2) { + const source = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| + return customInfraFailure(allocator, timer, "interface test read: {}", .{err}); + const changed = std.mem.replaceOwned(u8, allocator, source, "Generic.f7(2.U64)", "Generic.f7(1.U64) + 1") catch |err| + return customInfraFailure(allocator, timer, "interface test edit: {}", .{err}); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = changed }) catch |err| + return customInfraFailure(allocator, timer, "interface test write: {}", .{err}); + } + const remaining = childCommandTimeoutMs(timer, timeout_ms) orelse return timeoutFailure(allocator, timer, .run, "case timeout exhausted before interface tests"); + const result = runRocInEnv(io, allocator, &isolated, if (iteration == 0) &.{ "test", "--opt=dev", "--timings", "--no-cache" } else &.{ "test", "--opt=dev", "--timings" }, path, .relative, &.{}, null, remaining) catch |err| + return customInfraFailure(allocator, timer, "interface test spawn: {}", .{err}); + if (!processSucceeded(result.term) or std.mem.find(u8, result.stderr, "unused failing interface root") != null) + return failureFromRun(allocator, timer, result, "persistent interface test replay changed results or demand"); + if (std.mem.count(u8, result.stderr, "persistent test observation") != 1) + return failureFromRun(allocator, timer, result, "persistent interface test replay changed observations"); + const unsupported = timingCounter(result.stderr, "Shared Monotype body + dispatch", "Persistent interface unrepresentable") orelse 0; + if (unsupported != 0) return failureFromRun(allocator, timer, result, "supported test interfaces were not representable"); + const expansions = timingCounter(result.stderr, "Shared Monotype body + dispatch", "Interface summary expansions") orelse 0; + if (iteration == 1) cold_expansions = expansions; + if (iteration == 2) { + const hits = timingCounter(result.stderr, "Shared Monotype body + dispatch", "Persistent interface hits") orelse 0; + if (hits == 0 or expansions >= cold_expansions) + return failureFromRun(allocator, timer, result, "edited tests did not avoid callee relation computation through persistent replay"); + const composites = timingCounter(result.stderr, "Shared Monotype body + dispatch", "Persistent interface composite hits") orelse 0; + const defaults = timingCounter(result.stderr, "Shared Monotype body + dispatch", "Persistent interface default hits") orelse 0; + if (composites == 0 or defaults == 0) + return failureFromRun(allocator, timer, result, "edited tests did not replay composite and default-bearing interfaces"); + } + } + return null; } /// A real checked-artifact/object-store workflow, with a changed app so the @@ -11646,6 +11721,7 @@ fn storeBuildsBehaveIdentically( const store_exes: []const []const u8 = if (expect.uncached_baseline) &.{ "uncached", "a", "b" } else &.{ "a", "b" }; var store_runs: [3]std.process.RunResult = undefined; var cold_body_contexts: ?u64 = null; + var cold_interface_expansions: ?u64 = null; for (store_exes, 0..) |name, index| { const exe = std.fmt.allocPrint(allocator, "{s}/{s}_{s}", .{ out_dir, prefix, name }) catch |err| return customInfraFailure(allocator, timer, "failed to allocate output path: {}", .{err}); @@ -11716,10 +11792,25 @@ fn storeBuildsBehaveIdentically( } } } + if (expect.persistent_interface_reuse) { + const unsupported = timingCounter(built.stderr, "Shared Monotype body + dispatch", "Persistent interface unrepresentable") orelse 0; + if (unsupported != 0) return failureFromRun(allocator, timer, built, "supported interface results were not representable"); + } + if (expect.persistent_interface_reuse and !last and cold_interface_expansions == null) { + cold_interface_expansions = timingCounter(built.stderr, "Shared Monotype body + dispatch", "Interface summary expansions") orelse + return failureFromRun(allocator, timer, built, "build did not report interface relation work"); + } if (last and expect.persistent_interface_reuse) { const hits = timingCounter(built.stderr, "Shared Monotype body + dispatch", "Persistent interface hits") orelse return failureFromRun(allocator, timer, built, "build did not report persistent interface reuse"); if (hits == 0) return failureFromRun(allocator, timer, built, "app edit reused no persistent interface summary"); + const expansions = timingCounter(built.stderr, "Shared Monotype body + dispatch", "Interface summary expansions") orelse + return failureFromRun(allocator, timer, built, "build did not report interface relation work"); + if (expansions >= cold_interface_expansions.?) return failureFromRun(allocator, timer, built, "persistent replay did not reduce actual relation expansion"); + const composites = timingCounter(built.stderr, "Shared Monotype body + dispatch", "Persistent interface composite hits") orelse 0; + if (composites == 0) return failureFromRun(allocator, timer, built, "app edit reused no newly produced composite interface"); + const defaults = timingCounter(built.stderr, "Shared Monotype body + dispatch", "Persistent interface default hits") orelse 0; + if (defaults == 0) return failureFromRun(allocator, timer, built, "app edit reused no default-bearing interface"); if (std.mem.find(u8, built.stderr, "unused failing interface root") != null) return failureFromRun(allocator, timer, built, "interface replay demanded an unused failing root"); } diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index 592f52b5541..bc0f958d6bf 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -1523,6 +1523,7 @@ pub fn prepareCheckedModulesMonotype( .{ .proc_debug_names = target.proc_debug_names or LirDump.filter() != null or SpecCensus.enabled(), .spec_cache = if (target.code_provision.permitsNativeObjects()) target.spec_cache else null, + .interface_cache = if (target.spec_cache) |cache| cache.interfaces else null, .spec_cache_takes = target.code_provision.specCacheTakes(), .post_check_executor = target.post_check_executor, .static_data_literals = target.checked_module_state == .checking_finalization or roots.include_internal_static_data, diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index 6f4f59f4e0b..8b3da2078e7 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -8,6 +8,12 @@ const Solve = @import("solve.zig"); const names = check.CheckedNames; const Allocator = std.mem.Allocator; const Constraints = Solve.InterfaceConstraints; +const GuardedList = @import("collections").GuardedList; +const invariant = base.invariant; +const magic = "RIFSUM02"; + +/// Work performed by the compact codec, independent of wall-clock timing. +pub const Stats = struct { types: u64 = 0, composites: u64 = 0, references: u64 = 0, defaults: u64 = 0 }; /// A settled relation, independent of mutable solver state. pub const Summary = union(enum) { unchanged, constraints: Constraints }; @@ -16,40 +22,291 @@ pub const EncodeError = Allocator.Error || error{NotRepresentable}; /// Allocation failure or an invalid optional cache record. pub const DecodeError = Allocator.Error || error{InvalidSummary}; -/// Encode settled leaves relative to the producer's captured input. -pub fn encode(allocator: Allocator, types: *Type.Store, ns: *const names.NameStore, leaves: []const Type.TypeId, summary: Summary) EncodeError![]u8 { - var writer: Writer = .{ .allocator = allocator, .types = types, .names = ns, .leaves = leaves }; +/// Encode only the immutable graph reachable from the settled relation. The +/// context supplies exact checked artifact addresses for executable defaults. +pub fn encode(allocator: Allocator, types: *Type.Store, ns: *const names.NameStore, leaves: []const Type.TypeId, summary: Summary, context: anytype) EncodeError![]u8 { + return encodeWithStats(allocator, types, ns, leaves, summary, context, null); +} + +/// Encoding with optional deterministic work counters. +pub fn encodeWithStats(allocator: Allocator, types: *Type.Store, ns: *const names.NameStore, leaves: []const Type.TypeId, summary: Summary, context: anytype, stats: ?*Stats) EncodeError![]u8 { + var writer: Writer(@TypeOf(context)) = .{ .allocator = allocator, .types = types, .names = ns, .leaves = leaves, .context = context }; defer writer.bytes.deinit(allocator); + defer writer.references.deinit(allocator); + defer writer.pending.deinit(allocator); + defer writer.leaf_buckets.deinit(allocator); + defer writer.leaf_next.deinit(allocator); + if (summary != .unchanged) try writer.indexLeaves(); + try writer.bytes.appendSlice(allocator, magic); const counts: [3]u32 = switch (summary) { .unchanged => .{ 0, 0, 0 }, .constraints => |c| .{ @intCast(c.nodes.len), @intCast(c.open_nodes.len), @intCast(c.kinds.len) }, }; try writer.write([3]u32, counts); + const count_offset = writer.bytes.items.len; + try writer.write(u32, 0); + try writer.write(u32, std.math.cast(u32, leaves.len) orelse return error.NotRepresentable); try writer.write(Summary, summary); + var index: usize = 0; + while (index < writer.pending.items.len) : (index += 1) { + const content = types.get(writer.pending.items[index]); + writer.stats.types += 1; + switch (content) { + .named, .record, .tuple, .tag_union, .list, .box, .func => writer.stats.composites += 1, + .primitive, .erased, .zst, .hole => {}, + } + try writer.writeContent(content); + } + std.mem.writeInt(u32, writer.bytes.items[count_offset..][0..4], @intCast(writer.pending.items.len), .little); + if (stats) |out| out.* = writer.stats; return writer.bytes.toOwnedSlice(allocator); } -/// `context.validNamedType` validates checked provenance against the current -/// exact module versions. Returned storage belongs to `arena`. +/// References in temporary decoded data name a request leaf or a table node, +/// never a live Type.Store slot. The complete record is validated before the +/// destination transaction starts. Its result is published only after commit. pub fn decode(arena: Allocator, types: *Type.Store, ns: *names.NameStore, leaves: []const Type.TypeId, bytes: []const u8, context: anytype) DecodeError!Summary { - var reader: Reader(@TypeOf(context)) = .{ .arena = arena, .types = types, .names = ns, .leaves = leaves, .bytes = bytes, .context = context }; + return decodeForRequest(arena, types, ns, leaves, bytes, context, null, true, null); +} + +/// The request supplies its expected result arity and whether an unchanged +/// relation is meaningful; these are not inferred from persisted bytes. +pub fn decodeForRequest(arena: Allocator, types: *Type.Store, ns: *names.NameStore, leaves: []const Type.TypeId, bytes: []const u8, context: anytype, root_count: ?usize, allow_unchanged: bool, stats: ?*Stats) DecodeError!Summary { + var scratch = std.heap.ArenaAllocator.init(types.allocator); + defer scratch.deinit(); + var reader: Reader(@TypeOf(context)) = .{ .arena = scratch.allocator(), .names = ns, .leaves = leaves, .bytes = bytes, .context = context }; + if (!std.mem.startsWith(u8, reader.bytes, magic)) return error.InvalidSummary; + reader.bytes = reader.bytes[magic.len..]; reader.counts = try reader.read([3]u32); + reader.type_count = try reader.read(u32); + if (try reader.read(u32) != leaves.len) return error.InvalidSummary; + if (reader.type_count > reader.bytes.len or leaves.len > std.math.maxInt(u32) - reader.type_count) return error.InvalidSummary; const summary = try reader.read(Summary); + const nodes = try scratch.allocator().alloc(WireType, reader.type_count); + for (nodes) |*node| { + node.* = try reader.read(WireType); + reader.stats.types += 1; + switch (node.*) { + .named, .record, .tuple, .tag_union, .list, .box, .func => reader.stats.composites += 1, + .primitive, .erased, .zst, .hole => {}, + } + } if (reader.bytes.len != 0) return error.InvalidSummary; switch (summary) { - .unchanged => if (!std.meta.eql(reader.counts, [3]u32{ 0, 0, 0 })) return error.InvalidSummary, + .unchanged => if (!allow_unchanged or !std.meta.eql(reader.counts, [3]u32{ 0, 0, 0 }) or nodes.len != 0) return error.InvalidSummary, .constraints => |c| { + if (root_count) |expected| if (c.roots.len != expected) return error.InvalidSummary; if (c.nodes.len != reader.counts[0] or c.open_nodes.len != reader.counts[1] or c.kinds.len != reader.counts[2]) return error.InvalidSummary; for (c.nodes) |node| if (node == .open and node.open >= c.open_nodes.len) return error.InvalidSummary; for (c.open_nodes) |node| { if (node.content == .redirect or node.content == .leaf) return error.InvalidSummary; + if (node.content == .unresolved and node.content.unresolved.origin == .placeholder) return error.InvalidSummary; if (node.related_group != .none and @backingInt(node.related_group) >= c.open_nodes.len) return error.InvalidSummary; } }, } - return summary; + try validateTypeGraph(scratch.allocator(), leaves.len, summary, nodes); + const relocated = try scratch.allocator().alloc(Type.TypeId, leaves.len + nodes.len); + @memcpy(relocated[0..leaves.len], leaves); + const transaction = types.beginTransaction(); + errdefer transaction.abort(types); + for (relocated[leaves.len..]) |*slot| slot.* = try transaction.reserve(types); + var mapper = Relocation{ .types = types, .ids = relocated, .allocator = scratch.allocator() }; + for (nodes, relocated[leaves.len..]) |node, slot| transaction.fill(types, slot, try mapper.content(node)); + // Allocate every returned slice before commit. Only the two type-bearing + // columns below need translating after the transaction interns its nodes. + const result: Summary = switch (summary) { + .unchanged => .unchanged, + .constraints => |c| .{ .constraints = try c.copy(arena, mapper) }, + }; + if (nodes.len == 0) { + transaction.abort(types); + if (stats) |out| out.* = reader.stats; + return result; + } + var committed = try types.commitTransaction(ns, transaction, relocated[leaves.len]); + defer committed.deinit(); + if (result == .constraints) { + for (@constCast(result.constraints.nodes)) |*node| if (node.* == .mono) { + node.mono = committed.remapType(node.mono); + }; + for (@constCast(result.constraints.open_nodes)) |*node| if (node.finished.get()) |ty| { + node.finished = .of(committed.remapType(ty)); + }; + } + if (stats) |out| out.* = reader.stats; + return result; +} + +// This wire graph deliberately has no arena spans or producer indices. Its +// nesting has fixed schema depth; all user-controlled recursion uses TypeId. +const WireTag = struct { name: names.TagNameId, checked_name: names.TagNameId, payloads: []const Type.TypeId }; +const WireNamed = struct { + named_type: Type.NamedType, + def: Type.TypeDef, + kind: Type.NamedKind, + builtin_owner: ?check.StaticDispatchRegistry.BuiltinOwner, + args: []const Type.TypeId, + backing: ?Type.NamedBacking, + declared_order: []const Type.DeclaredField, +}; +const WireType = union(enum(u8)) { + primitive: Type.Primitive, + named: WireNamed, + record: []const Type.Field, + tuple: []const Type.TypeId, + tag_union: []const WireTag, + list: Type.TypeId, + box: Type.TypeId, + func: struct { args: []const Type.TypeId, ret: Type.TypeId }, + erased: names.TypeDigest, + zst, + hole, +}; + +// All traversal here is over explicit integer edges. Schema reflection only +// visits the fixed-depth wire records; it never follows a type graph edge. +const TypeEdges = struct { + allocator: Allocator, + edges: std.ArrayList(Type.TypeId) = .empty, + fn collect(self: *TypeEdges, comptime T: type, value: T) Allocator.Error!void { + if (T == Type.TypeId) return self.edges.append(self.allocator, value); + if (T == Type.MaybeTypeId) { + if (value.get()) |ty| try self.edges.append(self.allocator, ty); + return; + } + switch (@typeInfo(T)) { + .@"struct" => |info| inline for (info.field_names, info.field_types) |field, F| try self.collect(F, @field(value, field)), + .@"union" => |info| inline for (info.field_names, info.field_types) |field, F| { + if (std.meta.activeTag(value) == @field(info.tag_type.?, field)) return self.collect(F, @field(value, field)); + }, + .optional => |info| if (value) |v| { + try self.collect(info.child, v); + }, + .pointer => |info| switch (info.size) { + .one => try self.collect(info.child, value.*), + .slice => for (value) |v| try self.collect(info.child, v), + .many, .c => @compileError("unbounded summary pointer"), + }, + .array => |info| for (value) |v| try self.collect(info.child, v), + .@"enum", .int, .bool, .void => {}, + else => @compileError("unsupported summary graph field"), + } + } +}; + +fn validateTypeGraph(allocator: Allocator, leaf_count: usize, summary: Summary, nodes: []const WireType) DecodeError!void { + var edges: TypeEdges = .{ .allocator = allocator }; + defer edges.edges.deinit(allocator); + try edges.collect(Summary, summary); + const roots_end = edges.edges.items.len; + const starts = try allocator.alloc(usize, nodes.len + 1); + defer allocator.free(starts); + for (nodes, 0..) |node, i| { + starts[i] = edges.edges.items.len; + try edges.collect(WireType, node); + } + starts[nodes.len] = edges.edges.items.len; + const seen = try allocator.alloc(bool, nodes.len); + defer allocator.free(seen); + @memset(seen, false); + var queue = std.ArrayList(Type.TypeId).empty; + defer queue.deinit(allocator); + try queue.appendSlice(allocator, edges.edges.items[0..roots_end]); + var head: usize = 0; + while (head < queue.items.len) : (head += 1) { + const ref = @backingInt(queue.items[head]); + if (ref < leaf_count) continue; + const index = ref - leaf_count; + if (seen[index]) continue; + seen[index] = true; + try queue.appendSlice(allocator, edges.edges.items[starts[index]..starts[index + 1]]); + } + for (seen) |reachable| if (!reachable) return error.InvalidSummary; + + // Checking rejects alias-only cycles. A recursive type must cross a real + // constructor, so an unguarded alias cycle is corrupt external input. + const colors = try allocator.alloc(enum { unseen, active, done }, nodes.len); + defer allocator.free(colors); + @memset(colors, .unseen); + for (nodes, 0..) |_, start| { + var index = start; + while (colors[index] == .unseen) { + colors[index] = .active; + const node = nodes[index]; + if (node != .named or node.named.kind != .alias) break; + const backing = node.named.backing orelse break; + const ref = @backingInt(backing.ty); + if (ref < leaf_count) break; + index = ref - leaf_count; + if (colors[index] == .active) return error.InvalidSummary; + } + index = start; + while (colors[index] == .active) { + colors[index] = .done; + const node = nodes[index]; + if (node != .named or node.named.kind != .alias) break; + const backing = node.named.backing orelse break; + const ref = @backingInt(backing.ty); + if (ref < leaf_count) break; + index = ref - leaf_count; + } + } } +const Relocation = struct { + types: *Type.Store, + ids: []const Type.TypeId, + allocator: Allocator, + + pub fn mapScalar(self: Relocation, comptime T: type, value: T) Allocator.Error!T { + if (T == Type.TypeId) return self.ids[@backingInt(value)]; + return value; + } + fn ty(self: Relocation, value: Type.TypeId) Type.TypeId { + return self.ids[@backingInt(value)]; + } + fn span(self: Relocation, values: []const Type.TypeId) Allocator.Error!Type.Span { + const mapped = try self.allocator.alloc(Type.TypeId, values.len); + for (values, mapped) |value, *out| out.* = self.ty(value); + return self.types.addSpan(mapped); + } + fn content(self: Relocation, node: WireType) Allocator.Error!Type.Content { + return switch (node) { + .primitive => |v| .{ .primitive = v }, + .erased => |v| .{ .erased = v }, + .zst => .zst, + .hole => .hole, + .list => |v| .{ .list = self.ty(v) }, + .box => |v| .{ .box = self.ty(v) }, + .tuple => |v| .{ .tuple = try self.span(v) }, + .func => |v| .{ .func = .{ .args = try self.span(v.args), .ret = self.ty(v.ret) } }, + .record => |v| blk: { + const fields = try self.allocator.dupe(Type.Field, v); + for (fields) |*field| { + field.ty = self.ty(field.ty); + if (field.value_ty.get()) |value| field.value_ty = .of(self.ty(value)); + } + break :blk .{ .record = try self.types.addFields(fields) }; + }, + .tag_union => |v| blk: { + const tags = try self.allocator.alloc(Type.Tag, v.len); + for (v, tags) |tag, *out| out.* = .{ .name = tag.name, .checked_name = tag.checked_name, .payloads = try self.span(tag.payloads) }; + break :blk .{ .tag_union = try self.types.addTags(tags) }; + }, + .named => |v| blk: { + const declared = try self.allocator.dupe(Type.DeclaredField, v.declared_order); + for (declared) |*field| if (field.* == .padding) { + field.padding = self.ty(field.padding); + }; + var backing = v.backing; + if (backing) |*b| b.ty = self.ty(b.ty); + break :blk .{ .named = .{ .named_type = v.named_type, .def = v.def, .kind = v.kind, .builtin_owner = v.builtin_owner, .args = try self.span(v.args), .backing = backing, .declared_order = try self.types.addDeclaredFields(declared) } }; + }, + }; + } +}; + /// Procedure ordinals, captured input and context-qualified evidence identity. /// Checked callable-type ordinals are provenance; producer-issued callable keys /// carry their relation, as in `Ast.fnEvidenceDigest`. @@ -64,97 +321,194 @@ pub fn requestKey(request: Constraints.Identity, evidence: [32]u8, procedure: [8 return result; } -const Writer = struct { - allocator: Allocator, - types: *Type.Store, - names: *const names.NameStore, - leaves: []const Type.TypeId, - bytes: std.ArrayList(u8) = .empty, - - fn write(self: *Writer, comptime T: type, value: T) EncodeError!void { - if (T == Type.TypeId) { - for (self.leaves, 0..) |leaf, index| if (leaf == value or - (std.meta.eql(self.types.representationDigestCached(self.names, leaf, null), self.types.representationDigestCached(self.names, value, null)) and - try self.types.typeEql(self.names, leaf, value))) - { - try self.write(u8, 0); - return self.write(u32, @intCast(index)); - }; - const content = self.types.get(value); - if (content != .primitive) return error.NotRepresentable; - try self.write(u8, 1); - return self.write(Type.Primitive, content.primitive); +fn Writer(comptime Context: type) type { + return struct { + const Self = @This(); + context: Context, + stats: Stats = .{}, + references: std.AutoHashMapUnmanaged(Type.TypeId, u32) = .empty, + pending: std.ArrayList(Type.TypeId) = .empty, + leaf_buckets: std.AutoHashMapUnmanaged([32]u8, u32) = .empty, + leaf_next: std.ArrayList(?u32) = .empty, + allocator: Allocator, + types: *Type.Store, + names: *const names.NameStore, + leaves: []const Type.TypeId, + bytes: std.ArrayList(u8) = .empty, + + fn indexLeaves(self: *Self) EncodeError!void { + try self.leaf_next.resize(self.allocator, self.leaves.len); + @memset(self.leaf_next.items, null); + // One representative per representation/equality class, always + // the first request position, even when checked occurrences have + // different process-local TypeIds. + for (self.leaves, 0..) |leaf, index| { + if (self.references.contains(leaf)) continue; + const digest = try self.types.representationDigestFallible(self.names, leaf); + var candidate = self.leaf_buckets.get(digest.bytes); + var representative: ?u32 = null; + while (candidate) |other| : (candidate = self.leaf_next.items[other]) { + if (try self.types.typeEql(self.names, self.leaves[other], leaf)) { + representative = other; + break; + } + } + if (representative) |other| { + try self.references.put(self.allocator, leaf, other); + } else { + const entry = try self.leaf_buckets.getOrPut(self.allocator, digest.bytes); + self.leaf_next.items[index] = if (entry.found_existing) entry.value_ptr.* else null; + entry.value_ptr.* = @intCast(index); + try self.references.put(self.allocator, leaf, @intCast(index)); + } + } } - if (T == Type.MaybeTypeId) return self.write(?Type.TypeId, value.get()); - if (T == names.ModuleIdentityId) return self.write([32]u8, self.names.moduleIdentityBytes(value).*); - if (T == names.TypeNameId) return self.write([]const u8, self.names.typeNameText(value)); - if (T == names.RecordFieldNameId) return self.write([]const u8, self.names.recordFieldLabelText(value)); - if (T == names.TagNameId) return self.write([]const u8, self.names.tagLabelText(value)); - // A default's expression address needs a separate relocatable producer - // contract; it is not inferred from its current type or source spelling. - if (T == Type.FieldDefault) return error.NotRepresentable; - switch (@typeInfo(T)) { - .@"struct" => |info| inline for (info.field_names, info.field_types) |field, F| try self.write(F, @field(value, field)), - .@"union" => |info| { - try self.write(info.tag_type.?, std.meta.activeTag(value)); - inline for (info.field_names, info.field_types) |field, F| if (std.meta.activeTag(value) == @field(info.tag_type.?, field)) return self.write(F, @field(value, field)); - unreachable; // activeTag(value) names a field handled by the loop above. - }, - .optional => |info| { - try self.write(bool, value != null); - if (value) |v| try self.write(info.child, v); - }, - .pointer => |info| switch (info.size) { - .slice => { - try self.write(u32, std.math.cast(u32, value.len) orelse return error.NotRepresentable); - for (value) |v| try self.write(info.child, v); - }, - .one => try self.write(info.child, value.*), - .many, .c => @compileError("unbounded summary pointer"), - }, - .array => |info| for (value) |v| try self.write(info.child, v), - .@"enum" => |info| try self.write(info.tag_type, @backingInt(value)), - .bool => try self.write(u8, @intFromBool(value)), - .int => { - const U = @Int(.unsigned, @bitSizeOf(T)); - const size = (@bitSizeOf(T) + 7) / 8; - var bits: U = @bitCast(value); - for (0..size) |_| { - try self.bytes.append(self.allocator, @truncate(@as(u64, bits))); - bits = std.math.shr(U, bits, 8); + fn reference(self: *Self, value: Type.TypeId) EncodeError!u32 { + if (self.references.get(value)) |ref| return ref; + if (self.leaves.len != 0) { + const digest = try self.types.representationDigestFallible(self.names, value); + var candidate = self.leaf_buckets.get(digest.bytes); + while (candidate) |index| : (candidate = self.leaf_next.items[index]) { + if (try self.types.typeEql(self.names, self.leaves[index], value)) { + try self.references.put(self.allocator, value, index); + return index; + } } - }, - .void => {}, - .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), + } + const ref = std.math.cast(u32, self.leaves.len + self.pending.items.len) orelse return error.NotRepresentable; + try self.references.put(self.allocator, value, ref); + try self.pending.append(self.allocator, value); + return ref; } - } -}; + fn writeSpan(self: *Self, comptime T: type, borrow: anytype) EncodeError!void { + try self.write(u32, @intCast(GuardedList.borrowLen(borrow))); + for (0..GuardedList.borrowLen(borrow)) |index| try self.write(T, GuardedList.at(borrow, index)); + } + fn writeContent(self: *Self, content: Type.Content) EncodeError!void { + // The wire tag order is explicit and versioned with this payload. + switch (content) { + .primitive => |v| try self.write(WireType, .{ .primitive = v }), + .erased => |v| try self.write(WireType, .{ .erased = v }), + .zst => try self.write(WireType, .zst), + .hole => try self.write(WireType, .hole), + .list => |v| try self.write(WireType, .{ .list = v }), + .box => |v| try self.write(WireType, .{ .box = v }), + .tuple => |v| { + try self.write(std.meta.Tag(WireType), .tuple); + try self.writeSpan(Type.TypeId, self.types.span(v)); + }, + .func => |v| { + try self.write(std.meta.Tag(WireType), .func); + try self.writeSpan(Type.TypeId, self.types.span(v.args)); + try self.write(Type.TypeId, v.ret); + }, + .record => |v| { + try self.write(std.meta.Tag(WireType), .record); + try self.writeSpan(Type.Field, self.types.fieldSpan(v)); + }, + .tag_union => |v| { + try self.write(std.meta.Tag(WireType), .tag_union); + const tags = self.types.tagSpan(v); + try self.write(u32, @intCast(GuardedList.borrowLen(tags))); + for (0..GuardedList.borrowLen(tags)) |index| { + const tag = GuardedList.at(tags, index); + try self.write(names.TagNameId, tag.name); + try self.write(names.TagNameId, tag.checked_name); + try self.writeSpan(Type.TypeId, self.types.span(tag.payloads)); + } + }, + .named => |v| { + try self.write(std.meta.Tag(WireType), .named); + try self.write(Type.NamedType, v.named_type); + try self.write(Type.TypeDef, v.def); + try self.write(Type.NamedKind, v.kind); + try self.write(@TypeOf(v.builtin_owner), v.builtin_owner); + try self.writeSpan(Type.TypeId, self.types.span(v.args)); + try self.write(?Type.NamedBacking, v.backing); + try self.writeSpan(Type.DeclaredField, self.types.declaredFieldSpan(v.declared_order)); + }, + } + } + + fn write(self: *Self, comptime T: type, value: T) EncodeError!void { + if (T == Type.TypeId) { + self.stats.references += 1; + return self.write(u32, try self.reference(value)); + } + if (T == Solve.InstNode) { + if (value == .redirect or value == .leaf or (value == .unresolved and value.unresolved.origin == .placeholder)) invariant("settled interface contains unfinished solver state", .{}); + } + if (T == Type.MaybeTypeId) return self.write(?Type.TypeId, value.get()); + if (T == names.ModuleIdentityId) return self.write([32]u8, self.names.moduleIdentityBytes(value).*); + if (T == names.TypeNameId) return self.write([]const u8, self.names.typeNameText(value)); + if (T == names.RecordFieldNameId) return self.write([]const u8, self.names.recordFieldLabelText(value)); + if (T == names.TagNameId) return self.write([]const u8, self.names.tagLabelText(value)); + if (T == Type.FieldDefault) { + self.stats.defaults += 1; + const identity = self.names.moduleIdentityBytes(value.module).*; + const artifact = self.context.defaultArtifact(identity, value.expr_node) orelse + invariant("published field default has no declaring checked artifact or archived expression", .{}); + try self.write([32]u8, identity); + try self.write([32]u8, artifact); + return self.write(u32, value.expr_node); + } + switch (@typeInfo(T)) { + .@"struct" => |info| inline for (info.field_names, info.field_types) |field, F| try self.write(F, @field(value, field)), + .@"union" => |info| { + try self.write(info.tag_type.?, std.meta.activeTag(value)); + inline for (info.field_names, info.field_types) |field, F| if (std.meta.activeTag(value) == @field(info.tag_type.?, field)) return self.write(F, @field(value, field)); + unreachable; // activeTag(value) names a field handled by the loop above. + }, + .optional => |info| { + try self.write(bool, value != null); + if (value) |v| try self.write(info.child, v); + }, + .pointer => |info| switch (info.size) { + .slice => { + try self.write(u32, std.math.cast(u32, value.len) orelse return error.NotRepresentable); + for (value) |v| try self.write(info.child, v); + }, + .one => try self.write(info.child, value.*), + .many, .c => @compileError("unbounded summary pointer"), + }, + .array => |info| for (value) |v| try self.write(info.child, v), + .@"enum" => |info| try self.write(info.tag_type, @backingInt(value)), + .bool => try self.write(u8, @intFromBool(value)), + .int => { + const U = @Int(.unsigned, @bitSizeOf(T)); + const size = (@bitSizeOf(T) + 7) / 8; + var bits: U = @bitCast(value); + for (0..size) |_| { + try self.bytes.append(self.allocator, @truncate(@as(u64, bits))); + bits = std.math.shr(U, bits, 8); + } + }, + .void => {}, + .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), + } + } + }; +} fn Reader(comptime Context: type) type { return struct { const Self = @This(); arena: Allocator, - types: *Type.Store, names: *names.NameStore, leaves: []const Type.TypeId, bytes: []const u8, context: Context, + stats: Stats = .{}, counts: [3]u32 = .{ 0, 0, 0 }, - depth: usize = 0, + type_count: u32 = 0, fn read(self: *Self, comptime T: type) DecodeError!T { - if (self.depth == 128) return error.InvalidSummary; - self.depth += 1; - defer self.depth -= 1; - if (T == Type.TypeId) return switch (try self.read(u8)) { - 0 => blk: { - const index = try self.read(u32); - if (index >= self.leaves.len) return error.InvalidSummary; - break :blk self.leaves[index]; - }, - 1 => try self.types.internPrimitive(self.names, try self.read(Type.Primitive)), - else => error.InvalidSummary, - }; + if (T == Type.TypeId) { + self.stats.references += 1; + const index = try self.read(u32); + if (index >= self.leaves.len + self.type_count) return error.InvalidSummary; + return @fromBackingInt(index); + } if (T == Type.MaybeTypeId) return .of(try self.read(?Type.TypeId)); if (T == names.ModuleIdentityId) { const digest = try self.read([32]u8); @@ -164,7 +518,15 @@ fn Reader(comptime Context: type) type { if (T == names.TypeNameId) return self.names.internTypeName(try self.read([]const u8)); if (T == names.RecordFieldNameId) return self.names.internRecordFieldLabel(try self.read([]const u8)); if (T == names.TagNameId) return self.names.internTagLabel(try self.read([]const u8)); - if (T == Type.FieldDefault) return error.InvalidSummary; + if (T == Type.FieldDefault) { + self.stats.defaults += 1; + const identity = try self.read([32]u8); + const artifact = try self.read([32]u8); + const expr = try self.read(u32); + const current = self.context.defaultArtifact(identity, expr) orelse return error.InvalidSummary; + if (!std.mem.eql(u8, &artifact, ¤t)) return error.InvalidSummary; + return .{ .module = try self.names.internModuleIdentity(&identity), .expr_node = expr }; + } if (T == Solve.NodeId or T == Solve.MaybeNodeId or T == Solve.FieldKindId) { const index = try self.read(u32); if (T == Solve.MaybeNodeId and index == std.math.maxInt(u32)) return @fromBackingInt(index); @@ -229,11 +591,44 @@ fn Reader(comptime Context: type) type { .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), }; if (T == Type.NamedType) if (!self.context.validNamedType(result)) return error.InvalidSummary; + if (T == WireNamed or T == @typeInfo(@FieldType(Solve.InstNode, "named")).pointer.child or T == Solve.InstIteratorPublicSource) { + if (!self.context.validNamedDefinition(self.names, result.named_type, result.def, result.kind, result.builtin_owner)) return error.InvalidSummary; + } + if (T == WireType) switch (result) { + .record => |fields| { + for (fields, 0..) |field, i| { + if (i > 0 and std.mem.order(u8, self.names.recordFieldLabelText(fields[i - 1].name), self.names.recordFieldLabelText(field.name)) != .lt) return error.InvalidSummary; + if (field.kind_state == .undetermined and (field.value_ty.get() == null or field.ty != field.value_ty.get().? or field.default != null)) return error.InvalidSummary; + } + }, + .tag_union => |tags| for (tags, 0..) |tag, i| { + if (i > 0 and std.mem.order(u8, self.names.tagLabelText(tags[i - 1].name), self.names.tagLabelText(tag.name)) != .lt) return error.InvalidSummary; + }, + .primitive, .named, .tuple, .list, .box, .func, .erased, .zst, .hole => {}, + }; return result; } }; } +const TestProvenance = struct { + artifact: [32]u8 = @splat(7), + available: bool = true, + pub fn defaultArtifact(self: @This(), identity: [32]u8, expr: u32) ?[32]u8 { + if (!self.available or identity[0] != 1 or expr > 2) return null; + return self.artifact; + } + pub fn validModuleIdentity(self: @This(), identity: [32]u8) bool { + return self.available and identity[0] == 1; + } + pub fn validNamedType(self: @This(), named: Type.NamedType) bool { + return self.available and named.module.bytes[0] == 0 and named.ty_module.bytes[0] == 0 and @backingInt(named.ty) == 1; + } + pub fn validNamedDefinition(self: @This(), _: *const names.NameStore, named: Type.NamedType, _: Type.TypeDef, _: Type.NamedKind, _: ?check.StaticDispatchRegistry.BuiltinOwner) bool { + return self.validNamedType(named); + } +}; + test "persistent interface constraints relocate request leaves and preserve cycles" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); @@ -257,25 +652,17 @@ test "persistent interface constraints relocate request leaves and preserve cycl .open_nodes = &.{.{ .content = .{ .func = .{ .args = @constCast(&[_]Solve.NodeId{@fromBackingInt(argument_slot)}), .ret = @fromBackingInt(function_slot) } } }}, .kinds = &.{}, } }; - const bytes = try encode(allocator, &source, &ns, &.{source_ty}, input); + const bytes = try encode(allocator, &source, &ns, &.{source_ty}, input, TestProvenance{}); defer allocator.free(bytes); - const Provenance = struct { - pub fn validNamedType(_: @This(), _: Type.NamedType) bool { - return false; - } - pub fn validModuleIdentity(_: @This(), _: [32]u8) bool { - return false; - } - }; - const result = (try decode(arena.allocator(), &destination, &ns, &.{destination_ty}, bytes, Provenance{})).constraints; + const result = (try decode(arena.allocator(), &destination, &ns, &.{destination_ty}, bytes, TestProvenance{})).constraints; try std.testing.expectEqual(destination_ty, result.nodes[1].mono); try std.testing.expectEqual(@as(u32, 0), @backingInt(result.open_nodes[0].content.func.ret)); - for (0..bytes.len) |len| try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &destination, &ns, &.{destination_ty}, bytes[0..len], Provenance{})); + for (0..bytes.len) |len| try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &destination, &ns, &.{destination_ty}, bytes[0..len], TestProvenance{})); const bad = try allocator.dupe(u8, bytes); defer allocator.free(bad); // Header declares no node slots, but the root still references slot zero. - @memset(bad[0..4], 0); - try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &destination, &ns, &.{destination_ty}, bad, Provenance{})); + @memset(bad[magic.len..][0..4], 0); + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &destination, &ns, &.{destination_ty}, bad, TestProvenance{})); } test "persistent interface leaves preserve representation beyond semantic equality" { @@ -301,7 +688,22 @@ test "persistent interface leaves preserve representation beyond semantic equali .open_nodes = &.{}, .kinds = &.{}, } }; - try std.testing.expectError(error.NotRepresentable, encode(allocator, &types, &ns, &.{u64_ty}, summary)); + const bytes = try encode(allocator, &types, &ns, &.{u64_ty}, summary, TestProvenance{}); + defer allocator.free(bytes); + var destination = Type.Store.init(allocator); + defer destination.deinit(); + var destination_names = names.NameStore.init(allocator); + defer destination_names.deinit(); + _ = try destination_names.internTypeName("unrelated"); + _ = try destination.internPrimitive(&destination_names, .bool); + const input = try destination.internPrimitive(&destination_names, .u64); + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const result = try decode(arena.allocator(), &destination, &destination_names, &.{input}, bytes, TestProvenance{}); + const restored = result.constraints.nodes[0].mono; + try std.testing.expect(restored != input); + try std.testing.expectEqual(types.representationDigestCached(&ns, alias, null), destination.representationDigestCached(&destination_names, restored, null)); + try std.testing.expectEqual(Type.NamedKind.alias, destination.get(restored).named.kind); } test "persistent interface requests use canonical evidence instead of checked ordinals" { @@ -360,3 +762,316 @@ test "persistent interface evidence contexts preserve types and exact procedure targets[0].method.kind.procedure.template.artifact.bytes = second; try std.testing.expect(!std.meta.eql(procedure, Ast.fnEvidenceContextDigest(evidence, &.{}, null, &contexts))); } + +fn typeSummary(ty: Type.TypeId, nodes: *[1]Constraints.Node) Summary { + nodes.* = .{.{ .mono = ty }}; + return .{ .constraints = .{ .roots = &.{@fromBackingInt(0)}, .nodes = nodes, .open_nodes = &.{}, .kinds = &.{} } }; +} + +test "persistent interface composite graph preserves sharing defaults and representation metadata" { + const gpa = std.testing.allocator; + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var source = Type.Store.init(gpa); + defer source.deinit(); + const module = try ns.internModuleIdentity(&@as([32]u8, @splat(1))); + const label = try ns.internRecordFieldLabel("value"); + const primitive = try source.internPrimitive(&ns, .u64); + const boxed = try source.add(.{ .box = primitive }); + const list = try source.add(.{ .list = boxed }); + const erased = try source.add(.{ .erased = .{ .bytes = @splat(3) } }); + const hole = try source.add(.hole); + const zst = try source.add(.zst); + const tuple = try source.add(.{ .tuple = try source.addSpan(&.{ list, list, erased, hole, zst }) }); + const tag = try source.add(.{ .tag_union = try source.addTags(&.{.{ .name = try ns.internTagLabel("Some"), .checked_name = try ns.internTagLabel("Checked.Some"), .payloads = try source.addSpan(&.{tuple}) }}) }); + const field: Type.Field = .{ .name = label, .ty = tag, .default = .{ .module = module, .expr_node = 1 } }; + const record = try source.add(.{ .record = try source.addFields(&.{field}) }); + const provisional = try source.add(.{ .record = try source.addFields(&.{.{ .name = label, .ty = primitive, .value_ty = .of(primitive), .kind_state = .undetermined, .default = null }}) }); + const definition: Type.TypeDef = .{ + .module = module, + .type_name = try ns.internTypeName("Iter"), + .source_decl = 12, + .generated = .{ .bytes = @splat(4) }, + .iterator_representation = .minted, + .iterator_kind = .list, + .iterator_depth = 2, + .iterator_topology = .{ + .len_field = label, + .step_field = label, + .known_tag = try ns.internTagLabel("Known"), + .unknown_tag = try ns.internTagLabel("Unknown"), + .done_tag = try ns.internTagLabel("Done"), + .one_tag = try ns.internTagLabel("One"), + .skip_tag = try ns.internTagLabel("Skip"), + .item_field = label, + .rest_field = label, + }, + }; + var named_types: [3]Type.TypeId = undefined; + for ([_]Type.NamedKind{ .nominal, .alias, .@"opaque" }, &named_types) |kind, *out| { + out.* = try source.add(.{ .named = .{ + .named_type = .{ .module = .{}, .ty = @fromBackingInt(1), .ty_module = .{} }, + .def = definition, + .kind = kind, + .args = try source.addSpan(&.{primitive}), + .backing = .{ .ty = record, .use = if (kind == .@"opaque") .runtime_layout_only else .inspectable, .authority = .generated_private }, + .declared_order = try source.addDeclaredFields(&.{ .{ .named = label }, .{ .padding = primitive } }), + } }); + } + const root = try source.add(.{ .func = .{ .args = try source.addSpan(&named_types), .ret = provisional } }); + var summary_nodes: [1]Constraints.Node = undefined; + const bytes = try encode(gpa, &source, &ns, &.{primitive}, typeSummary(root, &summary_nodes), TestProvenance{}); + defer gpa.free(bytes); + var dest = Type.Store.init(gpa); + defer dest.deinit(); + var dest_names = names.NameStore.init(gpa); + defer dest_names.deinit(); + _ = try dest_names.internRecordFieldLabel("unrelated"); + _ = try dest_names.internTagLabel("unrelated"); + _ = try dest_names.internModuleIdentity(&@as([32]u8, @splat(9))); + _ = try dest.internPrimitive(&dest_names, .bool); + const input = try dest.internPrimitive(&dest_names, .u64); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const restored = (try decode(arena.allocator(), &dest, &dest_names, &.{input}, bytes, TestProvenance{})).constraints.nodes[0].mono; + try std.testing.expectEqual(source.representationDigestCached(&ns, root, null), dest.representationDigestCached(&dest_names, restored, null)); + const again = try encode(gpa, &dest, &dest_names, &.{input}, typeSummary(restored, &summary_nodes), TestProvenance{}); + defer gpa.free(again); + try std.testing.expectEqualSlices(u8, bytes, again); + const size = dest.types.len(); + for (0..3) |_| { + const replay = try decode(arena.allocator(), &dest, &dest_names, &.{input}, bytes, TestProvenance{}); + try std.testing.expectEqual(restored, replay.constraints.nodes[0].mono); + try std.testing.expectEqual(size, dest.types.len()); + } + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &dest, &dest_names, &.{input}, bytes, TestProvenance{ .artifact = @splat(8) })); + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &dest, &dest_names, &.{input}, bytes, TestProvenance{ .available = false })); + try std.testing.expectEqual(size, dest.types.len()); + var other_field = field; + other_field.default.?.expr_node = 2; + const other_record = try source.add(.{ .record = try source.addFields(&.{other_field}) }); + const first_default = try encode(gpa, &source, &ns, &.{}, typeSummary(record, &summary_nodes), TestProvenance{}); + defer gpa.free(first_default); + const second_default = try encode(gpa, &source, &ns, &.{}, typeSummary(other_record, &summary_nodes), TestProvenance{}); + defer gpa.free(second_default); + try std.testing.expect(!std.mem.eql(u8, first_default, second_default)); +} + +test "persistent interface graph traversal is bounded by nodes rather than shared paths or depth" { + const gpa = std.testing.allocator; + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var source = Type.Store.init(gpa); + defer source.deinit(); + var root = try source.internPrimitive(&ns, .u64); + // Every level shares its child twice; recursive serialization would have + // exponential size. The long chain also exceeds native recursion budgets. + const depth = 2048; + for (0..depth) |_| root = try source.add(.{ .tuple = try source.addSpan(&.{ root, root }) }); + var nodes: [1]Constraints.Node = undefined; + const bytes = try encode(gpa, &source, &ns, &.{}, typeSummary(root, &nodes), TestProvenance{}); + defer gpa.free(bytes); + try std.testing.expect(bytes.len < 16 * depth); + var dest = Type.Store.init(gpa); + defer dest.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var restored = (try decode(arena.allocator(), &dest, &ns, &.{}, bytes, TestProvenance{})).constraints.nodes[0].mono; + for (0..depth) |_| { + const items = dest.span(dest.get(restored).tuple); + try std.testing.expectEqual(GuardedList.at(items, 0), GuardedList.at(items, 1)); + restored = GuardedList.at(items, 0); + } + try std.testing.expectEqual(Type.Primitive.u64, dest.get(restored).primitive); +} + +test "persistent interface recursive function record survives producer destruction" { + const gpa = std.testing.allocator; + const bytes = blk: { + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var source = Type.Store.init(gpa); + defer source.deinit(); + // Recursive iterator-style record: step returns the same record. + const slot = try source.beginRecursive(); + const function = try source.add(.{ .func = .{ .args = .empty(), .ret = slot.ty } }); + source.finishRecursive(slot, .{ .record = try source.addFields(&.{.{ .name = try ns.internRecordFieldLabel("step"), .ty = function, .default = null }}) }); + var nodes: [1]Constraints.Node = undefined; + break :blk try encode(gpa, &source, &ns, &.{}, typeSummary(slot.ty, &nodes), TestProvenance{}); + }; + defer gpa.free(bytes); + for (0..2) |_| { + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var dest = Type.Store.init(gpa); + defer dest.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const root = (try decode(arena.allocator(), &dest, &ns, &.{}, bytes, TestProvenance{})).constraints.nodes[0].mono; + const field = GuardedList.at(dest.fieldSpan(dest.get(root).record), 0); + try std.testing.expectEqual(root, dest.get(field.ty).func.ret); + } +} + +test "persistent interface open variables and defaulted field kinds remain relation data" { + const gpa = std.testing.allocator; + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var store = Type.Store.init(gpa); + defer store.deinit(); + const module = try ns.internModuleIdentity(&@as([32]u8, @splat(1))); + const summary: Summary = .{ .constraints = .{ + .roots = &.{ @fromBackingInt(0), @fromBackingInt(0) }, + .nodes = &.{.{ .open = 0 }}, + .open_nodes = &.{.{ .content = .{ .unresolved = Solve.InstVariable.checkedVariable(null, null) } }}, + .kinds = &.{.{ .resolved = .{ .defaulted = .{ .module = module, .expr_node = 1 } }, .cells = null }}, + } }; + const bytes = try encode(gpa, &store, &ns, &.{}, summary, TestProvenance{}); + defer gpa.free(bytes); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const result = (try decode(arena.allocator(), &store, &ns, &.{}, bytes, TestProvenance{})).constraints; + try std.testing.expectEqual(result.roots[0], result.roots[1]); + try std.testing.expectEqual(Solve.InstVariableOrigin.checked_variable, result.open_nodes[0].content.unresolved.origin); + try std.testing.expectEqual(@as(u32, 1), result.kinds[0].resolved.?.defaulted.expr_node); +} + +fn allocationFailureRoundTrip(gpa: Allocator) !void { + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var source = Type.Store.init(gpa); + defer source.deinit(); + const primitive = try source.internPrimitive(&ns, .u64); + const record = try source.add(.{ .record = try source.addFields(&.{.{ + .name = try ns.internRecordFieldLabel("value"), + .ty = primitive, + .default = .{ .module = try ns.internModuleIdentity(&@as([32]u8, @splat(1))), .expr_node = 1 }, + }}) }); + const root = try source.add(.{ .tuple = try source.addSpan(&.{ record, record }) }); + var nodes: [1]Constraints.Node = undefined; + const bytes = try encode(gpa, &source, &ns, &.{primitive}, typeSummary(root, &nodes), TestProvenance{}); + defer gpa.free(bytes); + var dest = Type.Store.init(gpa); + defer dest.deinit(); + var destination_names = names.NameStore.init(gpa); + defer destination_names.deinit(); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const input = try dest.internPrimitive(&destination_names, .u64); + const before = dest.types.len(); + _ = decode(arena.allocator(), &dest, &destination_names, &.{input}, bytes, TestProvenance{}) catch |err| { + try std.testing.expectEqual(before, dest.types.len()); + try std.testing.expect(!dest.hasSpeculativeConstruction()); + return err; + }; +} + +test "persistent interface allocation failures roll back construction" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationFailureRoundTrip, .{}); +} + +test "persistent interface rejects corrupt graph data before changing the receiving store" { + const gpa = std.testing.allocator; + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var source = Type.Store.init(gpa); + defer source.deinit(); + const primitive = try source.internPrimitive(&ns, .u64); + const root = try source.internTuple(&ns, &.{ primitive, primitive }); + var summary_nodes: [1]Constraints.Node = undefined; + const bytes = try encode(gpa, &source, &ns, &.{}, typeSummary(root, &summary_nodes), TestProvenance{}); + defer gpa.free(bytes); + var dest = Type.Store.init(gpa); + defer dest.deinit(); + _ = try dest.internPrimitive(&ns, .bool); + const before = dest.types.len(); + for (0..bytes.len) |len| { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &dest, &ns, &.{}, bytes[0..len], TestProvenance{})); + try std.testing.expectEqual(before, dest.types.len()); + try std.testing.expect(!dest.hasSpeculativeConstruction()); + } + const bad = try gpa.dupe(u8, bytes); + defer gpa.free(bad); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + // Payload version, enormous table count, invalid node tag, invalid type + // reference, and an invalid table tag. These offsets are the v2 contract + // for a single-root single-leaf relation with no open nodes or kinds. + for ([_]usize{ 7, 20, 24, 41, 42, 54 }) |offset| { + @memcpy(bad, bytes); + bad[offset] = 255; + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &dest, &ns, &.{}, bad, TestProvenance{})); + try std.testing.expectEqual(before, dest.types.len()); + } + // Append a well-formed but unreachable primitive, then a tuple edge that + // names it. Reachability, rather than allocation order, defines ownership. + const extra = try gpa.alloc(u8, bytes.len + 2); + defer gpa.free(extra); + @memcpy(extra[0..bytes.len], bytes); + @memcpy(extra[bytes.len..], bytes[bytes.len - 2 ..]); + std.mem.writeInt(u32, extra[20..24], 3, .little); + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &dest, &ns, &.{}, extra, TestProvenance{})); + try std.testing.expectEqual(before, dest.types.len()); + std.mem.writeInt(u32, extra[63..67], 2, .little); + _ = try decode(arena.allocator(), &dest, &ns, &.{}, extra, TestProvenance{}); + try std.testing.expectError(error.InvalidSummary, decodeForRequest(arena.allocator(), &dest, &ns, &.{}, bytes, TestProvenance{}, 2, true, null)); + const restored = try decodeForRequest(arena.allocator(), &dest, &ns, &.{}, bytes, TestProvenance{}, 1, false, null); + try std.testing.expectEqual(@as(usize, 1), restored.constraints.roots.len); +} + +test "persistent interface captured open relations replay independently like memory summaries" { + const gpa = std.testing.allocator; + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var store = Type.Store.init(gpa); + defer store.deinit(); + const graph = try Solve.InstGraph.create(gpa, &store, &ns); + defer graph.destroy(); + const variable = try graph.newNode(.{ .unresolved = Solve.InstVariable.checkedVariable(null, null) }); + const function = try graph.newNode(.{ .func = .{ .args = try graph.arena().dupe(Solve.NodeId, &.{variable}), .ret = variable } }); + const captured = try Constraints.capture(graph, graph.arena(), &.{function}); + const memory = try captured.instantiate(graph); + const bytes = try encode(gpa, &store, &ns, &.{}, .{ .constraints = captured }, TestProvenance{}); + defer gpa.free(bytes); + const restored = (try decode(graph.arena(), &store, &ns, &.{}, bytes, TestProvenance{})).constraints; + const first = try restored.instantiate(graph); + const second = try restored.instantiate(graph); + const original_identity = try captured.identity(graph); + const persistent_identity = try restored.identity(graph); + try std.testing.expectEqualSlices(u8, original_identity.bytes, persistent_identity.bytes); + const first_fn = try graph.functionNodes(first[0]); + const second_fn = try graph.functionNodes(second[0]); + const memory_fn = try graph.functionNodes(memory[0]); + try std.testing.expect(graph.sameClass(first_fn.args[0], first_fn.ret)); + try std.testing.expect(!graph.sameClass(first_fn.ret, second_fn.ret)); + try graph.unify(first_fn.ret, try graph.newNode(.{ .primitive = .u64 })); + try std.testing.expect((try graph.content(second_fn.ret)) == .unresolved); + try std.testing.expect((try graph.content(memory_fn.ret)) == .unresolved); + try std.testing.expect((try graph.content(variable)) == .unresolved); +} + +test "persistent interface equivalent input leaves use the first request position" { + const gpa = std.testing.allocator; + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var source = Type.Store.init(gpa); + defer source.deinit(); + const first = try source.add(.{ .primitive = .u64 }); + const second = try source.add(.{ .primitive = .u64 }); + var nodes: [1]Constraints.Node = undefined; + var stats: Stats = .{}; + const bytes = try encodeWithStats(gpa, &source, &ns, &.{ first, second }, typeSummary(second, &nodes), TestProvenance{}, &stats); + defer gpa.free(bytes); + try std.testing.expectEqual(@as(u64, 0), stats.types); + var dest = Type.Store.init(gpa); + defer dest.deinit(); + _ = try dest.add(.{ .primitive = .bool }); + const first_input = try dest.add(.{ .primitive = .u64 }); + const second_input = try dest.add(.{ .primitive = .u64 }); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const result = try decode(arena.allocator(), &dest, &ns, &.{ first_input, second_input }, bytes, TestProvenance{}); + try std.testing.expectEqual(first_input, result.constraints.nodes[0].mono); +} diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index e9cbee36db6..ba66088d598 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -6174,7 +6174,7 @@ const Builder = struct { try entry.request.copy(scratch.allocator(), relocation), relocation, ); - try self.recordPersistentInterface(.{ .address = entry.address, .evidence = entry.evidence, .request = try entry.request.copy(scratch.allocator(), relocation), .summary = summary }, &self.program.types, &self.program.names); + if (!entry.persisted) try self.recordPersistentInterface(.{ .address = entry.address, .evidence = entry.evidence, .request = try entry.request.copy(scratch.allocator(), relocation), .summary = summary }, &self.program.types, &self.program.names); } } @@ -6191,7 +6191,7 @@ const Builder = struct { const scope_context = scope.compile_time_root_identities.valueContext() orelse return null; const program_context = (try self.programValueContext(false)) orelse return null; var hash = std.crypto.hash.sha2.Sha256.init(.{}); - hash.update("roc.interface-provider.v1"); + hash.update("roc.interface-provider.v2"); hash.update(&(callee.code_generation_key orelse callee.key).bytes); hash.update(&scope_context); // Method lookup order is producer data too; a sorted module set does @@ -6290,11 +6290,13 @@ const Builder = struct { fn recordPersistentInterface(self: *Builder, entry: InterfaceSummaryEntry, types_: *Type.Store, ns: *const names.NameStore) Allocator.Error!void { const family = (try self.persistentInterfaceFamily(entry.address)) orelse return; + const evidence = (try self.persistentInterfaceEvidence(entry.evidence)) orelse return; const summary: PersistentInterface.Summary = switch (entry.summary) { .unchanged => .unchanged, .constraints => |c| .{ .constraints = c }, }; - const bytes = PersistentInterface.encode(self.allocator, types_, ns, entry.request.leaves, summary) catch |err| switch (err) { + var stats: PersistentInterface.Stats = .{}; + const bytes = PersistentInterface.encodeWithStats(self.allocator, types_, ns, entry.request.leaves, summary, BodyContext.PersistentProvenance{ .builder = self }, &stats) catch |err| switch (err) { error.NotRepresentable => { self.count("interface_persistent_unrepresentable"); return; @@ -6302,11 +6304,15 @@ const Builder = struct { error.OutOfMemory => return error.OutOfMemory, }; defer self.allocator.free(bytes); - const evidence = (try self.persistentInterfaceEvidence(entry.evidence)) orelse return; const key = PersistentInterface.requestKey(entry.request, evidence, family.procedure); const cache = self.interface_cache.?; try cache.record(cache.context, family.provider, key, bytes); self.count("interface_persistent_records"); + self.countBy("interface_persistent_bytes", @intCast(bytes.len)); + self.countBy("interface_persistent_types", @intCast(stats.types)); + self.countBy("interface_persistent_references", @intCast(stats.references)); + if (stats.composites != 0) self.count("interface_persistent_composite_records"); + if (stats.defaults != 0) self.count("interface_persistent_default_records"); } fn persistentInterfaceEvidence(self: *Builder, evidence: StoredConstFnEvidence) Allocator.Error!?[32]u8 { @@ -21832,6 +21838,8 @@ const InterfaceSummaryEntry = struct { evidence: StoredConstFnEvidence, request: InterfaceConstraints.Identity, summary: InterfaceSummary, + /// Already loaded from the persistent family; do not encode it again. + persisted: bool = false, }; /// A completed expansion's contribution to its request roots. An expansion @@ -21871,6 +21879,7 @@ const InterfaceSummary = union(enum) { const FoundInterfaceSummary = struct { summary: InterfaceSummary, domain: Domain, + persistent: bool = false, const Domain = enum { /// The finding body's own type and name stores. @@ -22057,6 +22066,7 @@ const InterfaceSummaryCache = struct { }, .request = try request.copy(arena, InterfaceSummaryCopy{}), .summary = try source.summary.copy(arena, summary_relocation), + .persisted = source.persisted, }; self.buckets.appendAssumeCapacity(source.address, @intCast(self.entries.items.len)); self.entries.appendAssumeCapacity(owned); @@ -27928,7 +27938,7 @@ const BodyContext = struct { return .{ .cache = &workspace.interface_summaries, .types_are_durable = false }; } - fn findInterfaceSummary(self: *BodyContext, address: InterfaceReplayAddress, evidence: StoredConstFnEvidence, request: InterfaceConstraints.Identity) Allocator.Error!?FoundInterfaceSummary { + fn findInterfaceSummary(self: *BodyContext, address: InterfaceReplayAddress, evidence: StoredConstFnEvidence, request: InterfaceConstraints.Identity, root_count: usize) Allocator.Error!?FoundInterfaceSummary { if (!self.builder.replay_interface_summaries.enabled()) return null; if (try self.findMemoryInterfaceSummary(address, evidence, request)) |found| return found; const family = (try self.builder.persistentInterfaceFamily(address)) orelse return null; @@ -27940,7 +27950,8 @@ const BodyContext = struct { return null; }; defer self.allocator.free(bytes); - const decoded = PersistentInterface.decode(self.graph.arena(), self.typeStore(), self.nameStoreMut(), request.leaves, bytes, PersistentProvenance{ .builder = self.builder }) catch |err| switch (err) { + var stats: PersistentInterface.Stats = .{}; + const decoded = PersistentInterface.decodeForRequest(self.graph.arena(), self.typeStore(), self.nameStoreMut(), request.leaves, bytes, PersistentProvenance{ .builder = self.builder }, root_count, address.kind != .method_signature, &stats) catch |err| switch (err) { error.InvalidSummary => { self.builder.count("interface_persistent_invalid"); return null; @@ -27952,13 +27963,40 @@ const BodyContext = struct { .constraints => |c| .{ .constraints = c }, }; self.builder.count("interface_persistent_hits"); + if (stats.composites != 0) self.builder.count("interface_persistent_composite_hits"); + if (stats.defaults != 0) self.builder.count("interface_persistent_default_hits"); // This cache owns the record independently of the graph arena. Worker // records cross to the coordinator only with their accepted shard. - return .{ .summary = try self.insertInterfaceSummary(.{ .address = address, .evidence = evidence, .request = request, .summary = summary }), .domain = .graph }; + return .{ .summary = try self.insertInterfaceSummary(.{ .address = address, .evidence = evidence, .request = request, .summary = summary, .persisted = true }), .domain = .graph, .persistent = true }; } const PersistentProvenance = struct { builder: *Builder, + pub fn defaultArtifact(self: PersistentProvenance, identity: [32]u8, expr: u32) ?[32]u8 { + const declaring = self.builder.moduleForIdentityHash(&identity) orelse return null; + _ = declaring.bodies.defaultExpr(expr) orelse return null; + return declaring.key.bytes; + } + pub fn validNamedDefinition(self: PersistentProvenance, ns: *const names.NameStore, named: Type.NamedType, def: Type.TypeDef, kind: Type.NamedKind, owner: ?static_dispatch.BuiltinOwner) bool { + if (def.site != self.builder.isAppModuleIdentity(ns.moduleIdentityBytes(def.module))) return false; + if (!self.validNamedType(named)) return false; + const occurrence = self.builder.moduleForKeyBytes(named.ty_module.bytes).?; + const payload = occurrence.types.payload(named.ty); + switch (payload) { + .alias => |alias| { + return kind == .alias and owner == null and std.mem.eql(u8, &alias.owner_module.bytes, &named.module.bytes) and + std.mem.eql(u8, occurrence.names.moduleIdentityBytes(alias.origin_module), ns.moduleIdentityBytes(def.module)) and + alias.source_decl == def.source_decl and std.mem.eql(u8, occurrence.names.typeNameText(alias.name), ns.typeNameText(def.type_name)); + }, + .nominal => |nominal| { + return kind == (if (nominal.is_opaque) Type.NamedKind.@"opaque" else .nominal) and owner == builtinOwner(nominal.builtin) and + std.mem.eql(u8, &nominal.owner_module.bytes, &named.module.bytes) and + std.mem.eql(u8, occurrence.names.moduleIdentityBytes(nominal.origin_module), ns.moduleIdentityBytes(def.module)) and + nominal.source_decl == def.source_decl and std.mem.eql(u8, occurrence.names.typeNameText(nominal.name), ns.typeNameText(def.type_name)); + }, + .pending, .err, .flex, .rigid, .record, .tuple, .function, .tag_union, .empty_record, .empty_tag_union => return false, + } + } pub fn validModuleIdentity(self: PersistentProvenance, identity: [32]u8) bool { return self.builder.moduleForIdentityHash(&identity) != null; } @@ -28038,7 +28076,7 @@ const BodyContext = struct { if (binding.types_are_durable) { // Coordinator leaves already belong to permanent program storage. const summary = try binding.cache.insert(self.typeStore(), self.nameStore(), entry); - try self.builder.recordPersistentInterface(.{ .address = entry.address, .evidence = entry.evidence, .request = entry.request, .summary = summary }, self.typeStore(), self.nameStore()); + if (!entry.persisted) try self.builder.recordPersistentInterface(.{ .address = entry.address, .evidence = entry.evidence, .request = entry.request, .summary = summary }, self.typeStore(), self.nameStore()); return summary; } // Retain only settled leaves; open cells remain explicit constraints. @@ -28297,14 +28335,14 @@ const BodyContext = struct { }, } } - if (cached == null and replay_state.use_finished_summaries) cached = try self.findInterfaceSummary(address, stored_evidence, request); + if (cached == null and replay_state.use_finished_summaries) cached = try self.findInterfaceSummary(address, stored_evidence, request, request_roots.items.len); var verify_summary: ?InterfaceSummary = null; const saved_use_summaries = replay_state.use_finished_summaries; expansion.saved_use_summaries = saved_use_summaries; expansion.restores_use_summaries = true; if (cached) |found| { self.builder.count("interface_summary_hits"); - if (std.debug.runtime_safety and self.builder.diagnostics != null and self.builder.interface_summary_checks < 16) { + if (!found.persistent and std.debug.runtime_safety and self.builder.diagnostics != null and self.builder.interface_summary_checks < 16) { self.builder.interface_summary_checks += 1; self.builder.count("interface_summary_verifications"); verify_summary = try self.graphDomainSummary(found); @@ -53286,9 +53324,9 @@ const BodyContext = struct { .input_digest = interfaceRequestBucket(request.bytes), }; var verify_summary: ?InterfaceSummary = null; - if (try self.findInterfaceSummary(address, evidence, request)) |found| { + if (try self.findInterfaceSummary(address, evidence, request, roots.items.len)) |found| { self.builder.count("interface_summary_hits"); - if (std.debug.runtime_safety and self.builder.diagnostics != null and self.builder.interface_summary_checks < 16) { + if (!found.persistent and std.debug.runtime_safety and self.builder.diagnostics != null and self.builder.interface_summary_checks < 16) { self.builder.interface_summary_checks += 1; self.builder.count("interface_summary_verifications"); verify_summary = try self.graphDomainSummary(found); @@ -53407,7 +53445,7 @@ const BodyContext = struct { }; const use_summaries = self.draft.interface_replay.use_finished_summaries; if (use_summaries) { - if (try self.findInterfaceSummary(address, evidence, request)) |found| { + if (try self.findInterfaceSummary(address, evidence, request, 1)) |found| { switch (found.summary) { .unchanged => {}, .constraints => |constraints| try self.graph.unify((try self.instantiateFoundConstraints(found.domain, constraints))[0], constraint_node), @@ -53467,7 +53505,7 @@ const BodyContext = struct { const evidence = StoredConstFnEvidence.empty(); const request: InterfaceConstraints.Identity = .{ .bytes = &.{}, .leaves = &.{} }; if (cacheable) { - if (try self.findInterfaceSummary(address, evidence, request)) |found| { + if (try self.findInterfaceSummary(address, evidence, request, 1)) |found| { return switch (found.summary) { .constraints => |constraints| (try self.instantiateFoundConstraints(found.domain, constraints))[0], .unchanged => Common.invariant("checked method signature snapshot had no root"), diff --git a/src/postcheck/monotype/specialize.zig b/src/postcheck/monotype/specialize.zig index 6aa45b7f3e3..dd3e5d50731 100644 --- a/src/postcheck/monotype/specialize.zig +++ b/src/postcheck/monotype/specialize.zig @@ -69,6 +69,14 @@ pub const Counters = struct { interface_persistent_misses: u64 = 0, interface_persistent_invalid: u64 = 0, interface_persistent_records: u64 = 0, + interface_persistent_bytes: u64 = 0, + interface_persistent_types: u64 = 0, + interface_persistent_references: u64 = 0, + interface_persistent_composite_records: u64 = 0, + interface_persistent_default_records: u64 = 0, + interface_persistent_composite_hits: u64 = 0, + interface_persistent_default_hits: u64 = 0, + interface_persistent_unrepresentable: u64 = 0, /// Summary hits whose expansion contributed no constraint beyond its input. diff --git a/src/postcheck/monotype/type.zig b/src/postcheck/monotype/type.zig index bb794df1e58..5464ab7da9d 100644 --- a/src/postcheck/monotype/type.zig +++ b/src/postcheck/monotype/type.zig @@ -2525,6 +2525,11 @@ pub const Store = struct { return self.computeDigest(name_store, ty, .representation, stats) catch digestOutOfMemory(); } + /// Fallible representation hashing for optional persistent data publication. + pub fn representationDigestFallible(self: *Store, name_store: *const names.NameStore, ty: TypeId) std.mem.Allocator.Error!names.TypeDigest { + return self.computeDigest(name_store, ty, .representation, null); + } + /// Exact structural equality for closed Monotype types. /// /// Aliases with backing compare as their backing, non-alias named types diff --git a/test/cli/interface_cache_bench.py b/test/cli/interface_cache_bench.py index 311a1a66b5f..453b9de41b5 100644 --- a/test/cli/interface_cache_bench.py +++ b/test/cli/interface_cache_bench.py @@ -77,7 +77,7 @@ def main(): (source / "main.roc").write_text(app) if phase == "corrupt": for record in cache.rglob("*.ris"): - record.write_bytes(b"RIFACE01truncated") + record.write_bytes(b"RIFACE02truncated") for output in cache.rglob("*.rpo"): output.unlink() exe = run / f"{phase}-app" diff --git a/test/cli/persistent_interface_cache/CacheTests.roc b/test/cli/persistent_interface_cache/CacheTests.roc new file mode 100644 index 00000000000..94ef2bb6edd --- /dev/null +++ b/test/cli/persistent_interface_cache/CacheTests.roc @@ -0,0 +1,13 @@ +CacheTests := [].{} + +import Generic +import Defaults + +expect { + dbg "persistent test observation" + Generic.f7(2.U64) == 10 +} + +unused = |_| crash "unused failing interface root" + +expect Defaults.parses_defaults({}) diff --git a/test/cli/persistent_interface_cache/Defaults.roc b/test/cli/persistent_interface_cache/Defaults.roc new file mode 100644 index 00000000000..642a56594e7 --- /dev/null +++ b/test/cli/persistent_interface_cache/Defaults.roc @@ -0,0 +1,18 @@ +Defaults := { count : U64 ?? 0 }.{ + parser_for : _ + is_eq : _ + make = |_| { value: 0.U64, base: Defaults.{} } + + parses_defaults = |_| { + first : Try(Defaults, [InvalidJson(Str)]) + first = Json.parse("{}") + second : Try(Other, [InvalidJson(Str)]) + second = Json.parse("{}") + first == Ok(Defaults.{ count: 0 }) and second == Ok(Other.{ count: 3 }) + } +} + +Other := { count : U64 ?? 3 }.{ + parser_for : _ + is_eq : _ +} diff --git a/test/cli/persistent_interface_cache/Generic.roc b/test/cli/persistent_interface_cache/Generic.roc index 0f1418f1aa5..df9848533a1 100644 --- a/test/cli/persistent_interface_cache/Generic.roc +++ b/test/cli/persistent_interface_cache/Generic.roc @@ -1,5 +1,12 @@ +import Defaults + Generic := [].{ - f0 = |x| x + 1 + fresh = |factory| factory.make() + composite = |_| fresh(Defaults.{}) + f0 = |x| { + wrapped = fresh(Defaults.{}) + x + wrapped.value + wrapped.base.count + 1 + } f1 = |x| f0(x) + 1 f2 = |x| f1(x) + 1 f3 = |x| f2(x) + 1 diff --git a/test/cli/persistent_interface_cache/main.roc b/test/cli/persistent_interface_cache/main.roc index 38a9f7f3055..8ec3e807235 100644 --- a/test/cli/persistent_interface_cache/main.roc +++ b/test/cli/persistent_interface_cache/main.roc @@ -5,14 +5,14 @@ import Generic offset : U64 offset = { dbg "persistent interface observation" - Generic.f7(2) + Generic.f7(2) + Generic.composite({}).value } unused : U64 unused = crash "unused failing interface root" main! = |args| { - total = Generic.f7(args.len().to_u64() + 10) + offset + total = Generic.f7(args.len().to_u64() + 10) + offset + Generic.composite({}).value Stdout.line!(total.to_str()) Ok({}) } From 64fb973c8dc0802cebd8ebd6e361908f60b4ff8a Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 16:50:06 -0400 Subject: [PATCH 33/97] Reuse owned native payloads and stable dependency components --- design.md | 131 +++- src/backend/dev/LirCodeGen.zig | 9 + src/backend/dev/PackFile.zig | 31 +- src/backend/dev/ProcArtifact.zig | 188 ++++- src/base/mod.zig | 1 + src/base/retained_cache.zig | 146 ++++ src/cli/main.zig | 62 +- src/cli/pack_index.zig | 447 +++++++++--- src/cli/pack_store.zig | 1075 ++++++++++++++++++++++------ src/compile/cache_cleanup.zig | 62 +- src/lir/pack_program.zig | 183 +++-- test/cli/pack_index_bench.py | 13 +- test/cli/pack_publication_bench.py | 126 ++++ 13 files changed, 1988 insertions(+), 486 deletions(-) create mode 100644 src/base/retained_cache.zig create mode 100644 test/cli/pack_publication_bench.py diff --git a/design.md b/design.md index ad4b66bee42..178be32a3c4 100644 --- a/design.md +++ b/design.md @@ -764,6 +764,38 @@ expose partial records or change correctness. No historical providers are scanne `ROC_DISABLE_PERSISTENT_INTERFACES` is a developer-diagnostic-only comparison switch, read solely through `base.dev_diagnostics`. +### Native publication analysis ownership + +Offer collection owns one analysis of the frozen LIR store for each program. +It records direct call edges, literal rejection sites and copied site code in +one statement walk per procedure. Linked-entry reachability propagates over +all direct call edges; copied site code propagates only across the existing +unkeyed-procedure boundary. Worklists mark each procedure at most once per +property, including cycles. These facts expire with that LIR graph; they are +never reused across a mutation or inferred from native instructions. + +Native offer admission similarly propagates program-local and literal-conversion +restrictions over the exact splice graph once. Publication gathers the union +of selected roots in a single traversal. Neither boolean query materializes a +transitive closure per candidate. Explicit closures needed by persisted records +are accounted separately from eligibility analysis. + +Native payload provenance is an owned reference to validated immutable record +bytes, not a remembered digest. Acquisitions retain a shared file lock, and +cache cleanup takes an exclusive lock before removing these records. A +compilation and its captured artifact sets share the acquisition owner; the +last owner releases its locks and bytes. A retained record can be referenced +without another read only inside the same procedure namespace. Optional +storage failures remain publication failures or lookup misses. + +Splicing records the immutable payload on the assembled code region. Extraction +of that region consumes the producer's canonical code and ordered carried data; +placement patches only the separate relocation representation. Source-file and +set-local reference rebinding change metadata, not the payload. Newly generated +regions have no persisted identity. Deep copies and canonical assembly retain +the acquisition owner explicitly. This contract never identifies code by +inspecting instructions or equating paths, lengths or timestamps. + ### Object Cache Store The store has two namespaces under `// @@ -786,38 +818,63 @@ same provider capability (`CodeProvision`). Within the procedure namespace, dev offers live under `/offers// --.rpi`. The `ROFFER02` binary record names a -shared metadata digest, a specialization ordinal, and the explicit origin and -module identity owning its storage. Provider visibility and rank are supplied -by the checked program, root first. A lookup probes one key bucket per origin, -filters candidates against that provider map, then sorts by provider rank and -content digest. It never probes every visible module or enumerates unrelated -specialization keys. Concurrent writers publish independent immutable records, -so they cannot overwrite another writer's additions. - -One `ROFMETA1` metadata record per producer holds the union of its offered -closures: one payload digest per artifact and a payload-free `PackFile` with -all specialization entries. It is stored at `//metadata/ -.rpm`. Source/debug locations, explicit local and symbolic references, -relocations, failure meanings, compile-time reads and ARC interfaces are -serialized once for all of that producer's offers. The record is read and -decoded once per loader, and shared across hits. Metadata granularity is a -producer's offered union, not a single artifact: selecting an offer can read -metadata for other offers in that producer, but does not read their code/data -or any unrelated producer history. Different producer records can still carry -overlapping descriptions. Cycles remain explicit set-local references; there -is no recursive hash scheme that attempts to infer graph identity. - -Code/data payloads remain independently addressed `RPAYLD01` records at -`//payload/.rpa`. Each record contains length-prefixed -code and carried data slices. Publication deduplicates payload digests within -the producer. Before replacing an existing immutable record, the writer reads -and compares it with the known producer bytes: verified equal bytes need no -write; corrupt or missing records are repaired through staging and atomic -rename. This adds an unsuccessful read on an empty cache, and a verification -read on a warm cache, in exchange for avoiding repeated writes. It never scans -historical records. Payloads and shared metadata publish before their offers; -the `ROCINDEX01` completion marker publishes last. +-.rpi`. The `ROFFER03` binary selection +record names an immutable component digest and its root's component-local +ordinal, exact procedure identity, specialization key, compile-time reads and +ARC interface. Provider visibility and rank are supplied by the checked +program, root first. A lookup probes one key bucket per origin, filters +candidates against that provider map, then sorts by provider rank and content +digest. It never enumerates unrelated specialization keys. Concurrent writers +publish independent immutable records. + +The exact native splice graph is partitioned into strongly connected +components once per publication. Each `ROFCOMP1` metadata record holds one +component's payload digests and payload-free `PackFile` metadata. Internal +edges name component-local ordinals; external edges name a dependency's +content digest and ordinal. Dependencies publish first. No recursive hashing +is needed for cycles, and shared dependency metadata is emitted once. An +unrelated offer's insertion, removal or ordering cannot change a component. +Acquired components retain their producer's member order; new components use +the current assembly's explicit order. Reuse checks each member's current +metadata and direct dependency endpoints against its owned producer record. +Payload identities are never retained across payload mutation. + +A loader appends each acquired component once to its coordinator-owned native +graph. Existing indices and dependency bindings remain immutable; explicit +symbolic bindings prevent historical definitions from capturing each other's +references. Only the coordinator extends this graph, before assembly consumes +it. Source eligibility and linked-entry facts extend over the appended suffix, +using settled dependency facts. Decoded component owners and validated record +locks outlive every captured set that refers to their provenance. + +Payloads and metadata share content-addressed storage at +`local//{payload,metadata}/.{rpa,rpm}` within the +procedure domain. They are independent of the producing module and obey the +local native-cache retention window. Offers still route to their explicit +local or package provider, preserving provider precedence. `RPAYLD01` holds +length-prefixed canonical code and ordered carried data. A newly generated +payload is encoded and hashed once in a publication. A retained immutable +payload is referenced directly, without encoding, hashing or rereading it; +publication to another namespace uses its validated bytes and establishes +availability there independently. + +For an unchanged offer, publication checks the already admitted spec's exact +ARC/read interface, provider and component reference, then references its owned +offer without encoding or rereading it. New content retains the existing +compare-and-repair atomic rename protocol, including corruption handling. +Payloads become available before metadata, metadata before offers, and the +`ROCINDEX01` producer completion marker comes last. It records successful +publication, not immunity from later optional-cache eviction. Held generations +cannot be evicted by cleanup. Mapping locks cover only open/lock/remove/rename; +encoding and hashing never run under a global lock. + +Publication counters distinguish changed/reused offers, encoded payloads and +bytes, hashed bytes, encoded metadata, validation reads, published records and +bytes, reused validated references, graph nodes/edges, closure queries and peak +encoded scratch bytes. Acquisition remains separately accounted by loader +counters. This branch's path-based object store has no catalog transactions; +the separate shared artifact catalog work must preserve these ownership and +publication-order contracts when integrating this path. `LoadedPacks` memoizes misses under the specialization key, code-provision policy, and exact program-module digest. Its index snapshot lasts until @@ -864,8 +921,8 @@ namespaces without a migration scan. Optimized object packs live in the exact-target namespace: a package artifact's `.rpk` and the program's `optimized/.rpk`. Explicit pack export/import for developer diagnostics still uses complete `PackFile` -records. The background sweep deletes obsolete `objects-v*` namespaces and -recursively ages each current namespace's `local/` files and recorded +records. The background sweep removes unpinned records from obsolete +`objects-v*` namespaces, retaining stable lock files, and recursively ages each current namespace's `local/` files and recorded `programs/` outputs on one day, and `pkg/` and `optimized/` files on thirty, using `max(atime, mtime)`. Size eviction also includes indexed metadata and payloads. Either may be @@ -882,6 +939,12 @@ also reports metadata/payload bytes and elapsed wall-clock nanoseconds. print none of these diagnostics. Performance comparisons use a ReleaseFast compiler with developer diagnostics enabled, `roc build --opt=dev`, wall time and peak RSS; cold publication is measured separately from lookup. +`test/cli/pack_publication_bench.py` measures build and test publication +independently on many callers of shared code, bypassing only the temporary +fixture's completed-command records. It reports cold, unchanged and one-caller +edit costs, including acquisition and publication bytes and peak process RSS. +Runtime tests that publish no offers are reported separately and provide no +evidence of publication improvement. A pack entry's code is a function of the procedure alone: every reference from it into code placed with it, and every reference by identity to code diff --git a/src/backend/dev/LirCodeGen.zig b/src/backend/dev/LirCodeGen.zig index cd27f9f852a..bedcc68e6c0 100644 --- a/src/backend/dev/LirCodeGen.zig +++ b/src/backend/dev/LirCodeGen.zig @@ -1056,6 +1056,7 @@ pub fn LirCodeGen(comptime target: RocTarget) type { /// One contiguous range of the code buffer with a known producer. pub const CodeRegion = struct { + payload: ?@import("ProcArtifact.zig").AssembledPayload = null, start: usize, end: usize, /// Offset from `start` that references to this region resolve to. @@ -23022,6 +23023,14 @@ pub fn LirCodeGen(comptime target: RocTarget) type { } /// Every range of the code buffer with its producer, in emission order. + /// Assembly copies canonical bytes and changes only relocation sites. + /// The source set owns this borrow until extraction retains its owner. + pub fn retainAssembledPayload(self: *Self, start: usize, payload: @import("ProcArtifact.zig").AssembledPayload) void { + const region = &self.code_regions.items[self.code_regions.items.len - 1]; + std.debug.assert(region.start == start and region.end - region.start == payload.code.len); + region.payload = payload; + } + pub fn codeRegions(self: *const Self) []const CodeRegion { return self.code_regions.items; } diff --git a/src/backend/dev/PackFile.zig b/src/backend/dev/PackFile.zig index a1e60fc75ff..12bdce67058 100644 --- a/src/backend/dev/PackFile.zig +++ b/src/backend/dev/PackFile.zig @@ -23,7 +23,7 @@ const Allocator = std.mem.Allocator; const magic = "RPCK"; /// Format version; bump whenever the encoding or artifact contents change. -pub const format_version: u32 = 15; +pub const format_version: u32 = 16; /// One specialization the pack can serve: its reservation-time key, the /// artifact holding its procedure, and the ownership signature and @@ -155,6 +155,14 @@ pub fn writeWithNames(allocator: Allocator, set: *const ProcArtifact.Set, specs: .inline_call => 2, }); try writer.word(ref.veneer orelse std.math.maxInt(u32)); + switch (ref.binding) { + .unresolved => try writer.byte(0), + .absent => try writer.byte(1), + .local => |index| { + try writer.byte(2); + try writer.word(index); + }, + } switch (ref.target) { .proc => |identity| { try writer.byte(0); @@ -366,6 +374,16 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) const value = try reader.word(); break :veneer if (value == std.math.maxInt(u32)) null else value; }, + .binding = switch (try reader.byte()) { + 0 => .unresolved, + 1 => .absent, + 2 => binding: { + const index = try reader.word(); + if (index >= artifact_count) return error.MalformedPack; + break :binding .{ .local = index }; + }, + else => return error.MalformedPack, + }, .target = switch (try reader.byte()) { 0 => .{ .proc = .{ .bytes = (try reader.raw(32))[0..32].* } }, 1 => .{ .rc_helper = try reader.str() }, @@ -497,6 +515,17 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) } } + for (artifacts) |artifact| for (artifact.symbolic_refs) |ref| { + if (ref.binding != .local) continue; + const kind = artifacts[ref.binding.local].kind; + const matches = switch (ref.target) { + .proc => |identity| kind == .proc and std.meta.eql(identity, kind.proc), + .boxy_thunk => |identity| kind == .boxy_thunk and std.meta.eql(identity, kind.boxy_thunk), + .rc_helper => |name| kind == .rc_helper and std.mem.eql(u8, name, kind.rc_helper), + }; + if (!matches) return error.MalformedPack; + }; + const specs = try readSpecs(&reader, arena_allocator, spec_count, artifact_count); if (reader.offset != bytes.len) return error.MalformedPack; diff --git a/src/backend/dev/ProcArtifact.zig b/src/backend/dev/ProcArtifact.zig index f0dca817214..fdf0218b6cc 100644 --- a/src/backend/dev/ProcArtifact.zig +++ b/src/backend/dev/ProcArtifact.zig @@ -50,6 +50,9 @@ fn codeRefForm(form: Form) LirCodeGenMod.SymbolicRefForm { /// A producer-identified target whose code need not have been emitted yet. pub const SymbolicReference = struct { + /// A cache component binds its own optional definition explicitly. Appended + /// components may contain other versions of the same site-named procedure. + binding: union(enum) { unresolved, absent, local: u32 } = .unresolved, site: u32, form: Form, /// A producer-reserved in-body veneer, still repatched by final placement. @@ -160,6 +163,10 @@ pub const content_data_prefix = lir.Program.content_data_symbol_prefix; /// One lifted region of machine code. pub const Artifact = struct { + /// Producer-validated canonical payload. Relocations and dependency bindings + /// remain separate metadata; a byte-changing producer must clear this. + payload_record: ?*const base.retained_cache.Record = null, + metadata_origin: ?*const MetadataOrigin = null, kind: Kind, code: []const u8, /// Offset within `code` that references to this artifact resolve to. @@ -180,6 +187,28 @@ pub const Artifact = struct { loc: base.SourceLoc = .none, }; +/// An artifact's immutable dependency-component identity and producer metadata. +/// The record owner also owns source and the dependency arrays. +pub const MetadataOrigin = struct { + record: *const base.retained_cache.Record, + source: *const Set, + index: u32, + refs: []const Binding, + symbolic: []const ?Binding, +}; + +/// Exact content-addressed dependency endpoint, including its component ordinal. +pub const Binding = struct { component: [32]u8, index: u32 }; + +/// Canonical bytes retained across relocation-only assembly. Data metadata may +/// have content names rebound; its ordered raw bytes retain the payload identity. +pub const AssembledPayload = struct { + record: *const base.retained_cache.Record, + metadata_origin: ?*const MetadataOrigin, + code: []const u8, + data: []const DataItem, +}; + /// An ordered set of artifacts covering one code buffer. Order is emission /// order, which `assemble` reproduces. pub const Set = struct { @@ -188,11 +217,39 @@ pub const Set = struct { /// The module content identity of each source-file index the artifacts' /// lines use (`LirStore.sourceFileModuleIdentity`). source_files: []const [32]u8 = &.{}, + record_owners: []const *base.retained_cache.Owner = &.{}, + + /// Allocate the owner list before transferring the arena to a result. + /// No retain happens until the last fallible allocation has succeeded. + fn retainRecordOwners(a: Allocator, artifacts: []const Artifact) Allocator.Error![]const *base.retained_cache.Owner { + var owners = std.AutoHashMap(*base.retained_cache.Owner, void).init(a); + defer owners.deinit(); + for (artifacts) |artifact| { + if (artifact.payload_record) |record| try owners.put(record.owner, {}); + if (artifact.metadata_origin) |origin| try owners.put(origin.record.owner, {}); + } + const retained = try a.alloc(*base.retained_cache.Owner, owners.count()); + var it = owners.keyIterator(); + var i: usize = 0; + while (it.next()) |owner| : (i += 1) retained[i] = owner.*; + for (retained) |owner| owner.retain(); + return retained; + } pub fn deinit(self: *Set) void { + for (self.record_owners) |owner| owner.release(); self.arena.deinit(); } + /// Resolve within the producer's explicit binding domain. + pub fn symbolicDefinition(self: *const Set, ref: SymbolicReference) ?u32 { + return switch (ref.binding) { + .unresolved => self.definitionOf(ref.target), + .absent => null, + .local => |index| index, + }; + } + /// The index of the artifact defining `target`, a symbolic reference's /// target, or null when this set does not define it. pub fn definitionOf(self: *const Set, target: @FieldType(SymbolicReference, "target")) ?u32 { @@ -357,7 +414,8 @@ pub fn combine(allocator: Allocator, sets: []const *const Set) Allocator.Error!S } const owned = try artifacts.toOwnedSlice(a); const owned_source_files = try source_files.toOwnedSlice(a); - return .{ .arena = arena, .artifacts = owned, .source_files = owned_source_files }; + const retained = try Set.retainRecordOwners(arena.allocator(), owned); + return .{ .arena = arena, .artifacts = owned, .source_files = owned_source_files, .record_owners = retained }; } /// Deep-copy sets compiled from programs of one Solved program, which name a @@ -460,13 +518,17 @@ pub fn combineDistinct(allocator: Allocator, sets: []const *const Set) Allocator try copyArtifacts(a, &artifacts, &.{ .arena = undefined, .artifacts = &.{source} }, null); const refs: []Reference = @constCast(artifacts.items[first].refs); for (refs, source.refs) |*ref, original| ref.target = map[original.target]; + for (@constCast(artifacts.items[first].symbolic_refs), source.symbolic_refs) |*ref, original| if (original.binding == .local) { + ref.binding = .{ .local = map[original.binding.local] }; + }; try nameDataByContent(a, &artifacts.items[first], &names); } } // Both slices come from the arena before it is copied into the set. const owned_artifacts = try artifacts.toOwnedSlice(a); const owned_source_files = try a.dupe([32]u8, source_files); - return .{ .arena = arena, .artifacts = owned_artifacts, .source_files = owned_source_files }; + const retained = try Set.retainRecordOwners(arena.allocator(), owned_artifacts); + return .{ .arena = arena, .artifacts = owned_artifacts, .source_files = owned_source_files, .record_owners = retained }; } /// Replace every program-local constant name `artifact` carries or refers @@ -499,7 +561,8 @@ fn copySet(allocator: Allocator, set: *const Set) Allocator.Error!Set { var artifacts = std.ArrayList(Artifact).empty; try copyArtifacts(a, &artifacts, set, null); const owned = try artifacts.toOwnedSlice(a); - return .{ .arena = arena, .artifacts = owned }; + const retained = try Set.retainRecordOwners(arena.allocator(), owned); + return .{ .arena = arena, .artifacts = owned, .record_owners = retained }; } /// Append deep copies of `set`'s artifacts, rebasing set-local references @@ -517,6 +580,9 @@ fn copyArtifacts(a: Allocator, artifacts: *std.ArrayList(Artifact), set: *const for (refs) |*ref| ref.target += first; artifact.refs = refs; const symbolic_refs = try a.dupe(SymbolicReference, source.symbolic_refs); + for (symbolic_refs) |*ref| if (ref.binding == .local) { + ref.binding.local += first; + }; for (symbolic_refs) |*ref| switch (ref.target) { .rc_helper => |name| ref.target = .{ .rc_helper = try a.dupe(u8, name) }, .proc, .boxy_thunk => {}, @@ -1105,7 +1171,12 @@ pub fn extractPrepared( // Every data item the region names, then every item those name, so // an artifact carries the whole constant graph it points into. - const region_data = try captureData(allocator, arena_allocator, region_relocations.items, prepared); + const retained_payload = region.payload; + const region_data = if (retained_payload) |payload| data: { + const items = try arena_allocator.alloc(DataItem, payload.data.len); + for (payload.data, items) |item, *out| out.* = try cloneData(arena_allocator, item); + break :data items; + } else try captureData(allocator, arena_allocator, region_relocations.items, prepared); // Lines and failure sites are in code offset order, so each region's // are one contiguous run found by search. A failing call's return @@ -1122,11 +1193,16 @@ pub fn extractPrepared( if (site.offset > region.end) break; try failure_sites.append(arena_allocator, .{ .offset = @intCast(site.offset - region.start), .meaning = site.meaning }); } - const artifact_code = try arena_allocator.dupe(u8, code[region.start..region.end]); - codegen.normalizeArtifactCode(region.start, artifact_code); - for (region_refs.items) |ref| CG.normalizeArtifactRefSite(artifact_code, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); - for (symbolic_refs.items) |ref| CG.normalizeArtifactRefSite(artifact_code, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); + const artifact_code = if (retained_payload) |payload| try arena_allocator.dupe(u8, payload.code) else code_bytes: { + const bytes = try arena_allocator.dupe(u8, code[region.start..region.end]); + codegen.normalizeArtifactCode(region.start, bytes); + for (region_refs.items) |ref| CG.normalizeArtifactRefSite(bytes, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); + for (symbolic_refs.items) |ref| CG.normalizeArtifactRefSite(bytes, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); + break :code_bytes bytes; + }; artifacts[index] = .{ + .payload_record = if (retained_payload) |payload| payload.record else null, + .metadata_origin = if (retained_payload) |payload| payload.metadata_origin else null, .kind = kind, .code = artifact_code, .entry = @intCast(region.entry), @@ -1148,7 +1224,8 @@ pub fn extractPrepared( }; } - return .{ .arena = arena, .artifacts = artifacts }; + const retained = try Set.retainRecordOwners(arena.allocator(), artifacts); + return .{ .arena = arena, .artifacts = artifacts, .record_owners = retained }; } fn refSite(ref: anytype) ?usize { @@ -1469,10 +1546,14 @@ pub fn retainReachable(allocator: Allocator, set: *const Set, catalog: *const Pr const artifact = set.artifacts[index]; for (artifact.refs) |ref| try artifact_work.append(allocator, ref.target); for (artifact.symbolic_refs) |ref| { - const target = switch (ref.target) { - .proc => |identity| by_proc.get(identity), - .boxy_thunk => |identity| by_thunk.get(identity), - .rc_helper => |name| by_name.get(name), + const target = switch (ref.binding) { + .local => |target_index| @as(?u32, target_index), + .absent => null, + .unresolved => switch (ref.target) { + .proc => |identity| by_proc.get(identity), + .boxy_thunk => |identity| by_thunk.get(identity), + .rc_helper => |name| by_name.get(name), + }, }; if (target) |target_index| try artifact_work.append(allocator, target_index); } @@ -1514,6 +1595,9 @@ pub fn retainReachable(allocator: Allocator, set: *const Set, catalog: *const Pr for (refs) |*ref| ref.target = new_index[ref.target]; artifact.refs = refs; const symbolic_refs = try a.dupe(SymbolicReference, source.symbolic_refs); + for (symbolic_refs) |*ref| if (ref.binding == .local) { + ref.binding.local = new_index[ref.binding.local]; + }; for (symbolic_refs) |*ref| switch (ref.target) { .rc_helper => |name| ref.target = .{ .rc_helper = try a.dupe(u8, name) }, .proc, .boxy_thunk => {}, @@ -1534,7 +1618,8 @@ pub fn retainReachable(allocator: Allocator, set: *const Set, catalog: *const Pr // Allocated before the arena is copied into the result, which must own // every node. const source_files = try a.dupe([32]u8, set.source_files); - return .{ .arena = arena, .artifacts = artifacts, .source_files = source_files }; + const retained = try Set.retainRecordOwners(arena.allocator(), artifacts); + return .{ .arena = arena, .artifacts = artifacts, .source_files = source_files, .record_owners = retained }; } /// A program's machine code in its one layout: every artifact of the code @@ -1658,6 +1743,9 @@ pub fn canonicalize(allocator: Allocator, set: *const Set, host_visible: []const for (refs) |*ref| ref.target = position[ref.target]; out.refs = refs; const symbolic_refs = try a.dupe(SymbolicReference, source.symbolic_refs); + for (symbolic_refs) |*ref| if (ref.binding == .local) { + ref.binding.local = position[ref.binding.local]; + }; for (symbolic_refs) |*ref| switch (ref.target) { .rc_helper => |name| ref.target = .{ .rc_helper = try a.dupe(u8, name) }, .proc, .boxy_thunk => {}, @@ -1698,10 +1786,8 @@ pub fn canonicalize(allocator: Allocator, set: *const Set, host_visible: []const }.lessThan); const source_files = try a.dupe([32]u8, set.source_files); - return .{ - .set = .{ .arena = arena, .artifacts = artifacts, .source_files = source_files }, - .data = data[0..data_len], - }; + const retained = try Set.retainRecordOwners(arena.allocator(), artifacts); + return .{ .set = .{ .arena = arena, .artifacts = artifacts, .source_files = source_files, .record_owners = retained }, .data = data[0..data_len] }; } fn renamedData(a: Allocator, item: DataItem, names: *const ContentNames) Allocator.Error!DataItem { @@ -1940,6 +2026,7 @@ fn appendRuntimeRefs(comptime CG: type, codegen: *CG, artifact: Artifact, start: } fn appendMetadata(comptime CG: type, codegen: *CG, artifact: Artifact, start: usize, lines: LineBinding) Allocator.Error!void { + if (artifact.payload_record) |record| codegen.retainAssembledPayload(start, .{ .record = record, .metadata_origin = artifact.metadata_origin, .code = artifact.code, .data = artifact.data }); switch (artifact.kind) { .proc => |identity| { try codegen.registerAssembledProc(identity, start + artifact.entry); @@ -2139,45 +2226,69 @@ pub const ProcSymbols = enum { /// through a stable reference to an artifact of the set (to a procedure /// only as `proc_symbols` says), transitively. pub fn markReferrers(allocator: Allocator, set: *const Set, marked: []bool, proc_symbols: ProcSymbols) Allocator.Error!void { - std.debug.assert(marked.len == set.artifacts.len); + return markNewReferrers(allocator, set, marked, 0, proc_symbols); +} + +/// Extend facts over an append-only graph. Old nodes have no edges into the +/// appended suffix; their already settled facts and bindings remain immutable. +/// Loaded components carry explicit bindings, avoiding a scan of older nodes. +pub fn markNewReferrers(allocator: Allocator, set: *const Set, marked: []bool, first: usize, proc_symbols: ProcSymbols) Allocator.Error!void { + std.debug.assert(marked.len == set.artifacts.len and first <= marked.len); + if (first == marked.len) return; var by_proc = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator); defer by_proc.deinit(); var by_thunk = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator); defer by_thunk.deinit(); var by_helper = std.StringHashMap(u32).init(allocator); defer by_helper.deinit(); - for (set.artifacts, 0..) |artifact, index| switch (artifact.kind) { - .proc => |identity| _ = try by_proc.getOrPutValue(identity, @intCast(index)), - .boxy_thunk => |identity| _ = try by_thunk.getOrPutValue(identity, @intCast(index)), - .rc_helper => |name| _ = try by_helper.getOrPutValue(name, @intCast(index)), - .entrypoint, .message_pool_run, .branch_island => {}, + const unresolved = blk: { + for (set.artifacts[first..]) |artifact| for (artifact.symbolic_refs) |ref| { + if (ref.binding == .unresolved) break :blk true; + }; + break :blk false; }; - // Reverse edges: who places whom. - var referrers = try allocator.alloc(std.ArrayList(u32), set.artifacts.len); + if (unresolved) { + std.debug.assert(first == 0); + for (set.artifacts, 0..) |artifact, index| switch (artifact.kind) { + .proc => |identity| _ = try by_proc.getOrPutValue(identity, @intCast(index)), + .boxy_thunk => |identity| _ = try by_thunk.getOrPutValue(identity, @intCast(index)), + .rc_helper => |name| _ = try by_helper.getOrPutValue(name, @intCast(index)), + .entrypoint, .message_pool_run, .branch_island => {}, + }; + } + var referrers = try allocator.alloc(std.ArrayList(u32), marked.len - first); defer { for (referrers) |*list| list.deinit(allocator); allocator.free(referrers); } for (referrers) |*list| list.* = .empty; - for (set.artifacts, 0..) |artifact, index| { - for (artifact.refs) |ref| try referrers[ref.target].append(allocator, @intCast(index)); + for (set.artifacts[first..], first..) |artifact, index| { + for (artifact.refs) |ref| { + if (ref.target < first) { + marked[index] = marked[index] or marked[ref.target]; + } else try referrers[ref.target - first].append(allocator, @intCast(index)); + } for (artifact.symbolic_refs) |ref| { - const target = switch (ref.target) { - .proc => |identity| switch (proc_symbols) { - .follow => by_proc.get(identity), - .skip => null, + if (proc_symbols == .skip and ref.target == .proc) continue; + const target = switch (ref.binding) { + .local => |target| @as(?u32, target), + .absent => null, + .unresolved => switch (ref.target) { + .proc => |identity| by_proc.get(identity), + .boxy_thunk => |identity| by_thunk.get(identity), + .rc_helper => |name| by_helper.get(name), }, - .boxy_thunk => |identity| by_thunk.get(identity), - .rc_helper => |name| by_helper.get(name), } orelse continue; - try referrers[target].append(allocator, @intCast(index)); + if (target < first) { + marked[index] = marked[index] or marked[target]; + } else try referrers[target - first].append(allocator, @intCast(index)); } } var work = std.ArrayList(u32).empty; defer work.deinit(allocator); - for (marked, 0..) |is_marked, index| if (is_marked) try work.append(allocator, @intCast(index)); + for (marked[first..], first..) |is_marked, index| if (is_marked) try work.append(allocator, @intCast(index)); while (work.pop()) |index| { - for (referrers[index].items) |referrer| { + for (referrers[index - first].items) |referrer| { if (marked[referrer]) continue; marked[referrer] = true; try work.append(allocator, referrer); @@ -2303,7 +2414,7 @@ pub fn splice( .proc => |identity| if (procs_by_identity.contains(identity)) continue, .boxy_thunk, .rc_helper => {}, } - if (set.definitionOf(ref.target)) |definition| try stack.append(allocator, definition); + if (set.symbolicDefinition(ref)) |definition| try stack.append(allocator, definition); } } @@ -2499,6 +2610,7 @@ test "artifact metadata queues unresolved targets, preserves same-offset line or self.proc_entry = entry; } pub fn registerAssembledProcLoc(_: *Self, _: lir.ProcIdentity, _: base.SourceLoc) Allocator.Error!void {} + pub fn retainAssembledPayload(_: *Self, _: usize, _: AssembledPayload) void {} pub fn registerAssembledThunk(_: *Self, _: lir.ProcIdentity, _: usize) Allocator.Error!void {} pub fn registerSplicedHelper(_: *Self, _: []const u8, _: usize) Allocator.Error!void {} pub fn queueAssembledSymbolicRef(self: *Self, ref: SymbolicReference) Allocator.Error!void { diff --git a/src/base/mod.zig b/src/base/mod.zig index 4bad242b632..32d3b230555 100644 --- a/src/base/mod.zig +++ b/src/base/mod.zig @@ -27,6 +27,7 @@ comptime { pub const RegionInfo = @import("RegionInfo.zig"); pub const SourceLoc = @import("source_loc.zig").SourceLoc; pub const SourceFileEntry = @import("source_loc.zig").SourceFileEntry; +pub const retained_cache = @import("retained_cache.zig"); pub const Scratch = @import("Scratch.zig").Scratch; pub const parallel = @import("parallel.zig"); pub const post_check_task_executor = @import("post_check_task_executor.zig"); diff --git a/src/base/retained_cache.zig b/src/base/retained_cache.zig new file mode 100644 index 00000000000..a2afc330aef --- /dev/null +++ b/src/base/retained_cache.zig @@ -0,0 +1,146 @@ +//! Owned, validated immutable cache records. This is an acquisition lifetime, +//! not a storage namespace: records stay at their existing cache paths. +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// The persisted format boundary validated by the owning native loader. +pub const Kind = enum { native_payload_v1, native_component_v1, native_offer_v3 }; + +/// Validation work and retained storage, separate from publication work. +pub const Metrics = struct { + acquisitions: u64 = 0, + bytes_read: u64 = 0, + bytes_hashed: u64 = 0, + retained_bytes: u64 = 0, + retained_records: u64 = 0, +}; + +/// A compilation's acquisitions, also retained by captured native artifact sets. +pub const Owner = struct { + allocator: Allocator, + io: std.Io, + domain: [32]u8, + metrics: Metrics = .{}, + references: std.atomic.Value(usize) = .init(1), + records: std.ArrayList(*Record) = .empty, + resources: std.ArrayList(Resource) = .empty, + pub const Resource = struct { context: *anyopaque, deinit: *const fn (*anyopaque) void }; + + pub fn adopt(self: *Owner, resource: Resource) Allocator.Error!void { + try self.resources.append(self.allocator, resource); + } + + pub fn create(allocator: Allocator, io: std.Io, domain: [32]u8) Allocator.Error!*Owner { + const owner = try allocator.create(Owner); + owner.* = .{ .allocator = allocator, .io = io, .domain = domain }; + return owner; + } + + pub fn retain(self: *Owner) void { + _ = self.references.fetchAdd(1, .monotonic); + } + + pub fn release(self: *Owner) void { + if (self.references.fetchSub(1, .acq_rel) != 1) return; + for (self.resources.items) |resource| resource.deinit(resource.context); + self.resources.deinit(self.allocator); + for (self.records.items) |record| { + record.file.close(self.io); + self.allocator.free(record.bytes); + self.allocator.free(record.path); + self.allocator.destroy(record); + } + self.records.deinit(self.allocator); + self.allocator.destroy(self); + } + + /// Own and validate bytes while holding their immutable file generation. + /// Callers validate the bespoke format before using the reference. + pub fn acquire(self: *Owner, path: []const u8, expected: [32]u8, kind: Kind) (Allocator.Error || error{ Unavailable, Corrupt })!*const Record { + self.metrics.acquisitions += 1; + const file = try pin(std.Io.Dir.cwd(), self.io, path, .shared); + errdefer file.close(self.io); + var reader = file.reader(self.io, &.{}); + const bytes = reader.interface.allocRemaining(self.allocator, .limited(1024 * 1024 * 1024)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.Unavailable, + }; + errdefer self.allocator.free(bytes); + self.metrics.bytes_read += bytes.len; + self.metrics.bytes_hashed += bytes.len; + var hash: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash(bytes, &hash, .{}); + if (!std.mem.eql(u8, &hash, &expected)) return error.Corrupt; + const owned_path = try self.allocator.dupe(u8, path); + errdefer self.allocator.free(owned_path); + const record = try self.allocator.create(Record); + errdefer self.allocator.destroy(record); + record.* = .{ .owner = self, .file = file, .bytes = bytes, .digest = hash, .path = owned_path, .kind = kind }; + try self.records.append(self.allocator, record); + self.metrics.retained_bytes += bytes.len + owned_path.len + @sizeOf(Record); + self.metrics.retained_records += 1; + return record; + } +}; + +/// Validated bytes and their retained storage generation. Only Owner constructs +/// these references. Retain owner when a reference crosses an ownership boundary. +pub const Record = struct { + kind: Kind, + owner: *Owner, + file: std.Io.File, + bytes: []const u8, + digest: [32]u8, + path: []const u8, +}; + +/// Cleanup never removes a generation retained by a reader or publisher. +/// Failure to acquire ownership leaves optional storage for a later sweep. +pub fn remove(dir: std.Io.Dir, io: std.Io, path: []const u8) bool { + // Other cache families have their existing ownership protocols. Indexed + // native records coordinate their name-to-generation mapping explicitly. + const indexed = std.mem.endsWith(u8, path, ".rpa") or std.mem.endsWith(u8, path, ".rpm") or std.mem.endsWith(u8, path, ".rpi"); + var guard = if (indexed) Guard.acquire(dir, io, path) catch return false else null; + defer if (guard) |*held| held.release(); + const file = dir.openFile(io, path, .{}) catch return false; + defer file.close(io); + if (!(file.tryLock(io, .exclusive) catch return false)) return false; + dir.deleteFile(io, path) catch return false; + return true; +} + +/// Serializes only mapping changes (open-and-pin, replace, unlink). Readers +/// retain the record's own shared lock after releasing this short guard. +/// One stable guard covers the payload/metadata owner, another the offer index; +/// no lock is allocated per artifact and no guard spans encoding or hashing. +pub const Guard = struct { + file: std.Io.File, + io: std.Io, + + pub fn acquire(dir: std.Io.Dir, io: std.Io, path: []const u8) error{Unavailable}!Guard { + // All three record kinds live one directory below their owner: + // owner/{payload,metadata}/record and origin/offers/key/record. + const parent = std.fs.path.dirname(path) orelse "."; + var buffer: [std.fs.max_path_bytes]u8 = undefined; + const lock_path = std.fmt.bufPrint(&buffer, "{s}/../records.lock", .{parent}) catch return error.Unavailable; + const file = dir.createFile(io, lock_path, .{ .read = true, .truncate = false }) catch return error.Unavailable; + errdefer file.close(io); + file.lock(io, .exclusive) catch return error.Unavailable; + return .{ .file = file, .io = io }; + } + + pub fn release(self: *Guard) void { + self.file.close(self.io); + } +}; + +/// Pin exactly the generation currently named by path, atomically with respect +/// to repair and cleanup. Byte validation occurs outside the mapping guard. +pub fn pin(dir: std.Io.Dir, io: std.Io, path: []const u8, mode: std.Io.File.Lock) error{Unavailable}!std.Io.File { + var guard = try Guard.acquire(dir, io, path); + defer guard.release(); + const file = dir.openFile(io, path, .{}) catch return error.Unavailable; + errdefer file.close(io); + if (!(file.tryLock(io, mode) catch return error.Unavailable)) return error.Unavailable; + return file; +} diff --git a/src/cli/main.zig b/src/cli/main.zig index 8caee2f07a1..92379c768d1 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -8559,7 +8559,7 @@ fn writePackageDevPacksToStore( var pack = try compileModulePack(ctx, build_env, root_artifact, imports, relations, artifact, roots, .dev, .lss, RocTarget.detectNative(), false); defer pack.deinit(); const set = &pack.compiled.artifacts.?; - try publishDevPack(build_env, store, placement.origin, placement.identity, artifact.codeGenerationKey().bytes, set, &pack.lowered); + try publishDevPack(build_env, store, placement.origin, placement.identity, artifact.codeGenerationKey().bytes, set, &pack.lowered, null); for ([_]?*pack_store.LoadedPacks{ offers, runtime_packs }) |maybe_packs| { const packs = maybe_packs orelse continue; try packs.offerIndexed(store, placement.origin, placement.identity); @@ -8575,6 +8575,7 @@ fn writePacksToStore( root_artifact: *const check.CheckedArtifact.CheckedModuleArtifact, app_lowered: *const lir.CheckedPipeline.LoweredProgram, app_artifacts: ?*const backend.dev.ProcArtifact.Set, + loaded_packs: ?*const pack_store.LoadedPacks, ) CliMainError!void { if (dev_diagnostics.isSet(.pack_trace)) { // What this program lowered from source: every procedure by identity, @@ -8596,7 +8597,7 @@ fn writePacksToStore( } if (app_artifacts) |set| { if (build_env.packPlacementForArtifactKey(root_artifact.key)) |placement| { - try publishDevPack(build_env, store, placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes, set, app_lowered); + try publishDevPack(build_env, store, placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes, set, app_lowered, loaded_packs); } } } @@ -9189,7 +9190,7 @@ fn publishCompileTimePack(ctx: *CliCtx, build_env: *BuildEnv) Allocator.Error!vo var combined = try completed.combinedArtifacts(ctx.gpa); defer combined.deinit(); if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("compile-time pack {d} artifacts\n", .{combined.artifacts.len}); - try parts.offers.publish(build_env, store, destination.placement.origin, destination.placement.identity, destination.key, &combined); + try parts.offers.publish(build_env, store, destination.placement.origin, destination.placement.identity, destination.key, &combined, &cache.packs); cache.packs.refreshIndex(); } @@ -9219,17 +9220,6 @@ fn publishCompileTimeResults(ctx: *CliCtx, build_env: *BuildEnv, root_artifact: try publishDiscoverySummaries(build_env, root_artifact); } -/// Whether `root`'s code reaches a procedure a linked object-cache object -/// provides (`LIR.LirProcSpec.c_abi_entry`). -fn procReachesLinkedEntry(allocator: Allocator, store: *const lir.LirStore, root: lir.LIR.LirProcSpecId) Allocator.Error!bool { - const closure = try lir.PackProgram.procCallClosure(allocator, store, root); - defer allocator.free(closure); - for (closure, store.getProcSpecs()) |reached, proc| { - if (reached and proc.c_abi_entry) return true; - } - return false; -} - /// Encode a pack program's artifacts with its spec table: every keyed /// specialization whose procedure has an artifact, with the ownership /// signature ARC solved for it. @@ -9247,12 +9237,12 @@ fn packFileBytes( return offers.encode(allocator, set); } -fn publishDevPack(build_env: *const BuildEnv, store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set, lowered: *const lir.CheckedPipeline.LoweredProgram) Allocator.Error!void { +fn publishDevPack(build_env: *const BuildEnv, store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set, lowered: *const lir.CheckedPipeline.LoweredProgram, loaded_packs: ?*const pack_store.LoadedPacks) Allocator.Error!void { const reads = if (lowered.spec_comptime_reads) |*reads| reads else base.invariant("a dev pack producer recorded compile-time reads", .{}); var offers = PackOffers.init(store.allocator, false); defer offers.deinit(); try offers.addProgram(lowered, reads); - try offers.publish(build_env, store, origin, identity, key, set); + try offers.publish(build_env, store, origin, identity, key, set, loaded_packs); } /// The procedures a pack may offer, gathered from the programs it is written @@ -9322,20 +9312,19 @@ const PackOffers = struct { ) Allocator.Error!void { const allocator = self.literal_converters.allocator; const store = &lowered.lir_result.store; - const converting = try lir.PackProgram.literalConvertingProcs(allocator, store); - defer allocator.free(converting); - for (store.getProcSpecs(), converting) |proc, converts| { + var facts = try lir.PackProgram.OfferFacts.init(allocator, store); + defer facts.deinit(); + if (dev_diagnostics.isSet(.pack_stats)) std.debug.print("pack offer analysis: statements={d} call_edges={d} propagation_edges={d}\n", .{ facts.statements_visited, facts.call_edges, facts.propagation_edges }); + for (store.getProcSpecs(), facts.literal_conversion) |proc, converts| { if (converts) try self.literal_converters.put(proc.identity, {}); } for (lowered.lir_result.spec_procs.items) |spec_proc| { const key_entry = try self.keys.getOrPut(spec_proc.key); if (key_entry.found_existing) continue; key_entry.value_ptr.* = store.getProcSpecImmediate(spec_proc.proc).cache_provider_module; - try self.keyed.append(self.arena.allocator(), try self.candidate(lowered, spec_proc.proc, spec_proc.key, comptime_reads.get(spec_proc.key))); + try self.keyed.append(self.arena.allocator(), try self.candidate(lowered, &facts, spec_proc.proc, spec_proc.key, comptime_reads.get(spec_proc.key))); } - const carriers = try lir.PackProgram.siteCodeCarriers(allocator, store); - defer allocator.free(carriers); - for (store.getProcSpecs(), carriers, 0..) |proc, carries_site_code, index| { + for (store.getProcSpecs(), facts.site_code, 0..) |proc, carries_site_code, index| { if (proc.abi != .roc or proc.hosted != null or proc.is_static_initializer) continue; const source_fn = proc.source_monotype_fn orelse continue; const id: lir.LIR.LirProcSpecId = @fromBackingInt(@intCast(index)); @@ -9348,13 +9337,14 @@ const PackOffers = struct { const key_entry = try self.keys.getOrPut(key); if (key_entry.found_existing) continue; key_entry.value_ptr.* = proc.cache_provider_module; - try self.by_identity.append(self.arena.allocator(), try self.candidate(lowered, id, key, comptime_reads.fnReads(source_fn))); + try self.by_identity.append(self.arena.allocator(), try self.candidate(lowered, &facts, id, key, comptime_reads.fnReads(source_fn))); } } fn candidate( self: *PackOffers, lowered: *const lir.CheckedPipeline.LoweredProgram, + facts: *const lir.PackProgram.OfferFacts, id: lir.LIR.LirProcSpecId, key: [32]u8, reads: lir.CheckedPipeline.SpecComptimeReads.Lookup, @@ -9376,7 +9366,7 @@ const PackOffers = struct { const withheld = !reads.offerable or (self.evaluation and reads.evaluated) or proc.rc_variant_demandable or - try procReachesLinkedEntry(self.literal_converters.allocator, store, id) or + facts.reaches_linked[@backingInt(id)] or proc.inlined_at_calls or proc.tail_group != null; // An entry this program spliced from a pack is offered again with @@ -9413,6 +9403,10 @@ const PackOffers = struct { defer converting_artifacts.deinit(allocator); var closure = try SpliceClosure.init(allocator, set); defer closure.deinit(); + const rejected = try allocator.alloc(bool, set.artifacts.len); + defer allocator.free(rejected); + for (rejected, 0..) |*mark, index| mark.* = converting_artifacts.isSet(index) or closureNamesProgramLocalSymbols(set, &.{@intCast(index)}); + try backend.dev.ProcArtifact.markReferrers(allocator, set, rejected, .follow); for ([_][]const Candidate{ self.keyed.items, self.by_identity.items }, [_]bool{ false, true }) |candidates, by_identity| { for (candidates) |offer| { const artifact = closure.offeredProcArtifact(offer.identity) orelse continue; @@ -9424,8 +9418,7 @@ const PackOffers = struct { // and a constant holding a code pointer names code the pack // may not carry; an entry that reaches either cannot be // linked elsewhere, so it is not offered. - const placed = try closure.of(artifact); - if (closureNamesProgramLocalSymbols(set, placed) or closureConvertsLiteral(&converting_artifacts, placed)) { + if (rejected[artifact]) { withheld += 1; continue; } @@ -9458,7 +9451,7 @@ const PackOffers = struct { return backend.dev.PackFile.write(allocator, set, specs); } - fn publish(self: *const PackOffers, build_env: *const BuildEnv, store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set) Allocator.Error!void { + fn publish(self: *const PackOffers, build_env: *const BuildEnv, store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.ProcArtifact.Set, loaded_packs: ?*const pack_store.LoadedPacks) Allocator.Error!void { const specs = try self.entries(store.allocator, set); defer store.allocator.free(specs); const providers = try store.allocator.alloc(BuildEnv.PackPlacement, specs.len); @@ -9474,7 +9467,7 @@ const PackOffers = struct { } } } - store.writeIndexedForProviders(origin, identity, set, specs, providers) catch |err| switch (err) { + store.writeIndexedRetained(origin, identity, set, specs, providers, loaded_packs) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.PackWriteFailed => return, }; @@ -9552,10 +9545,15 @@ fn carriesData(artifact: backend.dev.ProcArtifact.Artifact, name: []const u8) bo } /// Test helper: whether the entry at `root` is withheld for program-local symbols. -fn testNamesProgramLocalSymbols(set: *const backend.dev.ProcArtifact.Set, root: u32) Allocator.Error!bool { +fn testNamesProgramLocalSymbols(set: *const backend.dev.ProcArtifact.Set, root: u32) !bool { var closure = try SpliceClosure.init(std.testing.allocator, set); defer closure.deinit(); - return closureNamesProgramLocalSymbols(set, try closure.of(root)); + const marks = try std.testing.allocator.alloc(bool, set.artifacts.len); + defer std.testing.allocator.free(marks); + for (marks, 0..) |*mark, i| mark.* = closureNamesProgramLocalSymbols(set, &.{@intCast(i)}); + try backend.dev.ProcArtifact.markReferrers(std.testing.allocator, set, marks, .follow); + try std.testing.expectEqual(closureNamesProgramLocalSymbols(set, try closure.of(root)), marks[root]); + return marks[root]; } test "pack withholds an entry whose stable references reach a constant holding a code pointer" { @@ -11937,7 +11935,7 @@ fn rocBuildNative(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResu const store = object_store.?; reporter.begin("Publishing Runtime Cache"); if (loaded_packs) |packs| try packs.publishInterfaces(); - try writePacksToStore(ctx, &build_env, store, root_artifact, &lowered, if (object_compiler.captured_artifacts) |*set| set else null); + try writePacksToStore(ctx, &build_env, store, root_artifact, &lowered, if (object_compiler.captured_artifacts) |*set| set else null, loaded_packs); reporter.end(); } diff --git a/src/cli/pack_index.zig b/src/cli/pack_index.zig index e3f689b9a81..bcf250a322c 100644 --- a/src/cli/pack_index.zig +++ b/src/cli/pack_index.zig @@ -1,6 +1,5 @@ -//! Persistent keyed dev offers. Metadata is a payload-free pack of one -//! producer-declared union of dependency closures; each artifact's bytes are addressed -//! separately by SHA-256. No directory of historical packs is consulted. +//! Persistent keyed dev offers over immutable dependency components. Payloads, +//! component metadata and selection manifests have independent content identities. const std = @import("std"); const base = @import("base"); const backend = @import("backend"); @@ -9,10 +8,6 @@ const PA = backend.dev.ProcArtifact; const PF = backend.dev.PackFile; const Allocator = std.mem.Allocator; -/// Magic and schema version of an offer metadata record. -pub const magic = "ROFFER02"; -/// Shared producer metadata, independent of specialization offers and payloads. -pub const metadata_magic = "ROFMETA1"; /// Magic and schema version of an independently addressed payload record. pub const payload_magic = "RPAYLD01"; /// Optional cache records can fail validation or allocation. @@ -42,93 +37,6 @@ pub fn payload(allocator: Allocator, artifact: PA.Artifact) Allocator.Error![]u8 return bytes.toOwnedSlice(allocator); } -/// The producer supplies the union of offered splice closures, in original set order. -/// Content naming uses the original bytes, never the stripped metadata. -pub fn encodeMetadata(allocator: Allocator, set: *const PA.Set, specs: []const PF.SpecEntry, closure: []const u32, hashes: []const [32]u8, names: *const PA.ContentNames) Allocator.Error![]u8 { - var arena = base.SingleThreadArena.init(allocator); - defer arena.deinit(); - const scratch = arena.allocator(); - const map = try scratch.alloc(u32, set.artifacts.len); - @memset(map, std.math.maxInt(u32)); - for (closure, 0..) |old, new| map[old] = std.math.cast(u32, new) orelse return error.OutOfMemory; - const artifacts = try scratch.alloc(PA.Artifact, closure.len); - for (closure, artifacts) |old, *artifact| { - artifact.* = set.artifacts[old]; - artifact.code = &.{}; - const refs = try scratch.dupe(PA.Reference, artifact.refs); - for (refs) |*ref| { - std.debug.assert(map[ref.target] != std.math.maxInt(u32)); - ref.target = map[ref.target]; - } - artifact.refs = refs; - const data = try scratch.dupe(PA.DataItem, artifact.data); - for (data) |*item| item.bytes = &.{}; - artifact.data = data; - } - const entries = try scratch.dupe(PF.SpecEntry, specs); - for (entries) |*entry| { - entry.artifact = map[entry.artifact]; - std.debug.assert(entry.artifact != std.math.maxInt(u32)); - } - const metadata = try PF.writeWithNames(scratch, &.{ .arena = undefined, .artifacts = artifacts, .source_files = set.source_files }, entries, names); - var bytes = std.ArrayList(u8).empty; - errdefer bytes.deinit(allocator); - try bytes.appendSlice(allocator, metadata_magic); - var count: [4]u8 = undefined; - std.mem.writeInt(u32, &count, std.math.cast(u32, closure.len) orelse return error.OutOfMemory, .little); - try bytes.appendSlice(allocator, &count); - for (closure) |old| try bytes.appendSlice(allocator, &hashes[old]); - try bytes.appendSlice(allocator, metadata); - return bytes.toOwnedSlice(allocator); -} - -/// Owned shared producer metadata and the explicit digests of its payloads. -pub const Metadata = struct { - pack: PF.Pack, - hashes: []const [32]u8, -}; - -/// Own both the metadata and its decoding; reject foreign/corrupt optional data. -pub fn decodeMetadata(allocator: Allocator, bytes: []const u8) Error!Metadata { - if (bytes.len < 12 or !std.mem.eql(u8, bytes[0..8], metadata_magic)) return error.MalformedPack; - const count = std.mem.readInt(u32, bytes[8..12], .little); - if (count > (bytes.len - 12) / 32) return error.MalformedPack; - var pack = try PF.read(allocator, bytes[12 + @as(usize, count) * 32 ..]); - errdefer pack.deinit(); - if (pack.set.artifacts.len != count) return error.MalformedPack; - for (pack.specs) |spec| if (pack.set.artifacts[spec.artifact].kind != .proc) return error.MalformedPack; - for (pack.set.artifacts) |artifact| { - if (artifact.code.len != 0 or artifact.runtime_refs.len != 0) return error.MalformedPack; - for (artifact.data) |item| if (item.bytes.len != 0) return error.MalformedPack; - } - const hashes = try pack.set.arena.allocator().alloc([32]u8, count); - for (hashes, 0..) |*hash, i| hash.* = bytes[12 + i * 32 ..][0..32].*; - return .{ .pack = pack, .hashes = hashes }; -} - -/// A keyed offer references one entry of shared immutable producer metadata. -pub const Offer = struct { - metadata: [32]u8, - spec: u32, - origin: enum(u8) { local, pkg }, - owner: [32]u8, - - pub fn encode(self: Offer) [77]u8 { - var bytes: [77]u8 = undefined; - bytes[0..8].* = magic.*; - bytes[8..40].* = self.metadata; - std.mem.writeInt(u32, bytes[40..44], self.spec, .little); - bytes[44] = @backingInt(self.origin); - bytes[45..77].* = self.owner; - return bytes; - } - - pub fn decode(bytes: []const u8) Error!Offer { - if (bytes.len != 77 or !std.mem.eql(u8, bytes[0..8], magic)) return error.MalformedPack; - return .{ .metadata = bytes[8..40].*, .spec = std.mem.readInt(u32, bytes[40..44], .little), .origin = std.enums.fromInt(@FieldType(Offer, "origin"), bytes[44]) orelse return error.MalformedPack, .owner = bytes[45..77].* }; - } -}; - /// Bytes are owned by the loader, and remain live until all its consumers end. pub fn materialize(artifact: *PA.Artifact, bytes: []const u8) Error!void { if (!std.mem.startsWith(u8, bytes, payload_magic)) return error.MalformedPack; @@ -158,6 +66,9 @@ pub const SpliceClosure = struct { /// Artifacts the current query reached; exactly the indices in `order`. seen: std.DynamicBitSetUnmanaged, order: std.ArrayList(u32) = .empty, + nodes_visited: usize = 0, + edges_visited: usize = 0, + queries: usize = 0, /// The artifacts of one procedure identity: a splice reaches the first, /// and a pack offers the last. @@ -206,28 +117,362 @@ pub const SpliceClosure = struct { /// Indices of the artifacts a splice of `root` places, valid until the /// next query. pub fn of(self: *SpliceClosure, root: u32) Allocator.Error![]const u32 { + self.clear(); + try self.visit(root); + return self.expand(); + } + + /// Gather the union without visiting a shared dependency once per root. + pub fn ofEntries(self: *SpliceClosure, entries: []const PF.SpecEntry) Allocator.Error![]const u32 { + self.clear(); + for (entries) |entry| try self.visit(entry.artifact); + return self.expand(); + } + + fn clear(self: *SpliceClosure) void { for (self.order.items) |index| self.seen.unset(index); self.order.clearRetainingCapacity(); - try self.visit(root); + self.queries += 1; + } + + fn expand(self: *SpliceClosure) Allocator.Error![]const u32 { var cursor: usize = 0; while (cursor < self.order.items.len) : (cursor += 1) { + self.nodes_visited += 1; const artifact = self.set.artifacts[self.order.items[cursor]]; - for (artifact.refs) |ref| try self.visit(ref.target); + for (artifact.refs) |ref| { + self.edges_visited += 1; + try self.visit(ref.target); + } for (artifact.symbolic_refs) |ref| { - const target = switch (ref.target) { - .proc => |identity| if (self.procs.get(identity)) |artifacts| artifacts.first else null, - .boxy_thunk => |identity| self.thunks.get(identity), - .rc_helper => |name| self.helpers.get(name), - }; + self.edges_visited += 1; + const target = self.symbolicTarget(ref); if (target) |index| try self.visit(index); } } return self.order.items; } + pub fn symbolicTarget(self: *const SpliceClosure, ref: PA.SymbolicReference) ?u32 { + return switch (ref.binding) { + .local => |index| index, + .absent => null, + .unresolved => switch (ref.target) { + .proc => |identity| if (self.procs.get(identity)) |artifacts| artifacts.first else null, + .boxy_thunk => |identity| self.thunks.get(identity), + .rc_helper => |name| self.helpers.get(name), + }, + }; + } + pub fn visit(self: *SpliceClosure, index: u32) Allocator.Error!void { if (self.seen.isSet(index)) return; try self.order.append(self.allocator, index); self.seen.set(index); } }; + +const collections = @import("collections"); + +/// SCCs of one frozen native graph. Outgoing component edges always lead to +/// higher component numbers; reverse order therefore names dependencies first. +pub const ComponentGraph = struct { + arena: base.SingleThreadArena, + components: collections.scc.Components, + ordinal: []u32, + edge_count: usize, + + pub fn init(allocator: Allocator, closure: *const SpliceClosure) Allocator.Error!ComponentGraph { + var arena = base.SingleThreadArena.init(allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + const set = closure.set; + const starts = try a.alloc(u32, set.artifacts.len + 1); + var targets = std.ArrayList(u32).empty; + for (set.artifacts, 0..) |artifact, i| { + starts[i] = std.math.cast(u32, targets.items.len) orelse return error.OutOfMemory; + for (artifact.refs) |ref| try targets.append(a, ref.target); + for (artifact.symbolic_refs) |ref| if (closure.symbolicTarget(ref)) |target_index| try targets.append(a, target_index); + } + starts[set.artifacts.len] = std.math.cast(u32, targets.items.len) orelse return error.OutOfMemory; + const reverse = try collections.csr.transpose(a, starts, targets.items); + const components = try collections.scc.compute(a, starts, targets.items, reverse.starts, reverse.targets); + const ordinal = try a.alloc(u32, set.artifacts.len); + for (0..components.count()) |c| { + const members = components.membersOf(c); + // A retained component keeps its producer's ordinal mapping even + // when assembly changes placement order. Fresh components use the + // producer's deterministic emission order within that component. + const origin = commonOrigin(set, members); + if (origin != null) { + std.mem.sort(u32, members, set, struct { + fn less(owner: *const PA.Set, lhs: u32, rhs: u32) bool { + return owner.artifacts[lhs].metadata_origin.?.index < owner.artifacts[rhs].metadata_origin.?.index; + } + }.less); + } else std.mem.sort(u32, members, {}, std.sort.asc(u32)); + for (members, 0..) |index, member| ordinal[index] = @intCast(member); + } + return .{ .arena = arena, .components = components, .ordinal = ordinal, .edge_count = targets.items.len }; + } + + pub fn deinit(self: *ComponentGraph) void { + self.arena.deinit(); + } + + pub fn commonOrigin(set: *const PA.Set, members: []const u32) ?*const PA.MetadataOrigin { + if (members.len == 0) return null; + const first = set.artifacts[members[0]].metadata_origin orelse return null; + if (first.source.artifacts.len != members.len) return null; + for (members) |index| { + const origin = set.artifacts[index].metadata_origin orelse return null; + if (origin.source != first.source) return null; + } + return first; + } + + pub fn target(self: *const ComponentGraph, component: u32, index: u32, hashes: []const [32]u8) Target { + const other = self.components.component_of[index]; + return .{ .component = if (other == component) null else hashes[other], .index = self.ordinal[index] }; + } +}; + +/// Internal edges use a component-local ordinal, so cycles do not require a +/// recursive hash. External edges name a previously committed component. +pub const Target = struct { component: ?[32]u8, index: u32 }; +/// One persisted dependency endpoint, attached to an exact reference slot. +pub const Edge = struct { artifact: u32, reference: u32, symbolic: bool, target: Target }; +/// Decoded component metadata; all slices belong to pack.set.arena. +pub const Component = struct { pack: PF.Pack, hashes: []const [32]u8, edges: []const Edge }; + +fn word(a: Allocator, out: *std.ArrayList(u8), value: u32) Allocator.Error!void { + var bytes: [4]u8 = undefined; + std.mem.writeInt(u32, &bytes, value, .little); + try out.appendSlice(a, &bytes); +} + +/// One component's stable metadata, independent of offered specializations. +pub fn encodeComponent(a: Allocator, set: *const PA.Set, graph: *const ComponentGraph, closure: *const SpliceClosure, component: u32, component_hashes: []const [32]u8, payload_hashes: []const [32]u8, names: *const PA.ContentNames) Allocator.Error![]u8 { + var arena = base.SingleThreadArena.init(a); + defer arena.deinit(); + const scratch = arena.allocator(); + const members = graph.components.membersOf(component); + const artifacts = try scratch.alloc(PA.Artifact, members.len); + var edges = std.ArrayList(Edge).empty; + for (members, artifacts, 0..) |old, *artifact, local| { + artifact.* = set.artifacts[old]; + artifact.code = &.{}; + const refs = try scratch.dupe(PA.Reference, artifact.refs); + for (refs, 0..) |*ref, r| { + const target = graph.target(component, ref.target, component_hashes); + try edges.append(scratch, .{ .artifact = @intCast(local), .reference = @intCast(r), .symbolic = false, .target = target }); + ref.target = if (target.component == null) target.index else 0; + } + artifact.refs = refs; + const symbolic = try scratch.dupe(PA.SymbolicReference, artifact.symbolic_refs); + for (symbolic, 0..) |*ref, r| { + if (closure.symbolicTarget(ref.*)) |target| try edges.append(scratch, .{ .artifact = @intCast(local), .reference = @intCast(r), .symbolic = true, .target = graph.target(component, target, component_hashes) }); + ref.binding = .absent; + } + artifact.symbolic_refs = symbolic; + const data = try scratch.dupe(PA.DataItem, artifact.data); + for (data) |*item| item.bytes = &.{}; + artifact.data = data; + } + const encoded_pack = try PF.writeWithNames(scratch, &.{ .arena = undefined, .artifacts = artifacts, .source_files = set.source_files }, &.{}, names); + var out = std.ArrayList(u8).empty; + errdefer out.deinit(a); + try out.appendSlice(a, "ROFCOMP1"); + try word(a, &out, std.math.cast(u32, members.len) orelse return error.OutOfMemory); + for (members) |index| try out.appendSlice(a, &payload_hashes[index]); + try word(a, &out, std.math.cast(u32, edges.items.len) orelse return error.OutOfMemory); + for (edges.items) |edge| { + try word(a, &out, edge.artifact); + try word(a, &out, edge.reference); + try out.append(a, @intFromBool(edge.symbolic)); + try out.append(a, @intFromBool(edge.target.component != null)); + if (edge.target.component) |hash| try out.appendSlice(a, &hash); + try word(a, &out, edge.target.index); + } + try out.appendSlice(a, encoded_pack); + return out.toOwnedSlice(a); +} + +const Cursor = struct { + bytes: []const u8, + offset: usize = 0, + fn raw(self: *Cursor, n: usize) Error![]const u8 { + if (n > self.bytes.len - self.offset) return error.MalformedPack; + defer self.offset += n; + return self.bytes[self.offset..][0..n]; + } + fn word(self: *Cursor) Error!u32 { + return std.mem.readInt(u32, (try self.raw(4))[0..4], .little); + } + fn flag(self: *Cursor) Error!bool { + return switch ((try self.raw(1))[0]) { + 0 => false, + 1 => true, + else => error.MalformedPack, + }; + } +}; + +/// Validate a component envelope and its complete ordered dependency table. +pub fn decodeComponent(a: Allocator, bytes: []const u8) Error!Component { + var cursor = Cursor{ .bytes = bytes }; + if (!std.mem.eql(u8, try cursor.raw(8), "ROFCOMP1")) return error.MalformedPack; + const count = try cursor.word(); + const raw_hashes = try cursor.raw(@as(usize, count) * 32); + const edge_count = try cursor.word(); + if (edge_count > (bytes.len - cursor.offset) / 14) return error.MalformedPack; + const edges = try a.alloc(Edge, edge_count); + defer a.free(edges); + for (edges) |*edge| { + edge.* = .{ .artifact = try cursor.word(), .reference = try cursor.word(), .symbolic = try cursor.flag(), .target = .{ .component = if (try cursor.flag()) (try cursor.raw(32))[0..32].* else null, .index = try cursor.word() } }; + } + var pack = try PF.read(a, bytes[cursor.offset..]); + errdefer pack.deinit(); + if (pack.set.artifacts.len != count or pack.specs.len != 0) return error.MalformedPack; + const scratch = pack.set.arena.allocator(); + const hashes = try scratch.alloc([32]u8, count); + for (hashes, 0..) |*hash, i| hash.* = raw_hashes[i * 32 ..][0..32].*; + // Edge order is canonical and every positional reference has one endpoint. + var edge_cursor: usize = 0; + for (pack.set.artifacts, 0..) |artifact, i| { + if (artifact.code.len != 0 or artifact.runtime_refs.len != 0) return error.MalformedPack; + for (artifact.data) |item| if (item.bytes.len != 0) return error.MalformedPack; + for (artifact.symbolic_refs) |ref| if (ref.binding != .absent) return error.MalformedPack; + for (artifact.refs, 0..) |_, r| { + if (edge_cursor == edges.len) return error.MalformedPack; + const edge = edges[edge_cursor]; + if (edge.artifact != i or edge.reference != r or edge.symbolic) return error.MalformedPack; + edge_cursor += 1; + } + var previous: ?u32 = null; + while (edge_cursor < edges.len and edges[edge_cursor].artifact == i) : (edge_cursor += 1) { + const edge = edges[edge_cursor]; + if (!edge.symbolic or edge.reference >= artifact.symbolic_refs.len) return error.MalformedPack; + if (previous) |last| if (edge.reference <= last) return error.MalformedPack; + previous = edge.reference; + } + } + if (edge_cursor != edges.len) return error.MalformedPack; + for (edges) |edge| if (edge.target.component == null and edge.target.index >= count) return error.MalformedPack; + const owned_edges = try scratch.dupe(Edge, edges); + return .{ .pack = pack, .hashes = hashes, .edges = owned_edges }; +} + +/// Small selection manifest. The dummy artifact carries only the root's exact +/// procedure identity; its spec codec is the ordinary PackFile spec codec. +pub fn encodeOffer(a: Allocator, component: [32]u8, index: u32, identity: lir.ProcIdentity, spec: PF.SpecEntry) Allocator.Error![]u8 { + var local_spec = spec; + local_spec.artifact = 0; + const encoded_pack = try PF.write(a, &.{ .arena = undefined, .artifacts = &.{.{ .kind = .{ .proc = identity }, .code = &.{}, .entry = 0, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }} }, &.{local_spec}); + defer a.free(encoded_pack); + var out = std.ArrayList(u8).empty; + errdefer out.deinit(a); + try out.appendSlice(a, "ROFFER03"); + try out.appendSlice(a, &component); + try word(a, &out, index); + try out.appendSlice(a, encoded_pack); + return out.toOwnedSlice(a); +} + +/// A decoded root selection and its owned specialization interface. +pub const Selection = struct { component: [32]u8, index: u32, pack: PF.Pack }; +/// Validate a selection manifest without acquiring its dependencies. +pub fn decodeOffer(a: Allocator, bytes: []const u8) Error!Selection { + var cursor = Cursor{ .bytes = bytes }; + if (!std.mem.eql(u8, try cursor.raw(8), "ROFFER03")) return error.MalformedPack; + const component = (try cursor.raw(32))[0..32].*; + const index = try cursor.word(); + var pack = try PF.read(a, bytes[cursor.offset..]); + errdefer pack.deinit(); + if (pack.set.artifacts.len != 1 or pack.specs.len != 1 or pack.set.artifacts[0].kind != .proc) return error.MalformedPack; + return .{ .component = component, .index = index, .pack = pack }; +} + +/// Structural equality of persisted metadata, with slices compared by value. +/// Code/data payloads never pass through this walk: their owned references prove +/// their identity. Only producer-stamped metadata and dependency identities do. +fn equal(comptime T: type, a: T, b: T) bool { + return switch (@typeInfo(T)) { + .pointer => |p| if (p.size == .slice) blk: { + if (a.len != b.len) break :blk false; + for (a, b) |x, y| if (!equal(p.child, x, y)) break :blk false; + break :blk true; + } else a == b, + .array => |array| blk: { + for (a, b) |x, y| if (!equal(array.child, x, y)) break :blk false; + break :blk true; + }, + .@"struct" => |info| blk: { + inline for (info.field_names) |name| if (!equal(@FieldType(T, name), @field(a, name), @field(b, name))) break :blk false; + break :blk true; + }, + .@"union" => |info| blk: { + const tag = std.meta.activeTag(a); + if (tag != std.meta.activeTag(b)) break :blk false; + inline for (info.field_names) |name| { + if (tag == @field(std.meta.Tag(T), name)) break :blk equal(@FieldType(T, name), @field(a, name), @field(b, name)); + } + unreachable; + }, + .optional => |opt| if (a) |value| if (b) |other| equal(opt.child, value, other) else false else b == null, + .void => true, + .bool, .int, .@"enum" => a == b, + .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported persisted native metadata type: " ++ @typeName(T)), + }; +} + +fn sameLoc(a: base.SourceLoc, files_a: []const [32]u8, b: base.SourceLoc, files_b: []const [32]u8) bool { + if (a.hasLocation() != b.hasLocation()) return false; + if (!a.hasLocation()) return true; + return a.line == b.line and a.column == b.column and std.mem.eql(u8, &files_a[a.file], &files_b[b.file]); +} + +fn binding(target: Target, own: [32]u8) PA.Binding { + return .{ .component = target.component orelse own, .index = target.index }; +} + +/// Reuse the exact immutable component only when all current metadata and every +/// dependency endpoint still match its producing component. This is one direct +/// edge walk per component, including cycles, never a closure walk per offer. +pub fn reusableComponent(set: *const PA.Set, graph: *const ComponentGraph, closure: *const SpliceClosure, component: u32, component_hashes: []const [32]u8, payload_hashes: []const [32]u8) ?*const base.retained_cache.Record { + const members = graph.components.membersOf(component); + const first = ComponentGraph.commonOrigin(set, members) orelse return null; + const own_hash = first.record.digest; + for (members, 0..) |index, ordinal| { + const current = set.artifacts[index]; + const origin = current.metadata_origin.?; + if (origin.index != ordinal) return null; + const old = origin.source.artifacts[origin.index]; + const old_payload = old.payload_record orelse return null; + if (!std.mem.eql(u8, &payload_hashes[index], &old_payload.digest)) return null; + if (!equal(PA.Kind, current.kind, old.kind) or current.entry != old.entry or !equal(?PA.Frame, current.frame, old.frame) or + !equal([]const PA.FailureSite, current.failure_sites, old.failure_sites) or + !equal([]const PA.NamedRelocation, current.relocations, old.relocations) or + !equal([]const PA.RuntimeReference, current.runtime_refs, old.runtime_refs)) return null; + if (!sameLoc(current.loc, set.source_files, old.loc, origin.source.source_files) or current.lines.len != old.lines.len) return null; + for (current.lines, old.lines) |x, y| { + if (x.offset != y.offset or !equal(base.Region, x.region, y.region) or !sameLoc(x.loc, set.source_files, y.loc, origin.source.source_files)) return null; + } + if (current.data.len != old.data.len) return null; + for (current.data, old.data) |x, y| { + if (!std.mem.eql(u8, x.name, y.name) or x.alignment != y.alignment or x.symbol_offset != y.symbol_offset or + x.program_local_name != y.program_local_name or !equal([]const PA.DataRelocation, x.relocations, y.relocations)) return null; + } + if (current.refs.len != old.refs.len or current.symbolic_refs.len != old.symbolic_refs.len) return null; + for (current.refs, old.refs, origin.refs) |x, y, expected| { + if (x.site != y.site or x.form != y.form or x.delta != y.delta or x.veneer != y.veneer) return null; + if (!equal(PA.Binding, binding(graph.target(component, x.target, component_hashes), own_hash), expected)) return null; + } + for (current.symbolic_refs, old.symbolic_refs, origin.symbolic) |x, y, expected| { + if (x.site != y.site or x.form != y.form or x.veneer != y.veneer or !equal(@FieldType(PA.SymbolicReference, "target"), x.target, y.target)) return null; + const actual: ?PA.Binding = if (closure.symbolicTarget(x)) |target| binding(graph.target(component, target, component_hashes), own_hash) else null; + if (!equal(?PA.Binding, actual, expected)) return null; + } + } + return first.record; +} diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index e1d466548d5..9964a9eeda0 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -19,6 +19,12 @@ const Index = @import("pack_index.zig"); pub const SpliceClosure = Index.SpliceClosure; const CoreCtx = ctx_mod.CoreCtx; +fn contentPath(allocator: Allocator, dir: []const u8, hash: [32]u8, kind: enum { metadata, payload }) Allocator.Error![]u8 { + const name = try std.fmt.allocPrint(allocator, "{s}.{s}", .{ std.fmt.bytesToHex(hash, .lower), if (kind == .metadata) "rpm" else "rpa" }); + defer allocator.free(name); + return std.fs.path.join(allocator, &.{ dir, @tagName(kind), name }); +} + /// The pack files of one compiler build, target, and optimization level, /// under the cache root. Dev packs use the declared procedure domain; linked /// packs and program outputs retain the exact target. Packs are filed by @@ -36,6 +42,28 @@ pub const Store = struct { /// Procedure packs and discovery summaries, independently of final output. procedure_root: []u8, verbose: bool, + publication_stats: ?*PublicationStats = null, + /// Explicit native format/target/ABI domain stamped by Store.init. + domain: [32]u8 = @splat(0), + + pub const PublicationStats = struct { + offers_changed: u64 = 0, + offers_reused: u64 = 0, + payloads_encoded: u64 = 0, + payload_bytes_encoded: u64 = 0, + bytes_hashed: u64 = 0, + metadata_encoded: u64 = 0, + metadata_bytes_encoded: u64 = 0, + records_read: u64 = 0, + bytes_read: u64 = 0, + records_written: u64 = 0, + bytes_written: u64 = 0, + validated_references: u64 = 0, + graph_nodes: u64 = 0, + graph_edges: u64 = 0, + closure_queries: u64 = 0, + peak_encoded_bytes: u64 = 0, + }; pub const InitError = Allocator.Error || error{NoHomeDirectory}; @@ -57,7 +85,7 @@ pub const Store = struct { defer allocator.free(procedure_mode); break :blk try std.fs.path.join(allocator, &.{ version_dir, namespace, procedure_mode }); } else try allocator.dupe(u8, root); - return .{ .allocator = allocator, .roc_ctx = cache_config.roc_ctx, .root = root, .procedure_root = procedure_root, .verbose = cache_config.verbose }; + return .{ .allocator = allocator, .roc_ctx = cache_config.roc_ctx, .root = root, .procedure_root = procedure_root, .verbose = cache_config.verbose, .domain = backend.dev.NativeProcDomain.init(target, strategy).key() }; } pub fn deinit(self: *Store) void { @@ -192,80 +220,115 @@ pub const Store = struct { /// Publish one producer's shared records and route each offer to its /// explicitly stamped provider. Null routes every offer to the producer. pub fn writeIndexedForProviders(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, set: *const backend.dev.ProcArtifact.Set, specs: []const PackFile.SpecEntry, providers: ?[]const compile.BuildEnv.PackPlacement) (Allocator.Error || error{PackWriteFailed})!void { + return self.writeIndexedRetained(origin, identity, set, specs, providers, null); + } + + /// Reuse exact offers already admitted and retained by this compilation. + pub fn writeIndexedRetained(self: *const Store, origin: compile.BuildEnv.PackOrigin, identity: [32]u8, set: *const backend.dev.ProcArtifact.Set, specs: []const PackFile.SpecEntry, providers: ?[]const compile.BuildEnv.PackPlacement, retained: ?*const LoadedPacks) (Allocator.Error || error{PackWriteFailed})!void { + var local_stats: PublicationStats = .{}; + const stats = self.publication_stats orelse &local_stats; const tracing = base.dev_diagnostics.isSet(.pack_stats); const start = if (tracing) std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds else 0; - var metadata_bytes: u64 = 0; - var payload_bytes: u64 = 0; - var records_written: u64 = 0; - var records_reused: u64 = 0; - defer if (tracing) std.debug.print("pack index publication: offers={d} metadata_bytes={d} payload_bytes={d} records_written={d} records_reused={d} wall_ns={d}\n", .{ specs.len, metadata_bytes, payload_bytes, records_written, records_reused, std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds - start }); + defer if (tracing) std.debug.print("pack publication: {any} wall_ns={d}\n", .{ stats.*, std.Io.Timestamp.now(self.roc_ctx.std_io, .awake).nanoseconds - start }); if (providers) |locations| std.debug.assert(locations.len == specs.len); if (specs.len == 0) return; const allocator = self.allocator; - const dir = try self.identityDir(origin, identity); + const dir = try self.identityDir(.local, @splat(0)); defer allocator.free(dir); - var names = try backend.dev.ProcArtifact.ContentNames.init(allocator, set); - defer names.deinit(); var closure = try SpliceClosure.init(allocator, set); defer closure.deinit(); - const included = try allocator.alloc(bool, set.artifacts.len); + var graph = try Index.ComponentGraph.init(allocator, &closure); + defer graph.deinit(); + stats.graph_nodes += set.artifacts.len; + stats.graph_edges += graph.edge_count; + const union_indices = try closure.ofEntries(specs); + stats.graph_nodes += closure.nodes_visited; + stats.graph_edges += closure.edges_visited; + stats.closure_queries += closure.queries; + const included = try allocator.alloc(bool, graph.components.count()); defer allocator.free(included); @memset(included, false); - for (specs) |spec| for (try closure.of(spec.artifact)) |index| { - included[index] = true; - }; - var union_indices = std.ArrayList(u32).empty; - defer union_indices.deinit(allocator); - for (included, 0..) |include, index| if (include) { - try union_indices.append(allocator, @intCast(index)); - }; - const hashes = try allocator.alloc([32]u8, set.artifacts.len); - defer allocator.free(hashes); + const payload_hashes = try allocator.alloc([32]u8, set.artifacts.len); + defer allocator.free(payload_hashes); + const component_hashes = try allocator.alloc([32]u8, graph.components.count()); + defer allocator.free(component_hashes); + var pins = std.ArrayList(std.Io.File).empty; + defer { + for (pins.items) |file| file.close(self.roc_ctx.std_io); + pins.deinit(allocator); + } var published = std.AutoHashMap([32]u8, void).init(allocator); defer published.deinit(); - for (union_indices.items) |index| { - const bytes = try Index.payload(allocator, set.artifacts[index]); + for (union_indices) |index| { + included[graph.components.component_of[index]] = true; + const artifact = set.artifacts[index]; + if (artifact.payload_record) |record| { + std.debug.assert(record.kind == .native_payload_v1); + if (!std.mem.eql(u8, &record.owner.domain, &self.domain)) return error.PackWriteFailed; + payload_hashes[index] = record.digest; + if ((try published.getOrPut(record.digest)).found_existing) continue; + const path = try contentPath(allocator, dir, record.digest, .payload); + defer allocator.free(path); + if (std.mem.eql(u8, record.path, path)) stats.validated_references += 1 else _ = try self.publishIndexedHeld(path, record.bytes, stats, &pins); + continue; + } + const bytes = try Index.payload(allocator, artifact); defer allocator.free(bytes); + stats.payloads_encoded += 1; + stats.payload_bytes_encoded += bytes.len; + stats.bytes_hashed += bytes.len; + stats.peak_encoded_bytes = @max(stats.peak_encoded_bytes, bytes.len); const hash = Index.digest(bytes); - hashes[index] = hash; + payload_hashes[index] = hash; if ((try published.getOrPut(hash)).found_existing) continue; - const name = std.fmt.bytesToHex(hash, .lower) ++ ".rpa".*; - const path = try std.fs.path.join(allocator, &.{ dir, "payload", &name }); + const path = try contentPath(allocator, dir, hash, .payload); defer allocator.free(path); - if (try self.publishIndexedPath(path, bytes)) { - payload_bytes += bytes.len; - records_written += 1; - } else { - records_reused += 1; + _ = try self.publishIndexedHeld(path, bytes, stats, &pins); + } + var names: ?backend.dev.ProcArtifact.ContentNames = null; + defer if (names) |*value| value.deinit(); + var remaining = graph.components.count(); + while (remaining != 0) { + remaining -= 1; + if (!included[remaining]) continue; + const c: u32 = @intCast(remaining); + if (Index.reusableComponent(set, &graph, &closure, c, component_hashes, payload_hashes)) |record| { + component_hashes[c] = record.digest; + const path = try contentPath(allocator, dir, record.digest, .metadata); + defer allocator.free(path); + if (std.mem.eql(u8, record.path, path)) stats.validated_references += 1 else _ = try self.publishIndexedHeld(path, record.bytes, stats, &pins); + continue; } + if (names == null) names = try backend.dev.ProcArtifact.ContentNames.init(allocator, set); + const bytes = try Index.encodeComponent(allocator, set, &graph, &closure, c, component_hashes, payload_hashes, &names.?); + defer allocator.free(bytes); + stats.metadata_encoded += 1; + stats.metadata_bytes_encoded += bytes.len; + stats.bytes_hashed += bytes.len; + stats.peak_encoded_bytes = @max(stats.peak_encoded_bytes, bytes.len); + const hash = Index.digest(bytes); + component_hashes[c] = hash; + const path = try contentPath(allocator, dir, hash, .metadata); + defer allocator.free(path); + _ = try self.publishIndexedHeld(path, bytes, stats, &pins); } - const metadata = try Index.encodeMetadata(allocator, set, specs, union_indices.items, hashes, &names); - defer allocator.free(metadata); - const metadata_hash = Index.digest(metadata); - const metadata_name = std.fmt.bytesToHex(metadata_hash, .lower) ++ ".rpm".*; - const metadata_path = try std.fs.path.join(allocator, &.{ dir, "metadata", &metadata_name }); - defer allocator.free(metadata_path); - if (try self.publishIndexedPath(metadata_path, metadata)) { - metadata_bytes += metadata.len; - records_written += 1; - } else { - records_reused += 1; - } - for (specs, 0..) |spec, spec_index| { - const provider = if (providers) |locations| locations[spec_index] else compile.BuildEnv.PackPlacement{ .origin = origin, .identity = identity }; - const bytes = (Index.Offer{ .metadata = metadata_hash, .spec = @intCast(spec_index), .origin = switch (origin) { - .local => .local, - .pkg => .pkg, - }, .owner = identity }).encode(); - const name = std.fmt.bytesToHex(provider.identity, .lower) ++ "-".* ++ std.fmt.bytesToHex(Index.digest(&bytes), .lower) ++ ".rpi".*; + for (specs, 0..) |spec, i| { + const provider = if (providers) |locations| locations[i] else compile.BuildEnv.PackPlacement{ .origin = origin, .identity = identity }; + const hash = component_hashes[graph.components.component_of[spec.artifact]]; + const ordinal = graph.ordinal[spec.artifact]; + if (retained) |loaded| if (loaded.reusesOffer(self, spec, provider, hash, ordinal)) { + stats.offers_reused += 1; + stats.validated_references += 1; + continue; + }; + stats.offers_changed += 1; + const bytes = try Index.encodeOffer(allocator, hash, ordinal, set.artifacts[spec.artifact].kind.proc, spec); + defer allocator.free(bytes); + stats.bytes_hashed += bytes.len; + const name = std.fmt.bytesToHex(provider.identity, .lower) ++ "-".* ++ std.fmt.bytesToHex(Index.digest(bytes), .lower) ++ ".rpi".*; const path = try std.fs.path.join(allocator, &.{ self.procedure_root, @tagName(provider.origin), "offers", &std.fmt.bytesToHex(spec.key, .lower), &name }); defer allocator.free(path); - if (try self.publishIndexedPath(path, &bytes)) { - metadata_bytes += bytes.len; - records_written += 1; - } else { - records_reused += 1; - } + _ = try self.publishIndexedHeld(path, bytes, stats, &pins); } } @@ -292,6 +355,21 @@ pub const Store = struct { /// Replacing a corrupt optional record is repair, not an invariant failure. /// Identical concurrent publishers have identical content-addressed bytes. fn publishIndexedPath(self: *const Store, path: []const u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!bool { + var stats: PublicationStats = .{}; + return self.publishIndexedCounted(path, bytes, self.publication_stats orelse &stats); + } + + fn publishIndexedCounted(self: *const Store, path: []const u8, bytes: []const u8, stats: *PublicationStats) (Allocator.Error || error{PackWriteFailed})!bool { + return self.publishIndexedHeld(path, bytes, stats, null); + } + + fn publishIndexedHeld(self: *const Store, path: []const u8, bytes: []const u8, stats: *PublicationStats, pins: ?*std.ArrayList(std.Io.File)) (Allocator.Error || error{PackWriteFailed})!bool { + if (pins) |held| try held.ensureUnusedCapacity(self.allocator, 1); + const io = self.roc_ctx.std_io; + const indexed = std.mem.endsWith(u8, path, ".rpa") or std.mem.endsWith(u8, path, ".rpm") or std.mem.endsWith(u8, path, ".rpi"); + var read_pin = if (indexed) base.retained_cache.pin(std.Io.Dir.cwd(), io, path, .shared) catch null else null; + defer if (read_pin) |file| file.close(io); + stats.records_read += 1; // Verify immutable bytes before skipping a write. Corrupt records are // repaired, and a vanished record is republished rather than trusted. const existing = self.roc_ctx.readFile(path, self.allocator) catch |err| switch (err) { @@ -300,7 +378,18 @@ pub const Store = struct { }; if (existing) |old| { defer self.allocator.free(old); - if (std.mem.eql(u8, old, bytes)) return false; + stats.bytes_read += old.len; + if (std.mem.eql(u8, old, bytes) and (!indexed or read_pin != null)) { + if (pins) |held| { + held.appendAssumeCapacity(read_pin.?); + read_pin = null; + } + return false; + } + } + if (read_pin) |file| { + file.close(io); + read_pin = null; } self.roc_ctx.makePath(std.fs.path.dirname(path).?) catch return error.PackWriteFailed; const staged = self.roc_ctx.writeTempFile(self.allocator, path, bytes) catch |err| switch (err) { @@ -309,7 +398,51 @@ pub const Store = struct { }; defer self.allocator.free(staged); defer self.roc_ctx.deleteFile(staged) catch {}; + var guard = if (indexed) base.retained_cache.Guard.acquire(std.Io.Dir.cwd(), io, path) catch return error.PackWriteFailed else null; + defer if (guard) |*held| held.release(); + // Existing generations cannot be replaced while an acquisition pins + // them. The mapping guard prevents a stale open from unlinking or + // replacing a newer generation at the same path. + var existing_file = if (indexed) std.Io.Dir.cwd().openFile(io, path, .{}) catch |err| switch (err) { + error.FileNotFound => null, + else => return error.PackWriteFailed, + } else null; + defer if (existing_file) |file| file.close(io); + if (existing_file) |file| if (!(file.tryLock(io, .exclusive) catch return error.PackWriteFailed)) { + // A concurrent publisher may have installed these exact bytes + // after our initial miss and a reader may already own them. + if (!(file.tryLock(io, .shared) catch return error.PackWriteFailed)) return error.PackWriteFailed; + guard.?.release(); + guard = null; + var reader = file.reader(io, &.{}); + stats.records_read += 1; + const raced = reader.interface.allocRemaining(self.allocator, .limited(1024 * 1024 * 1024)) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.PackWriteFailed, + }; + defer self.allocator.free(raced); + stats.bytes_read += raced.len; + if (std.mem.eql(u8, raced, bytes)) { + if (pins) |held| { + held.appendAssumeCapacity(file); + existing_file = null; + } + return false; + } + return error.PackWriteFailed; + }; + // Pin our staging generation before it becomes visible. The batch + // retains it until every referring metadata record and offer is ready. + var staged_pin = if (pins != null) std.Io.Dir.cwd().openFile(io, staged, .{}) catch return error.PackWriteFailed else null; + defer if (staged_pin) |file| file.close(io); + if (staged_pin) |file| if (!(file.tryLock(io, .shared) catch return error.PackWriteFailed)) return error.PackWriteFailed; self.roc_ctx.rename(staged, path) catch return error.PackWriteFailed; + if (pins) |held| { + held.appendAssumeCapacity(staged_pin.?); + staged_pin = null; + } + stats.records_written += 1; + stats.bytes_written += bytes.len; return true; } @@ -368,6 +501,7 @@ pub const Store = struct { pub fn loadIdentity(self: *const Store, io: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, identity: [32]u8) Allocator.Error!void { const dir = try self.identityDir(origin, identity); defer self.allocator.free(dir); + packs.record_domain = self.domain; try packs.addIndexDir(io, dir); } @@ -1008,10 +1142,12 @@ pub const LoadedPacks = struct { }; const IndexedMetadata = struct { + record: *const base.retained_cache.Record, pack: *PackFile.Pack, hashes: []const [32]u8, - closure: SpliceClosure, - materialized: []bool, + edges: []const Index.Edge, + start: ?u32 = null, + materialized: bool = false, }; const MissKey = struct { key: [32]u8, @@ -1037,9 +1173,17 @@ pub const LoadedPacks = struct { index_roots: std.ArrayList([]const u8) = .empty, provider_ranks: std.StringHashMapUnmanaged(u32) = .empty, metadata: std.AutoHashMapUnmanaged([32]u8, *IndexedMetadata) = .empty, + indexed_pack: ?*PackFile.Pack = null, + indexed_artifacts: std.ArrayList(backend.dev.ProcArtifact.Artifact) = .empty, + indexed_files: std.ArrayList([32]u8) = .empty, + indexed_file_ids: std.AutoHashMapUnmanaged([32]u8, u32) = .empty, negative: std.AutoHashMapUnmanaged(MissKey, void) = .empty, - payloads: std.AutoHashMapUnmanaged([32]u8, []const u8) = .empty, + record_owner: ?*base.retained_cache.Owner = null, + record_domain: [32]u8 = @splat(0), + payloads: std.AutoHashMapUnmanaged([32]u8, *const base.retained_cache.Record) = .empty, + retained_offers: std.AutoHashMapUnmanaged([32]u8, *const base.retained_cache.Record) = .empty, + index_stats: IndexStats = .{}, /// A completed-output probe has priority over procedure discovery. Until /// it misses, compile-time demand uses source bodies and cached values. @@ -1058,11 +1202,11 @@ pub const LoadedPacks = struct { /// Per dev pack, by its artifact set, whether a splice of each artifact /// would place code for a procedure an object pack offers /// (`linked_identities`). - dev_reaches_linked: std.AutoHashMapUnmanaged(*const backend.dev.ProcArtifact.Set, []bool) = .empty, + dev_reaches_linked: std.AutoHashMapUnmanaged(*const backend.dev.ProcArtifact.Set, std.ArrayList(bool)) = .empty, /// Per dev pack and asking program, whether a splice of each artifact /// would place a line or a location in a module version the program does /// not lower against (`linesOutsideProgram`). - lines_outside: std.AutoHashMapUnmanaged(ProgramPack, []bool) = .empty, + lines_outside: std.AutoHashMapUnmanaged(ProgramPack, std.ArrayList(bool)) = .empty, /// Per object pack, whether the program takes one of its entries, so it /// links the pack's object; settled from the lowered program /// (`settleLinkedObjects`). @@ -1092,6 +1236,33 @@ pub const LoadedPacks = struct { state: enum { pending, ready, unavailable } = .pending, failure: ?LoadError = null, + fn recordOwner(self: *LoadedPacks, io: std.Io) Allocator.Error!*base.retained_cache.Owner { + if (self.record_owner) |owner| return owner; + const owner = try base.retained_cache.Owner.create(self.allocator, io, self.record_domain); + self.record_owner = owner; + return owner; + } + + fn reusesOffer(self: *const LoadedPacks, store: *const Store, spec: PackFile.SpecEntry, provider: compile.BuildEnv.PackPlacement, component: [32]u8, ordinal: u32) bool { + const offer = self.retained_offers.get(spec.key) orelse return false; + const old = self.artifacts.get(spec.key) orelse return false; + const origin = old.set.artifacts[old.index].metadata_origin orelse return false; + if (!std.mem.eql(u8, &origin.record.owner.domain, &store.domain) or !std.mem.eql(u8, &origin.record.digest, &component) or origin.index != ordinal) return false; + const hit = self.specs.get(spec.key) orelse return false; + if (hit.rc_borrowed_params != spec.rc_borrowed_params or hit.rc_ret_borrowed != spec.rc_ret_borrowed or + hit.rc_ret_lenders != spec.rc_ret_lenders or hit.rc_read_only_params != spec.rc_read_only_params or + hit.rc_ret_unique != spec.rc_ret_unique or hit.rc_ret_unique_fields != spec.rc_ret_unique_fields or + !std.mem.eql(u32, hit.rc_ret_conditions, spec.rc_ret_conditions) or hit.comptime_root_reads.len != spec.comptime_root_reads.len) return false; + for (hit.comptime_root_reads, spec.comptime_root_reads) |a, b| if (!std.meta.eql(a, b)) return false; + const name = std.fs.path.basename(offer.path); + if (name.len != 133 or !std.mem.eql(u8, name[0..64], &std.fmt.bytesToHex(provider.identity, .lower))) return false; + const bucket = std.fs.path.dirname(offer.path).?; + const offers = std.fs.path.dirname(bucket).?; + const origin_dir = std.fs.path.dirname(offers).?; + return std.mem.eql(u8, std.fs.path.basename(origin_dir), @tagName(provider.origin)) and + std.mem.eql(u8, std.fs.path.dirname(origin_dir).?, store.procedure_root); + } + pub const IndexStats = struct { index_bytes: u64 = 0, bucket_opens: u64 = 0, @@ -1151,53 +1322,217 @@ pub const LoadedPacks = struct { self.refreshIndex(); } + fn releaseRecordPack(context: *anyopaque) void { + const pack: *PackFile.Pack = @ptrCast(@alignCast(context)); + const a = pack.set.arena.child_allocator; + pack.deinit(); + a.destroy(pack); + } + + fn adoptPack(self: *LoadedPacks, value: PackFile.Pack) Allocator.Error!*PackFile.Pack { + const pack = try self.allocator.create(PackFile.Pack); + errdefer self.allocator.destroy(pack); + pack.* = value; + try self.record_owner.?.adopt(.{ .context = pack, .deinit = releaseRecordPack }); + return pack; + } + fn loadMetadata(self: *LoadedPacks, io: std.Io, module_dir: []const u8, hash: [32]u8) Allocator.Error!?*IndexedMetadata { if (self.metadata.get(hash)) |existing| return existing; const allocator = self.allocator; - const name = std.fmt.bytesToHex(hash, .lower) ++ ".rpm".*; - const path = try std.fs.path.join(allocator, &.{ module_dir, "metadata", &name }); + const path = try contentPath(allocator, module_dir, hash, .metadata); defer allocator.free(path); self.index_stats.metadata_opens += 1; - const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024 * 1024)) catch |err| switch (err) { + const owner = try self.recordOwner(io); + const record = owner.acquire(path, hash, .native_component_v1) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, - else => { + error.Corrupt => { + self.index_stats.corrupt += 1; + return null; + }, + error.Unavailable => { self.index_stats.missing += 1; return null; }, }; - defer allocator.free(bytes); - self.bytes_loaded +|= bytes.len; - self.index_stats.index_bytes += bytes.len; - self.index_stats.metadata_bytes += bytes.len; - if (!std.mem.eql(u8, &Index.digest(bytes), &hash)) { - self.index_stats.corrupt += 1; - return null; - } - var decoded = Index.decodeMetadata(allocator, bytes) catch |err| switch (err) { + self.bytes_loaded +|= record.bytes.len; + self.index_stats.index_bytes += record.bytes.len; + self.index_stats.metadata_bytes += record.bytes.len; + var decoded = Index.decodeComponent(allocator, record.bytes) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => { self.index_stats.corrupt += 1; return null; }, }; - errdefer decoded.pack.deinit(); + var adopted = false; + defer if (!adopted) decoded.pack.deinit(); + try self.metadata.ensureUnusedCapacity(allocator, 1); + const entry = try allocator.create(IndexedMetadata); + errdefer allocator.destroy(entry); const pack = try allocator.create(PackFile.Pack); errdefer allocator.destroy(pack); pack.* = decoded.pack; - const entry = try allocator.create(IndexedMetadata); - errdefer allocator.destroy(entry); - var closure = try SpliceClosure.init(allocator, &pack.set); - errdefer closure.deinit(); - const materialized = try allocator.alloc(bool, pack.set.artifacts.len); - errdefer allocator.free(materialized); - @memset(materialized, false); - entry.* = .{ .pack = pack, .hashes = decoded.hashes, .closure = closure, .materialized = materialized }; - try self.packs.ensureUnusedCapacity(allocator, 1); - try self.metadata.put(allocator, hash, entry); - self.packs.appendAssumeCapacity(pack); + // Allocate through the original arena until ownership transfers. + const a = decoded.pack.set.arena.allocator(); + const PA = backend.dev.ProcArtifact; + const origins = try a.alloc(PA.MetadataOrigin, decoded.pack.set.artifacts.len); + for (decoded.pack.set.artifacts, origins, 0..) |artifact, *origin, i| { + const refs = try a.alloc(PA.Binding, artifact.refs.len); + const symbolic = try a.alloc(?PA.Binding, artifact.symbolic_refs.len); + @memset(symbolic, null); + origin.* = .{ .record = record, .source = &pack.set, .index = @intCast(i), .refs = refs, .symbolic = symbolic }; + } + for (decoded.edges) |edge| { + const target = PA.Binding{ .component = edge.target.component orelse hash, .index = edge.target.index }; + const origin = &origins[edge.artifact]; + if (edge.symbolic) @constCast(origin.symbolic)[edge.reference] = target else @constCast(origin.refs)[edge.reference] = target; + } + for (@constCast(decoded.pack.set.artifacts), origins) |*artifact, *origin| artifact.metadata_origin = origin; + pack.* = decoded.pack; + try owner.adopt(.{ .context = pack, .deinit = releaseRecordPack }); + adopted = true; + entry.* = .{ .record = record, .pack = pack, .hashes = decoded.hashes, .edges = decoded.edges }; + self.metadata.putAssumeCapacity(hash, entry); return entry; } + /// Append each immutable component once to a coordinator-owned view. Existing + /// indices never change. Symbolic bindings remain scoped to their producing + /// component, so unrelated historical definitions cannot capture references. + fn appendComponent(self: *LoadedPacks, entry: *IndexedMetadata) (Allocator.Error || error{MalformedPack})!void { + const PA = backend.dev.ProcArtifact; + if (self.indexed_pack == null) { + var value = PackFile.Pack{ .set = .{ .arena = base.SingleThreadArena.init(self.allocator), .artifacts = &.{} }, .specs = &.{} }; + errdefer value.deinit(); + self.indexed_pack = try self.adoptPack(value); + } + const pack = self.indexed_pack.?; + const a = pack.set.arena.allocator(); + defer { + pack.set.artifacts = self.indexed_artifacts.items; + pack.set.source_files = self.indexed_files.items; + } + const start = std.math.cast(u32, self.indexed_artifacts.items.len) orelse return error.OutOfMemory; + _ = std.math.cast(u32, self.indexed_artifacts.items.len + entry.pack.set.artifacts.len) orelse return error.OutOfMemory; + // Validate external indices and symbolic identities before committing. + for (entry.edges) |edge| { + const target_set = if (edge.target.component) |hash| &self.metadata.get(hash).?.pack.set else &entry.pack.set; + if (edge.target.index >= target_set.artifacts.len) return error.MalformedPack; + if (edge.symbolic) { + const target = entry.pack.set.artifacts[edge.artifact].symbolic_refs[edge.reference].target; + const kind = target_set.artifacts[edge.target.index].kind; + const matches = switch (target) { + .proc => |id| kind == .proc and std.meta.eql(id, kind.proc), + .boxy_thunk => |id| kind == .boxy_thunk and std.meta.eql(id, kind.boxy_thunk), + .rc_helper => |name| kind == .rc_helper and std.mem.eql(u8, name, kind.rc_helper), + }; + if (!matches) return error.MalformedPack; + } + } + const files = try a.alloc(u32, entry.pack.set.source_files.len); + for (entry.pack.set.source_files, files) |identity, *mapped| { + const slot = try self.indexed_file_ids.getOrPut(self.allocator, identity); + if (!slot.found_existing) { + errdefer _ = self.indexed_file_ids.remove(identity); + try self.indexed_files.append(a, identity); + slot.value_ptr.* = @intCast(self.indexed_files.items.len - 1); + } + mapped.* = slot.value_ptr.*; + } + const artifacts = try a.dupe(PA.Artifact, entry.pack.set.artifacts); + for (artifacts) |*artifact| { + artifact.refs = try a.dupe(PA.Reference, artifact.refs); + artifact.symbolic_refs = try a.dupe(PA.SymbolicReference, artifact.symbolic_refs); + const lines = try a.dupe(PA.LineEntry, artifact.lines); + for (lines) |*line| line.loc = PA.rebindLoc(line.loc, files); + artifact.lines = lines; + artifact.loc = PA.rebindLoc(artifact.loc, files); + } + for (entry.edges) |edge| { + const base_index = if (edge.target.component) |hash| self.metadata.get(hash).?.start.? else start; + const target = base_index + edge.target.index; + const artifact = &artifacts[edge.artifact]; + if (edge.symbolic) @constCast(artifact.symbolic_refs)[edge.reference].binding = .{ .local = target } else @constCast(artifact.refs)[edge.reference].target = target; + } + try self.indexed_artifacts.appendSlice(a, artifacts); + entry.start = start; + } + + fn ensureGraph(self: *LoadedPacks, io: std.Io, module_dir: []const u8, root: *IndexedMetadata) Allocator.Error!bool { + if (root.start != null) return true; + const Frame = struct { entry: *IndexedMetadata, next: usize = 0 }; + var stack = std.ArrayList(Frame).empty; + defer stack.deinit(self.allocator); + var visiting = std.AutoHashMap(*IndexedMetadata, void).init(self.allocator); + defer visiting.deinit(); + try stack.append(self.allocator, .{ .entry = root }); + try visiting.put(root, {}); + while (stack.items.len != 0) { + const top = &stack.items[stack.items.len - 1]; + if (top.next < top.entry.edges.len) { + const edge = top.entry.edges[top.next]; + top.next += 1; + const hash = edge.target.component orelse continue; + const dependency = try self.loadMetadata(io, module_dir, hash) orelse return false; + if (dependency.start != null) continue; + if ((try visiting.getOrPut(dependency)).found_existing) { + self.index_stats.corrupt += 1; + return false; + } + try stack.append(self.allocator, .{ .entry = dependency }); + continue; + } + self.appendComponent(top.entry) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.MalformedPack => { + self.index_stats.corrupt += 1; + return false; + }, + }; + _ = visiting.remove(top.entry); + _ = stack.pop(); + } + return true; + } + + fn materializeComponent(self: *LoadedPacks, io: std.Io, module_dir: []const u8, root: *IndexedMetadata) Allocator.Error!bool { + if (root.materialized) return true; + const Frame = struct { entry: *IndexedMetadata, next: usize = 0 }; + var stack = std.ArrayList(Frame).empty; + defer stack.deinit(self.allocator); + try stack.append(self.allocator, .{ .entry = root }); + while (stack.items.len != 0) { + const top = &stack.items[stack.items.len - 1]; + if (top.next < top.entry.edges.len) { + const edge = top.entry.edges[top.next]; + top.next += 1; + const hash = edge.target.component orelse continue; + const dependency = self.metadata.get(hash).?; + if (!dependency.materialized) try stack.append(self.allocator, .{ .entry = dependency }); + continue; + } + const entry = top.entry; + for (@constCast(entry.pack.set.artifacts), entry.hashes, 0..) |*artifact, hash, i| { + if (artifact.payload_record == null) { + const record = try self.readPayload(io, module_dir, hash) orelse return false; + Index.materialize(artifact, record.bytes) catch { + self.index_stats.corrupt += 1; + return false; + }; + artifact.payload_record = record; + self.index_stats.decoded_artifacts += 1; + } + const current = &self.indexed_artifacts.items[entry.start.? + i]; + current.code = artifact.code; + current.payload_record = artifact.payload_record; + } + entry.materialized = true; + _ = stack.pop(); + } + return true; + } + /// Probe direct key buckets, then only candidates belonging to visible /// providers. Provider rank remains root-first, independent of disk order. fn findIndexed(self: *LoadedPacks, key: [32]u8, takes: Common.SpecCacheTakes, modules: Common.ProgramModules) Allocator.Error!IndexedResult { @@ -1256,89 +1591,80 @@ pub const LoadedPacks = struct { if (candidate_index != 0) self.index_stats.duplicates += 1; const path = try std.fs.path.join(allocator, &.{ candidate.bucket, candidate.name }); defer allocator.free(path); - const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024)) catch |err| switch (err) { + var offer_digest: [32]u8 = undefined; + _ = std.fmt.hexToBytes(&offer_digest, candidate.name[65..129]) catch { + cacheable = false; + continue; + }; + const owner = try self.recordOwner(io); + const offer_record = owner.acquire(path, offer_digest, .native_offer_v3) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, - else => { + error.Corrupt => { + self.index_stats.corrupt += 1; + cacheable = false; + continue; + }, + error.Unavailable => { self.index_stats.missing += 1; cacheable = false; continue; }, }; - defer allocator.free(bytes); + const bytes = offer_record.bytes; self.bytes_loaded +|= bytes.len; self.index_stats.index_bytes += bytes.len; - if (!std.mem.eql(u8, candidate.name[65..129], &std.fmt.bytesToHex(Index.digest(bytes), .lower))) { - self.index_stats.corrupt += 1; - cacheable = false; - continue; - } - const offer = Index.Offer.decode(bytes) catch { - self.index_stats.corrupt += 1; - cacheable = false; - continue; + var selection = Index.decodeOffer(allocator, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + self.index_stats.corrupt += 1; + cacheable = false; + continue; + }, }; + var adopted = false; + defer if (!adopted) selection.pack.deinit(); const provider_dir = self.index_dirs.items[candidate.rank]; const namespace = std.fs.path.dirname(std.fs.path.dirname(provider_dir).?).?; - const module_dir = try std.fs.path.join(allocator, &.{ namespace, @tagName(offer.origin), &std.fmt.bytesToHex(offer.owner, .lower) }); + const module_dir = try std.fs.path.join(allocator, &.{ namespace, "local", &std.fmt.bytesToHex(@as([32]u8, @splat(0)), .lower) }); defer allocator.free(module_dir); - const metadata = try self.loadMetadata(io, module_dir, offer.metadata) orelse { + const metadata = try self.loadMetadata(io, module_dir, selection.component) orelse { cacheable = false; continue; }; - const pack = metadata.pack; - if (offer.spec >= pack.specs.len or !std.mem.eql(u8, &pack.specs[offer.spec].key, &key)) { + if (selection.index >= metadata.pack.set.artifacts.len or metadata.pack.set.artifacts[selection.index].kind != .proc or !std.mem.eql(u8, &selection.pack.specs[0].key, &key) or + !std.meta.eql(selection.pack.set.artifacts[0].kind.proc, metadata.pack.set.artifacts[selection.index].kind.proc)) + { self.index_stats.corrupt += 1; cacheable = false; continue; } - const spec = pack.specs[offer.spec]; - const program = ProgramPack.of(&pack.set, modules); - const absent = self.lines_outside.get(program) orelse blk: { - const present = try allocator.alloc(bool, pack.set.source_files.len); - defer allocator.free(present); - for (pack.set.source_files, present) |identity, *is_present| is_present.* = Common.programHasModule(modules, identity); - const marks = try backend.dev.ProcArtifact.reachingAbsentFiles(allocator, &pack.set, present); - errdefer allocator.free(marks); - try self.lines_outside.put(allocator, program, marks); - break :blk marks; - }; + if (!try self.ensureGraph(io, module_dir, metadata)) { + cacheable = false; + continue; + } + const pack = self.indexed_pack.?; + var spec = selection.pack.specs[0]; + spec.artifact = metadata.start.? + selection.index; + const absent = try self.sourceMarks(&pack.set, modules); if (absent[spec.artifact]) { self.index_stats.source_ineligible += 1; continue; } if (takes.refusesDevCodeOfLinkedProcs()) { self.ensureLinkedLoaded(); - const linked = self.dev_reaches_linked.get(&pack.set) orelse blk: { - const marks = try backend.dev.ProcArtifact.reachingIdentities(allocator, &pack.set, &self.linked_identities); - errdefer allocator.free(marks); - try self.dev_reaches_linked.put(allocator, &pack.set, marks); - break :blk marks; - }; + const linked = try self.linkedMarks(&pack.set); if (linked[spec.artifact]) { self.index_stats.linked_ineligible += 1; continue; } } - const reached = try metadata.closure.of(spec.artifact); - self.index_stats.requested_artifacts += reached.len; - var complete = true; - for (reached) |index| { - if (metadata.materialized[index]) continue; - const payload_bytes = try self.readPayload(io, module_dir, metadata.hashes[index]) orelse { - complete = false; - cacheable = false; - break; - }; - Index.materialize(&@constCast(pack.set.artifacts)[index], payload_bytes) catch { - self.index_stats.corrupt += 1; - complete = false; - cacheable = false; - break; - }; - metadata.materialized[index] = true; - self.index_stats.decoded_artifacts += 1; + self.index_stats.requested_artifacts += metadata.pack.set.artifacts.len; + if (!try self.materializeComponent(io, module_dir, metadata)) { + cacheable = false; + continue; } - if (!complete) continue; + _ = try self.adoptPack(selection.pack); + adopted = true; try self.artifacts.ensureUnusedCapacity(1); try self.specs.ensureUnusedCapacity(1); _ = self.specs.remove(key); @@ -1347,36 +1673,36 @@ pub const LoadedPacks = struct { error.OutOfMemory => return error.OutOfMemory, else => base.invariant("validated indexed metadata has a procedure root", .{}), }; - self.index_stats.served_artifacts += reached.len; + try self.retained_offers.put(allocator, key, offer_record); + self.index_stats.served_artifacts += metadata.pack.set.artifacts.len; return .hit; } return if (cacheable) .miss else .unavailable; } - fn readPayload(self: *LoadedPacks, io: std.Io, module_dir: []const u8, hash: [32]u8) Allocator.Error!?[]const u8 { - if (self.payloads.get(hash)) |bytes| return bytes; + fn readPayload(self: *LoadedPacks, io: std.Io, module_dir: []const u8, hash: [32]u8) Allocator.Error!?*const base.retained_cache.Record { + if (self.payloads.get(hash)) |record| return record; const name = std.fmt.bytesToHex(hash, .lower) ++ ".rpa".*; const path = try std.fs.path.join(self.allocator, &.{ module_dir, "payload", &name }); defer self.allocator.free(path); self.index_stats.payload_opens += 1; - const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, self.allocator, .limited(1024 * 1024 * 1024)) catch |err| switch (err) { + const owner = try self.recordOwner(io); + const record = owner.acquire(path, hash, .native_payload_v1) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, - else => { + error.Corrupt => { + self.index_stats.corrupt += 1; + return null; + }, + error.Unavailable => { self.index_stats.missing += 1; return null; }, }; - errdefer self.allocator.free(bytes); - self.bytes_loaded +|= bytes.len; - self.index_stats.payload_bytes += bytes.len; - if (!std.mem.eql(u8, &Index.digest(bytes), &hash)) { - self.allocator.free(bytes); - self.index_stats.corrupt += 1; - return null; - } - try self.payloads.put(self.allocator, hash, bytes); + self.bytes_loaded +|= record.bytes.len; + self.index_stats.payload_bytes += record.bytes.len; + try self.payloads.put(self.allocator, hash, record); self.index_stats.payload_files += 1; - return bytes; + return record; } pub const LoadError = Allocator.Error || PackFile.ReadError || error{PackDirectoryUnreadable}; @@ -1768,6 +2094,7 @@ pub const LoadedPacks = struct { pub fn deinit(self: *LoadedPacks) void { if (base.dev_diagnostics.isSet(.pack_stats)) { const stats = self.index_stats; + if (self.record_owner) |owner| std.debug.print("pack acquisitions: {any}\n", .{owner.metrics}); std.debug.print("pack index: index_bytes={d} bucket_opens={d} bucket_absent={d} negative_hits={d} metadata_opens={d} metadata_bytes={d} candidates={d} duplicates={d} payload_opens={d} payload_files={d} payload_bytes={d} decoded_artifacts={d} requested_artifacts={d} served_artifacts={d} allocation_failures={d} requested={d} served={d} misses={d} source_ineligible={d} linked_ineligible={d} corrupt={d} missing={d}\n", .{ stats.index_bytes, stats.bucket_opens, stats.bucket_absent, stats.negative_hits, stats.metadata_opens, stats.metadata_bytes, stats.candidates, stats.duplicates, stats.payload_opens, stats.payload_files, stats.payload_bytes, stats.decoded_artifacts, stats.requested_artifacts, stats.served_artifacts, stats.allocation_failures, stats.requested, stats.served, stats.misses, stats.source_ineligible, stats.linked_ineligible, stats.corrupt, stats.missing }); } var interface_families = self.interface_families.valueIterator(); @@ -1782,16 +2109,15 @@ pub const LoadedPacks = struct { self.index_roots.deinit(self.allocator); var metadata = self.metadata.valueIterator(); while (metadata.next()) |entry| { - entry.*.closure.deinit(); - self.allocator.free(entry.*.materialized); self.allocator.destroy(entry.*); } self.metadata.deinit(self.allocator); + self.indexed_file_ids.deinit(self.allocator); for (self.index_dirs.items) |dir| self.allocator.free(dir); self.index_dirs.deinit(self.allocator); - var payloads = self.payloads.valueIterator(); - while (payloads.next()) |bytes| self.allocator.free(bytes.*); self.payloads.deinit(self.allocator); + self.retained_offers.deinit(self.allocator); + defer if (self.record_owner) |owner| owner.release(); var settlements = self.discovery.valueIterator(); while (settlements.next()) |list| list.deinit(self.allocator); self.discovery.deinit(self.allocator); @@ -1799,10 +2125,10 @@ pub const LoadedPacks = struct { self.linked_specs.deinit(); self.linked_identities.deinit(self.allocator); var reaching = self.dev_reaches_linked.valueIterator(); - while (reaching.next()) |marks| self.allocator.free(marks.*); + while (reaching.next()) |marks| marks.deinit(self.allocator); self.dev_reaches_linked.deinit(self.allocator); var outside = self.lines_outside.valueIterator(); - while (outside.next()) |marks| self.allocator.free(marks.*); + while (outside.next()) |marks| marks.deinit(self.allocator); self.lines_outside.deinit(self.allocator); for (self.object_packs.items) |*pack| pack.deinit(); self.object_packs.deinit(self.allocator); @@ -2051,43 +2377,52 @@ pub const LoadedPacks = struct { /// location in a module version outside `modules`. fn linesOutsideProgram(self: *LoadedPacks, key: [32]u8, modules: Common.ProgramModules) bool { const located = self.artifacts.get(key) orelse return false; - const set = located.set; - var all_present = true; - for (set.source_files) |identity| all_present = all_present and Common.programHasModule(modules, identity); - if (all_present) return false; - const program_pack = ProgramPack.of(set, modules); - if (self.lines_outside.get(program_pack)) |marks| return marks[located.index]; - // Refusing the entry is always sound: the program compiles the - // procedure itself. - const present = self.allocator.alloc(bool, set.source_files.len) catch return true; - defer self.allocator.free(present); - for (set.source_files, present) |identity, *is_present| is_present.* = Common.programHasModule(modules, identity); - const marks = backend.dev.ProcArtifact.reachingAbsentFiles(self.allocator, set, present) catch return true; - self.lines_outside.put(self.allocator, program_pack, marks) catch { - const outside = marks[located.index]; - self.allocator.free(marks); - return outside; - }; + const marks = self.sourceMarks(located.set, modules) catch return true; return marks[located.index]; } - /// Whether the code a splice of the dev entry under `key` places - /// includes a procedure some object pack offers: the entry's own - /// procedure, or one its code reaches through the pack. + fn sourceMarks(self: *LoadedPacks, set: *const backend.dev.ProcArtifact.Set, modules: Common.ProgramModules) Allocator.Error![]bool { + const slot = try self.lines_outside.getOrPut(self.allocator, ProgramPack.of(set, modules)); + if (!slot.found_existing) slot.value_ptr.* = .empty; + const first = slot.value_ptr.items.len; + if (first == set.artifacts.len) return slot.value_ptr.items; + // Keep the old prefix on allocation failure; retry only the new suffix. + try slot.value_ptr.resize(self.allocator, set.artifacts.len); + errdefer slot.value_ptr.items.len = first; + const marks = slot.value_ptr.items; + for (set.artifacts[first..], marks[first..]) |artifact, *marked| { + marked.* = artifact.loc.hasLocation() and !Common.programHasModule(modules, set.source_files[artifact.loc.file]); + for (artifact.lines) |line| { + if (line.loc.hasLocation() and !Common.programHasModule(modules, set.source_files[line.loc.file])) marked.* = true; + } + } + try backend.dev.ProcArtifact.markNewReferrers(self.allocator, set, marks, first, .skip); + return marks; + } + + fn linkedMarks(self: *LoadedPacks, set: *const backend.dev.ProcArtifact.Set) Allocator.Error![]bool { + const slot = try self.dev_reaches_linked.getOrPut(self.allocator, set); + if (!slot.found_existing) slot.value_ptr.* = .empty; + const first = slot.value_ptr.items.len; + if (first == set.artifacts.len) return slot.value_ptr.items; + try slot.value_ptr.resize(self.allocator, set.artifacts.len); + errdefer slot.value_ptr.items.len = first; + const marks = slot.value_ptr.items; + for (set.artifacts[first..], marks[first..]) |artifact, *marked| { + marked.* = artifact.kind == .proc and self.linked_identities.contains(artifact.kind.proc); + for (artifact.symbolic_refs) |ref| { + if (ref.target == .proc and self.linked_identities.contains(ref.target.proc)) marked.* = true; + } + } + try backend.dev.ProcArtifact.markNewReferrers(self.allocator, set, marks, first, .follow); + return marks; + } + + /// Whether a dev entry places code supplied by a linked object. fn devEntryReachesLinked(self: *LoadedPacks, key: [32]u8) bool { if (self.linked_identities.count() == 0) return false; const located = self.artifacts.get(key) orelse return false; - const marks = self.dev_reaches_linked.get(located.set) orelse marks: { - // Refusing the entry is always sound: the program compiles the - // procedure itself. - const computed = backend.dev.ProcArtifact.reachingIdentities(self.allocator, located.set, &self.linked_identities) catch return true; - self.dev_reaches_linked.put(self.allocator, located.set, computed) catch { - const reaches = computed[located.index]; - self.allocator.free(computed); - return reaches; - }; - break :marks computed; - }; + const marks = self.linkedMarks(located.set) catch return true; return marks[located.index]; } @@ -2336,7 +2671,7 @@ const IndexFixture = struct { fn payloadPath(self: *@This(), artifact_value: backend.dev.ProcArtifact.Artifact) Allocator.Error![]u8 { const bytes = try Index.payload(allocator, artifact_value); defer allocator.free(bytes); - const dir = try self.store.identityDir(.local, identity); + const dir = try self.store.identityDir(.local, @splat(0)); defer allocator.free(dir); const name = std.fmt.bytesToHex(Index.digest(bytes), .lower) ++ ".rpa".*; return std.fs.path.join(allocator, &.{ dir, "payload", &name }); @@ -2370,11 +2705,12 @@ test "indexed object cache reads only requested closure regardless of unrelated try std.testing.expectEqual(@as(u32, 9), hit.comptime_root_reads[0].root); try std.testing.expectEqualSlices(u32, &.{1}, hit.rc_ret_conditions); const located = first.artifacts.get(spec.key).?; - try std.testing.expectEqual(@as(usize, 4), located.set.artifacts.len); + try std.testing.expectEqual(@as(usize, 3), located.set.artifacts.len); try std.testing.expectEqualStrings("constant bytes", located.set.artifacts[located.index].data[0].bytes); try std.testing.expectEqual(@as(u32, 4), located.set.artifacts[located.index].lines[0].loc.line); - try std.testing.expectEqualStrings(located.set.artifacts[1].data[0].name, located.set.artifacts[located.index].data[0].relocations[0].name); - try std.testing.expect(std.mem.startsWith(u8, located.set.artifacts[1].data[0].name, "roc__h")); + const dependency = located.set.artifacts[located.set.artifacts[located.index].refs[0].target]; + try std.testing.expectEqualStrings(dependency.data[0].name, located.set.artifacts[located.index].data[0].relocations[0].name); + try std.testing.expect(std.mem.startsWith(u8, dependency.data[0].name, "roc__h")); try std.testing.expectEqual(@as(u64, 3), first.index_stats.payload_files); const before = first.index_stats; _ = first.specCacheLookup().lookup(spec.key, .dev_code, &.{module}).?; @@ -2420,7 +2756,8 @@ test "indexed object cache selects eligible provenance deterministically and reu defer packs.deinit(); for ([_]usize{ 0, 1, 0 }) |version| { _ = packs.specCacheLookup().lookup(spec.key, .dev_code, files[version .. version + 1]) orelse return error.TestUnexpectedResult; - try std.testing.expectEqualSlices([32]u8, files[version .. version + 1], packs.artifacts.get(spec.key).?.set.source_files); + const selected = packs.artifacts.get(spec.key).?; + try std.testing.expectEqual(files[version], selected.set.source_files[selected.set.artifacts[selected.index].loc.file]); } try std.testing.expectEqual(@as(u64, 1), packs.index_stats.payload_opens); try std.testing.expect(packs.index_stats.source_ineligible > 0); @@ -2468,7 +2805,7 @@ test "indexed object cache requires complete intact payloads and owns hits acros try fixture.tmp.dir.deleteTree(std.testing.io, "local"); _ = pinned.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; const located = pinned.artifacts.get(spec.key).?; - try std.testing.expectEqualStrings("dependency", located.set.artifacts[1].code); + try std.testing.expectEqualStrings("dependency", located.set.artifacts[located.set.artifacts[located.index].refs[0].target].code); } test "indexed object cache source eligibility follows local dependencies but not site-named symbolic procedures" { @@ -2581,7 +2918,7 @@ test "indexed object cache rejects corrupt offers and shared metadata before rea const artifacts = [_]backend.dev.ProcArtifact.Artifact{F.artifact(1, "code")}; const specs = [_]PackFile.SpecEntry{F.spec(1, 0)}; try fixture.publish(&artifacts, &specs, &.{}); - const module_dir = try fixture.store.identityDir(.local, F.identity); + const module_dir = try fixture.store.identityDir(.local, @splat(0)); defer F.allocator.free(module_dir); const bucket = if (shared_metadata) try std.fs.path.join(F.allocator, &.{ module_dir, "metadata" }) @@ -2668,7 +3005,7 @@ test "indexed object cache misses probe keys once across many providers and refr try std.testing.expectEqual(@as(u64, 2), packs.index_stats.bucket_opens); } -test "indexed object cache shares producer metadata and verifies immutable records before skipping writes" { +test "indexed object cache shares payloads and verifies immutable records before skipping writes" { const F = IndexFixture; var fixture: F = undefined; try fixture.init(); @@ -2691,10 +3028,13 @@ test "indexed object cache shares producer metadata and verifies immutable recor defer packs.deinit(); _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; _ = packs.specCacheLookup().lookup(@splat(2), .dev_code, &.{}) orelse return error.TestUnexpectedResult; - try std.testing.expectEqual(@as(u64, 1), packs.index_stats.metadata_opens); + try std.testing.expectEqual(@as(u64, 3), packs.index_stats.metadata_opens); try std.testing.expectEqual(@as(u64, 2), packs.index_stats.payload_files); try std.testing.expectEqual(@as(u64, 3), packs.index_stats.decoded_artifacts); - try std.testing.expectEqual(packs.artifacts.get(@splat(1)).?.set, packs.artifacts.get(@splat(2)).?.set); + const left = packs.artifacts.get(@splat(1)).?; + const right = packs.artifacts.get(@splat(2)).?; + try std.testing.expectEqual(left.set, right.set); + try std.testing.expectEqual(left.set.artifacts[left.index].payload_record, right.set.artifacts[right.index].payload_record); } test "indexed object cache routes a package offer to producer storage outside its visible providers" { @@ -2776,3 +3116,338 @@ test "persistent interface families invalidate misses, share records, and reject defer allocator.free(value); try std.testing.expectEqualStrings("repaired", value); } + +test "native publication reuses acquired payloads and offers across a one-caller edit" { + const a = std.testing.allocator; + const F = IndexFixture; + const count = 48; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var stats: Store.PublicationStats = .{}; + fixture.store.publication_stats = &stats; + var artifacts: [count + 1]backend.dev.ProcArtifact.Artifact = undefined; + var specs: [count]PackFile.SpecEntry = undefined; + const ref = backend.dev.ProcArtifact.Reference{ .site = 0, .form = .call, .target = count, .delta = 0 }; + for (artifacts[0..count], &specs, 0..) |*artifact, *spec, i| { + artifact.* = F.artifact(@intCast(i + 1), "unchanged caller"); + artifact.refs = &.{ref}; + spec.* = F.spec(@intCast(i + 1), @intCast(i)); + } + artifacts[count] = F.artifact(100, "shared leaf"); + try fixture.publish(&artifacts, &specs, &.{}); + try std.testing.expectEqual(@as(u64, count + 1), stats.payloads_encoded); + try std.testing.expectEqual(@as(u64, count + 1), stats.metadata_encoded); + // One graph inventory and one union walk, independent of caller count. + try std.testing.expectEqual(@as(u64, (count + 1) * 2), stats.graph_nodes); + try std.testing.expectEqual(@as(u64, count * 2), stats.graph_edges); + + var loaded = try fixture.loader(); + defer loaded.deinit(); + for (specs) |spec| _ = loaded.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u64, count + 1), loaded.index_stats.decoded_artifacts); + var combined = try backend.dev.ProcArtifact.combineDistinct(a, &.{loaded.artifacts.get(specs[0].key).?.set}); + defer combined.deinit(); + var closure = try SpliceClosure.init(a, &combined); + defer closure.deinit(); + for (&specs, 0..) |*spec, i| spec.artifact = closure.offeredProcArtifact(lir.ProcIdentity.forTest(@intCast(i + 1))).?; + stats = .{}; + try fixture.store.writeIndexedRetained(.local, F.identity, &combined, &specs, null, &loaded); + try std.testing.expectEqual(@as(u64, count), stats.offers_reused); + try std.testing.expectEqual(@as(u64, 0), stats.payloads_encoded); + try std.testing.expectEqual(@as(u64, 0), stats.metadata_encoded); + try std.testing.expectEqual(@as(u64, 0), stats.bytes_hashed); + try std.testing.expectEqual(@as(u64, 0), stats.records_read); + try std.testing.expectEqual(@as(u64, 0), stats.records_written); + try std.testing.expectEqual(@as(u64, (count + 1) * 2), stats.graph_nodes); + + // A byte-changing producer explicitly clears the immutable payload identity. + const changed = &@constCast(combined.artifacts)[specs[0].artifact]; + changed.payload_record = null; + changed.code = "edited caller"; + specs[0].key = @splat(200); + stats = .{}; + try fixture.store.writeIndexedRetained(.local, F.identity, &combined, &specs, null, &loaded); + try std.testing.expectEqual(@as(u64, count - 1), stats.offers_reused); + try std.testing.expectEqual(@as(u64, 1), stats.payloads_encoded); + try std.testing.expectEqual(@as(u64, 1), stats.metadata_encoded); + try std.testing.expectEqual(@as(u64, 2 * count + 1), stats.validated_references); + try std.testing.expectEqual(@as(u64, (count + 1) * 2), stats.graph_nodes); + try std.testing.expectEqual(@as(u64, 3), stats.records_written); + var fresh = try fixture.loader(); + defer fresh.deinit(); + for (specs) |spec| _ = fresh.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + const edited = fresh.artifacts.get(specs[0].key).?; + try std.testing.expectEqualStrings("edited caller", edited.set.artifacts[edited.index].code); +} + +test "native publication unrelated offers do not change existing records" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var stats: Store.PublicationStats = .{}; + fixture.store.publication_stats = &stats; + const a = F.artifact(1, "one"); + const b = F.artifact(2, "two"); + try fixture.publish(&.{a}, &.{F.spec(1, 0)}, &.{}); + stats = .{}; + try fixture.publish(&.{ b, a }, &.{ F.spec(2, 0), F.spec(1, 1) }, &.{}); + try std.testing.expectEqual(@as(u64, 3), stats.records_written); + stats = .{}; + try fixture.publish(&.{ a, b }, &.{ F.spec(1, 0), F.spec(2, 1) }, &.{}); + try std.testing.expectEqual(@as(u64, 0), stats.records_written); + stats = .{}; + try fixture.publish(&.{a}, &.{F.spec(1, 0)}, &.{}); + try std.testing.expectEqual(@as(u64, 0), stats.records_written); +} + +test "native publication retained files survive cleanup until their last owner ends" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const artifact = F.artifact(1, "immutable bytes"); + const spec = F.spec(1, 0); + try fixture.publish(&.{artifact}, &.{spec}, &.{}); + var loaded = try fixture.loader(); + var live = true; + defer if (live) loaded.deinit(); + _ = loaded.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + const source = loaded.artifacts.get(spec.key).?; + var copy = try backend.dev.ProcArtifact.combine(F.allocator, &.{source.set}); + var copy_live = true; + defer if (copy_live) copy.deinit(); + const path = try fixture.payloadPath(artifact); + defer F.allocator.free(path); + loaded.deinit(); + live = false; + try std.testing.expect(!base.retained_cache.remove(std.Io.Dir.cwd(), std.testing.io, path)); + try std.testing.expectEqualStrings("immutable bytes", copy.artifacts[0].code); + copy.deinit(); + copy_live = false; + try std.testing.expect(base.retained_cache.remove(std.Io.Dir.cwd(), std.testing.io, path)); + var missing = try fixture.loader(); + defer missing.deinit(); + try std.testing.expect(missing.specCacheLookup().lookup(spec.key, .dev_code, &.{}) == null); +} + +test "native publication allocation failures release temporary records and retained owners" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const Check = struct { + fn publish(failing: Allocator) !void { + var fresh: F = undefined; + try fresh.init(); + defer fresh.deinit(); + var writer = fresh.store; + writer.allocator = failing; + try writer.writeIndexed(.local, F.identity, &.{ .arena = undefined, .artifacts = &.{F.artifact(1, "code")} }, &.{F.spec(1, 0)}); + } + fn load(failing: Allocator, store: *const Store) !void { + var packs = LoadedPacks.init(failing); + defer packs.deinit(); + try store.loadIdentity(std.testing.io, &packs, .local, F.identity); + try packs.indexPacks(); + try std.testing.expectEqual(LoadedPacks.IndexedResult.hit, try packs.findIndexed(@splat(1), .dev_code, &.{})); + } + fn copy(failing: Allocator, set: *const backend.dev.ProcArtifact.Set) !void { + var owned = try backend.dev.ProcArtifact.combine(failing, &.{set}); + defer owned.deinit(); + try std.testing.expectEqual(set.artifacts[0].payload_record, owned.artifacts[0].payload_record); + } + }; + std.testing.checkAllAllocationFailures(F.allocator, Check.publish, .{}) catch |err| { + std.debug.print("publication allocation check: {}\n", .{err}); + return err; + }; + try fixture.publish(&.{F.artifact(1, "code")}, &.{F.spec(1, 0)}, &.{}); + try std.testing.checkAllAllocationFailures(F.allocator, Check.load, .{&fixture.store}); + var loaded = try fixture.loader(); + defer loaded.deinit(); + _ = loaded.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + std.testing.checkAllAllocationFailures(F.allocator, Check.copy, .{loaded.artifacts.get(@splat(1)).?.set}) catch |err| { + std.debug.print("copy allocation check: {}\n", .{err}); + return err; + }; +} + +test "native publication union visits shared cyclic dependencies once" { + const F = IndexFixture; + const PA = backend.dev.ProcArtifact; + const count = 128; + var artifacts: [count]PA.Artifact = undefined; + var refs: [count]PA.Reference = undefined; + var specs: [count]PackFile.SpecEntry = undefined; + for (&artifacts, &refs, &specs, 0..) |*artifact, *ref, *spec, i| { + ref.* = .{ .site = 0, .form = .call, .target = if (i == 0) 1 else 0, .delta = 0 }; + artifact.* = F.artifact(@intCast(i), "code"); + artifact.refs = @as(*const [1]PA.Reference, ref)[0..]; + spec.* = F.spec(@intCast(i), @intCast(i)); + } + var closure = try SpliceClosure.init(F.allocator, &.{ .arena = undefined, .artifacts = &artifacts }); + defer closure.deinit(); + const union_indices = try closure.ofEntries(&specs); + try std.testing.expectEqual(@as(usize, count), union_indices.len); + try std.testing.expectEqual(@as(usize, count), closure.nodes_visited); + try std.testing.expectEqual(@as(usize, count), closure.edges_visited); + try std.testing.expectEqual(@as(usize, 1), closure.queries); +} + +test "native publication shares a cyclic component and invalidates exact dependency endpoints" { + const F = IndexFixture; + const PA = backend.dev.ProcArtifact; + const callers = 32; + const cycle = 16; + const count = callers + cycle; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var stats: Store.PublicationStats = .{}; + fixture.store.publication_stats = &stats; + var artifacts: [count]PA.Artifact = undefined; + var refs: [count]PA.Reference = undefined; + var specs: [callers]PackFile.SpecEntry = undefined; + for (&artifacts, &refs, 0..) |*artifact, *ref, i| { + artifact.* = F.artifact(@intCast(i), "identical payload"); + ref.* = .{ .site = 0, .form = .call, .target = if (i < callers) callers else @intCast(callers + (i - callers + 1) % cycle), .delta = 0 }; + artifact.refs = @as(*const [1]PA.Reference, ref)[0..]; + } + for (&specs, 0..) |*spec, i| spec.* = F.spec(@intCast(i), @intCast(i)); + try fixture.publish(&artifacts, &specs, &.{}); + try std.testing.expectEqual(@as(u64, callers + 1), stats.metadata_encoded); + try std.testing.expectEqual(@as(u64, 2 * count), stats.graph_nodes); + try std.testing.expectEqual(@as(u64, 2 * count), stats.graph_edges); + var loaded = try fixture.loader(); + defer loaded.deinit(); + for (specs) |spec| _ = loaded.specCacheLookup().lookup(spec.key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + const set = loaded.artifacts.get(specs[0].key).?.set; + for (specs) |spec| try std.testing.expectEqual(set, loaded.artifacts.get(spec.key).?.set); + try std.testing.expectEqual(@as(usize, count), set.artifacts.len); + try std.testing.expectEqual(@as(u64, count), loaded.index_stats.decoded_artifacts); + try std.testing.expectEqual(@as(u64, callers + 1), loaded.index_stats.metadata_opens); + try std.testing.expectEqual(@as(u64, 1), loaded.index_stats.payload_files); + var owned = try PA.combine(F.allocator, &.{set}); + defer owned.deinit(); + for (&specs) |*spec| spec.artifact = loaded.artifacts.get(spec.key).?.index; + stats = .{}; + try fixture.store.writeIndexedRetained(.local, F.identity, &owned, &specs, null, &loaded); + try std.testing.expectEqual(@as(u64, 0), stats.bytes_hashed); + try std.testing.expectEqual(@as(u64, 0), stats.records_read); + try std.testing.expectEqual(@as(u64, 0), stats.records_written); + // Identical code and payload identity do not authorize an old graph edge. + const root = &@constCast(owned.artifacts)[specs[0].artifact]; + const previous = root.refs[0].target; + const next = owned.artifacts[previous].refs[0].target; + @constCast(root.refs)[0].target = next; + specs[0].key = @splat(200); + stats = .{}; + try fixture.store.writeIndexedRetained(.local, F.identity, &owned, &specs, null, &loaded); + try std.testing.expectEqual(@as(u64, 0), stats.payloads_encoded); + try std.testing.expectEqual(@as(u64, 1), stats.metadata_encoded); + try std.testing.expectEqual(@as(u64, callers - 1), stats.offers_reused); + try std.testing.expectEqual(@as(u64, 2), stats.records_written); + var fresh = try fixture.loader(); + defer fresh.deinit(); + _ = fresh.specCacheLookup().lookup(specs[0].key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + const hit = fresh.artifacts.get(specs[0].key).?; + const dependency = hit.set.artifacts[hit.set.artifacts[hit.index].refs[0].target]; + try std.testing.expectEqual(owned.artifacts[next].kind.proc, dependency.kind.proc); +} + +test "native publication pins dependencies until all offers are visible" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const payload_path = try fixture.payloadPath(F.artifact(1, "code")); + defer F.allocator.free(payload_path); + const Cleaner = struct { + payload: []const u8, + attempts: usize = 0, + write: @TypeOf(@as(CoreCtx, undefined).vtable.writeTempFile), + fn stage(ctx: ?*anyopaque, io: std.Io, allocator: Allocator, path: []const u8, bytes: []const u8) (Allocator.Error || CoreCtx.WriteError)![]u8 { + const self: *@This() = @ptrCast(@alignCast(ctx.?)); + if (std.mem.endsWith(u8, path, ".rpm") or std.mem.endsWith(u8, path, ".rpi")) { + self.attempts += 1; + if (base.retained_cache.remove(std.Io.Dir.cwd(), io, self.payload)) return error.AccessDenied; + } + return self.write(null, io, allocator, path, bytes); + } + }; + var cleaner = Cleaner{ .payload = payload_path, .write = fixture.store.roc_ctx.vtable.writeTempFile }; + fixture.store.roc_ctx.ctx = &cleaner; + fixture.store.roc_ctx.vtable.writeTempFile = Cleaner.stage; + try fixture.publish(&.{F.artifact(1, "code")}, &.{F.spec(1, 0)}, &.{}); + try std.testing.expectEqual(@as(usize, 2), cleaner.attempts); + try std.testing.expect(base.retained_cache.remove(std.Io.Dir.cwd(), std.testing.io, payload_path)); +} + +test "native publication failure at every record boundary exposes only complete offers" { + const F = IndexFixture; + const Fail = struct { + suffix: []const u8, + fn rename(ctx: ?*anyopaque, io: std.Io, old: []const u8, new: []const u8) CoreCtx.RenameError!void { + const self: *const @This() = @ptrCast(@alignCast(ctx.?)); + if (std.mem.endsWith(u8, new, self.suffix)) return error.AccessDenied; + try std.Io.Dir.cwd().rename(old, std.Io.Dir.cwd(), new, io); + } + }; + for ([_][]const u8{ ".rpa", ".rpm", ".rpi" }) |suffix| { + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var fail = Fail{ .suffix = suffix }; + fixture.store.roc_ctx.ctx = &fail; + fixture.store.roc_ctx.vtable.rename = Fail.rename; + try std.testing.expectError(error.PackWriteFailed, fixture.publish(&.{F.artifact(1, "code")}, &.{F.spec(1, 0)}, &.{})); + var packs = try fixture.loader(); + defer packs.deinit(); + try std.testing.expect(packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) == null); + try std.testing.expect(!try fixture.store.hasIndexed(.local, F.identity, @splat(1))); + } +} + +test "native publication preserves symbolic bindings across historical definitions" { + const F = IndexFixture; + const PA = backend.dev.ProcArtifact; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var first = F.artifact(1, "first caller"); + var second = F.artifact(3, "second caller"); + first.symbolic_refs = &.{.{ .site = 0, .form = .call, .target = .{ .proc = lir.ProcIdentity.forTest(2) } }}; + second.symbolic_refs = first.symbolic_refs; + try fixture.publish(&.{ first, F.artifact(2, "old definition") }, &.{F.spec(1, 0)}, &.{}); + try fixture.publish(&.{ second, F.artifact(2, "new definition") }, &.{F.spec(3, 0)}, &.{}); + var loaded = try fixture.loader(); + defer loaded.deinit(); + for ([_]u8{ 3, 1 }) |key| _ = loaded.specCacheLookup().lookup(@splat(key), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + const old = loaded.artifacts.get(@splat(1)).?; + const new = loaded.artifacts.get(@splat(3)).?; + try std.testing.expectEqual(old.set, new.set); + const old_target = old.set.symbolicDefinition(old.set.artifacts[old.index].symbolic_refs[0]).?; + const new_target = new.set.symbolicDefinition(new.set.artifacts[new.index].symbolic_refs[0]).?; + try std.testing.expectEqualStrings("old definition", old.set.artifacts[old_target].code); + try std.testing.expectEqualStrings("new definition", new.set.artifacts[new_target].code); + var captured = try PA.combine(F.allocator, &.{old.set}); + defer captured.deinit(); + var stats: Store.PublicationStats = .{}; + fixture.store.publication_stats = &stats; + const specs = [_]PackFile.SpecEntry{ F.spec(1, old.index), F.spec(3, new.index) }; + try fixture.store.writeIndexedRetained(.local, F.identity, &captured, &specs, null, &loaded); + try std.testing.expectEqual(@as(u64, 0), stats.bytes_hashed); + try std.testing.expectEqual(@as(u64, 0), stats.records_written); + // Rebinding a stable identity to different code preserves the payload but + // must change this caller's component and selection manifest. + @constCast(captured.artifacts[old.index].symbolic_refs)[0].binding = .{ .local = new_target }; + stats = .{}; + var changed = specs[0]; + changed.key = @splat(4); + try fixture.store.writeIndexedRetained(.local, F.identity, &captured, &.{changed}, null, &loaded); + try std.testing.expectEqual(@as(u64, 0), stats.payloads_encoded); + try std.testing.expectEqual(@as(u64, 1), stats.metadata_encoded); + try std.testing.expectEqual(@as(u64, 2), stats.records_written); +} diff --git a/src/compile/cache_cleanup.zig b/src/compile/cache_cleanup.zig index ecb111b32fe..d011577ec5e 100644 --- a/src/compile/cache_cleanup.zig +++ b/src/compile/cache_cleanup.zig @@ -293,7 +293,11 @@ fn cleanupObjectPacks(std_io: Io, cache_base: []const u8, format_version: u32, n if (namespace_entry.kind != .directory) continue; if (!std.mem.startsWith(u8, namespace_entry.name, cache_config.object_pack_namespace_prefix)) continue; if (!std.mem.eql(u8, namespace_entry.name, current_namespace)) { - version_dir.deleteTree(std_io, namespace_entry.name) catch { + // Another live compiler may still own records in an older + // format. Remove only unpinned files, preserving stable locks. + purgeUnpinned(std_io, version_dir, namespace_entry.name); + version_dir.deleteDir(std_io, namespace_entry.name) catch |err| { + if (err == error.DirNotEmpty) continue; if (maybe_stats) |stats| stats.errors += 1; continue; }; @@ -307,6 +311,20 @@ fn cleanupObjectPacks(std_io: Io, cache_base: []const u8, format_version: u32, n } } +fn purgeUnpinned(io: Io, parent: Dir, name: []const u8) void { + var dir = parent.openDir(io, name, .{ .iterate = true }) catch return; + defer dir.close(io); + var it = dir.iterate(); + while (it.next(io) catch null) |entry| { + if (entry.kind == .directory) { + purgeUnpinned(io, dir, entry.name); + dir.deleteDir(io, entry.name) catch {}; + } else if (entry.kind == .file and !std.mem.endsWith(u8, entry.name, ".lock")) { + _ = @import("base").retained_cache.remove(dir, io, entry.name); + } + } +} + fn cleanupPackNamespace(std_io: Io, namespace_dir: Dir, now_ns: i128, maybe_stats: ?*CleanupStats) void { var mode_it = namespace_dir.iterate(); while (true) { @@ -356,13 +374,10 @@ fn ageFilesIn(std_io: Io, dir: Dir, max_age_ns: i96, now_ns: i128, maybe_stats: ageFilesIn(std_io, child, max_age_ns, now_ns, maybe_stats); continue; } - if (file_entry.kind != .file) continue; + if (file_entry.kind != .file or std.mem.endsWith(u8, file_entry.name, ".lock")) continue; const last_use = lastUseNs(dir, std_io, file_entry.name) orelse continue; if (now_ns - last_use <= max_age_ns) continue; - dir.deleteFile(std_io, file_entry.name) catch { - if (maybe_stats) |stats| stats.errors += 1; - continue; - }; + if (!@import("base").retained_cache.remove(dir, std_io, file_entry.name)) continue; if (maybe_stats) |stats| stats.cache_files_deleted += 1; } } @@ -404,10 +419,7 @@ fn enforceSizeLimit(std_io: Io, cache_base: []const u8, limits: Limits, maybe_st std.mem.sort(SizedFile, files.items, {}, SizedFile.lessThan); for (files.items) |file| { if (total <= limits.max_size_bytes) break; - base_dir.deleteFile(std_io, file.path) catch { - if (maybe_stats) |stats| stats.errors += 1; - continue; - }; + if (!@import("base").retained_cache.remove(base_dir, std_io, file.path)) continue; total -= file.size; if (maybe_stats) |stats| stats.evicted_files_deleted += 1; } @@ -951,3 +963,33 @@ test "size bound evicts least recently used files across families until under th try exe.access(std.testing.io, "newest", .{}); try exe.access(std.testing.io, "newest.lock", .{}); } + +test "object pack sweep respects native record ownership during age size and format eviction" { + const a = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", a); + defer a.free(root); + const dir = try std.fs.path.join(a, &.{ root, "compiler", "objects-v16", "proc-test-dev", "local", "zero", "payload" }); + defer a.free(dir); + const now = nowNs(io); + try writeTestFile(dir, "record.rpa", "held", now - 2 * Config.LOCAL_PACK_MAX_AGE_NS); + const path = try std.fs.path.join(a, &.{ dir, "record.rpa" }); + defer a.free(path); + var hash: [32]u8 = undefined; + std.crypto.hash.sha2.Sha256.hash("held", &hash, .{}); + const owner = try @import("base").retained_cache.Owner.create(a, io, @splat(0)); + var live = true; + defer if (live) owner.release(); + _ = try owner.acquire(path, hash, .native_payload_v1); + for ([_]u32{ 16, 17 }) |format| { + // Size eviction also runs, with a zero-byte budget. + sweep(io, root, .{ .object_pack_format_version = format, .max_size_bytes = 0 }, now + 2 * Config.LOCAL_PACK_MAX_AGE_NS, null); + try Dir.cwd().access(io, path, .{}); + } + owner.release(); + live = false; + sweep(io, root, .{ .object_pack_format_version = 16, .max_size_bytes = 0 }, now + 2 * Config.LOCAL_PACK_MAX_AGE_NS, null); + try std.testing.expectError(error.FileNotFound, Dir.cwd().access(io, path, .{})); +} diff --git a/src/lir/pack_program.zig b/src/lir/pack_program.zig index ea1ed4e0767..b5f95436381 100644 --- a/src/lir/pack_program.zig +++ b/src/lir/pack_program.zig @@ -500,73 +500,87 @@ pub fn procCopiesSiteCode(allocator: Allocator, store: *const LirStore, proc: LI /// procedure without a code key carries one. The entry's identity names none /// of that copied code. Caller owns the result. pub fn siteCodeCarriers(allocator: Allocator, store: *const LirStore) Allocator.Error![]bool { - const proc_count = store.procSpecCount(); - const carries = try allocator.alloc(bool, proc_count); - errdefer allocator.free(carries); - @memset(carries, false); - const seen = try allocator.alloc(bool, store.cfStmtCount()); - defer allocator.free(seen); - @memset(seen, false); - var visited = std.ArrayList(LIR.CFStmtId).empty; - defer visited.deinit(allocator); - var work = std.ArrayList(LIR.CFStmtId).empty; - defer work.deinit(allocator); - const Edge = struct { caller: LIR.LirProcSpecId, callee: LIR.LirProcSpecId }; - var edges = std.ArrayList(Edge).empty; - defer edges.deinit(allocator); - var carriers = std.ArrayList(LIR.LirProcSpecId).empty; - defer carriers.deinit(allocator); - for (carries, 0..) |*carried, index| { - const proc_id: LIR.LirProcSpecId = @fromBackingInt(@intCast(index)); - const proc = store.getProcSpecImmediate(proc_id); - if (proc.code_key != null) continue; - const body = proc.body orelse continue; - for (visited.items) |stmt_id| seen[@backingInt(stmt_id)] = false; - visited.clearRetainingCapacity(); - work.clearRetainingCapacity(); - try work.append(allocator, body); - while (work.pop()) |stmt_id| { - if (seen[@backingInt(stmt_id)]) continue; - seen[@backingInt(stmt_id)] = true; - try visited.append(allocator, stmt_id); - var scope = store.stmtInlineScope(stmt_id); - while (scope != LIR.InlineScopeId.none) { - const inline_scope = store.inlineScope(scope); - if (inline_scope.copies_site_code) carried.* = true; - scope = inline_scope.parent; - } - if (stmtDirectCallee(store, stmt_id)) |callee| { - if (store.getProcSpecImmediate(callee).code_key == null) try edges.append(allocator, .{ .caller = proc_id, .callee = callee }); - } - try BodyClone.appendSuccessors(store, &work, stmt_id, allocator); - } - if (carried.*) try carriers.append(allocator, proc_id); - } + var facts = try OfferFacts.init(allocator, store); + defer facts.deinit(); + return allocator.dupe(bool, facts.site_code); +} - // Each carrier's callers carry its copy too. - const caller_starts = try allocator.alloc(u32, proc_count + 1); - defer allocator.free(caller_starts); - @memset(caller_starts, 0); - for (edges.items) |edge| caller_starts[@backingInt(edge.callee) + 1] += 1; - for (1..caller_starts.len) |index| caller_starts[index] += caller_starts[index - 1]; - const callers = try allocator.alloc(LIR.LirProcSpecId, edges.items.len); - defer allocator.free(callers); - const fill = try allocator.dupe(u32, caller_starts[0..proc_count]); - defer allocator.free(fill); - for (edges.items) |edge| { - callers[fill[@backingInt(edge.callee)]] = edge.caller; - fill[@backingInt(edge.callee)] += 1; +/// Exact publication facts for one frozen LIR store. No per-root closure table +/// is retained; each boolean propagates once along the reverse call graph. +pub const OfferFacts = struct { + arena: collections.SingleThreadArena, + literal_conversion: []bool, + site_code: []bool, + reaches_linked: []bool, + statements_visited: usize = 0, + call_edges: usize = 0, + propagation_edges: usize = 0, + + pub fn deinit(self: *OfferFacts) void { + self.arena.deinit(); } - while (carriers.pop()) |carrier| { - const raw = @backingInt(carrier); - for (callers[caller_starts[raw]..caller_starts[raw + 1]]) |caller| { - if (carries[@backingInt(caller)]) continue; - carries[@backingInt(caller)] = true; - try carriers.append(allocator, caller); + + pub fn init(allocator: Allocator, store: *const LirStore) Allocator.Error!OfferFacts { + var arena = collections.SingleThreadArena.init(allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + const n = store.procSpecCount(); + const converts = try a.alloc(bool, n); + const carries = try a.alloc(bool, n); + const linked = try a.alloc(bool, n); + @memset(converts, false); + @memset(carries, false); + const starts = try a.alloc(u32, n + 1); + var targets = std.ArrayList(u32).empty; + const seen = try a.alloc(bool, store.cfStmtCount()); + @memset(seen, false); + var visited = std.ArrayList(LIR.CFStmtId).empty; + var work = std.ArrayList(LIR.CFStmtId).empty; + var statements: usize = 0; + for (store.getProcSpecs(), 0..) |proc, index| { + starts[index] = std.math.cast(u32, targets.items.len) orelse return error.OutOfMemory; + linked[index] = proc.c_abi_entry; + for (visited.items) |id| seen[@backingInt(id)] = false; + visited.clearRetainingCapacity(); + if (proc.body) |body| try work.append(a, body); + while (work.pop()) |id| { + if (seen[@backingInt(id)]) continue; + seen[@backingInt(id)] = true; + try visited.append(a, id); + statements += 1; + const stmt = store.getCFStmtImmediate(id); + if (stmt.* == .crash and store.stmtRowOrDefault(stmt.crash.extra).literal_rejection != null) converts[index] = true; + if (proc.code_key == null) { + var scope = store.stmtInlineScope(id); + while (scope != LIR.InlineScopeId.none) { + const inlined = store.inlineScope(scope); + if (inlined.copies_site_code) carries[index] = true; + scope = inlined.parent; + } + } + if (stmtDirectCallee(store, id)) |callee| try targets.append(a, @backingInt(callee)); + try BodyClone.appendSuccessors(store, &work, id, a); + } + } + starts[n] = std.math.cast(u32, targets.items.len) orelse return error.OutOfMemory; + const reverse = try collections.csr.transpose(a, starts, targets.items); + var propagation_edges: usize = 0; + var pending = std.ArrayList(u32).empty; + for ([_][]bool{ linked, carries }, 0..) |marks, property| { + for (marks, 0..) |marked, index| if (marked) try pending.append(a, @intCast(index)); + while (pending.pop()) |callee| { + for (reverse.targets[reverse.starts[callee]..reverse.starts[callee + 1]]) |caller| { + propagation_edges += 1; + if (marks[caller]) continue; + if (property == 1 and (store.getProcSpecs()[callee].code_key != null or store.getProcSpecs()[caller].code_key != null)) continue; + marks[caller] = true; + try pending.append(a, caller); + } + } } + return .{ .arena = arena, .literal_conversion = converts, .site_code = carries, .reaches_linked = linked, .statements_visited = statements, .call_edges = targets.items.len, .propagation_edges = propagation_edges }; } - return carries; -} +}; /// The procedure statement `stmt_id` calls directly, if any. fn stmtDirectCallee(store: *const LirStore, stmt_id: LIR.CFStmtId) ?LIR.LirProcSpecId { @@ -761,3 +775,46 @@ test "site code carriers follow calls to procedures without a code key" { try testing.expect(!carries[@backingInt(keyed_caller)]); try testing.expect(!carries[@backingInt(plain)]); } + +test "offer facts visit shared cyclic call graphs once and retain all admission facts" { + const a = std.testing.allocator; + var store = LirStore.init(a); + defer store.deinit(); + const local = try store.addLocal(.{ .layout_idx = .u64 }); + const args = try store.addLocalSpan(&.{}); + const ret = try store.addCFStmt(.{ .ret = .{ .value = local } }, .test_fixture); + const count = 128; + for (0..count) |i| { + _ = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .identity = LIR.ProcIdentity.forTest(@intCast(i)), + .args = args, + .body = ret, + .frame_locals = args, + .ret_layout = .u64, + }, .none); + } + for (0..count) |i| { + // All callers share the cycle 0 <-> 1, which reaches a linked entry. + const callee: LIR.LirProcSpecId = @fromBackingInt(if (i == 0) 1 else 0); + const body = try store.addCFStmt(.{ .assign_call = .{ .target = local, .proc = callee, .args = args, .next = ret } }, .test_fixture); + store.getProcSpecPtr(@fromBackingInt(@intCast(i))).body = body; + } + store.getProcSpecPtr(@fromBackingInt(1)).c_abi_entry = true; + var facts = try OfferFacts.init(a, &store); + defer facts.deinit(); + try std.testing.expectEqual(@as(usize, count * 2), facts.statements_visited); + try std.testing.expectEqual(@as(usize, count), facts.call_edges); + try std.testing.expectEqual(@as(usize, count), facts.propagation_edges); + for (facts.reaches_linked) |marked| try std.testing.expect(marked); + for (facts.literal_conversion) |marked| try std.testing.expect(!marked); + for (facts.site_code) |marked| try std.testing.expect(!marked); + // Compare the exact old per-root semantics, including the cyclic roots. + for (0..count) |i| { + const closure = try procCallClosure(a, &store, @fromBackingInt(@intCast(i))); + defer a.free(closure); + var linked = false; + for (closure, store.getProcSpecs()) |reached, proc| linked = linked or (reached and proc.c_abi_entry); + try std.testing.expectEqual(linked, facts.reaches_linked[i]); + } +} diff --git a/test/cli/pack_index_bench.py b/test/cli/pack_index_bench.py index 8e8df965315..9c22837284f 100644 --- a/test/cli/pack_index_bench.py +++ b/test/cli/pack_index_bench.py @@ -75,7 +75,7 @@ def run(label, force_lookup=False, no_cache=False, expected="55\n"): stats = {} publication_ns = 0 for line in completed.stderr.splitlines(): - if line.startswith("pack index publication:"): + if line.startswith("pack publication:"): publication_ns += int(re.search(r"wall_ns=(\d+)", line)[1]) if not line.startswith("pack index:"): continue @@ -99,19 +99,18 @@ def run(label, force_lookup=False, no_cache=False, expected="55\n"): templates = sorted(cache.rglob("*.rpi")) template = templates[0] offer_template = template.read_bytes() - assert offer_template[:8] == b"ROFFER02" and len(offer_template) == 77 + assert offer_template[:8] == b"ROFFER03" namespace = template.parents[3] - origin = ("local", "pkg")[offer_template[44]] - owner = namespace / origin / offer_template[45:77].hex() + owner = namespace / "local" / ("00" * 32) metadata_dir = owner / "metadata" payload_dir = owner / "payload" metadata = (metadata_dir / (offer_template[8:40].hex() + ".rpm")).read_bytes() - assert metadata[:8] == b"ROFMETA1" + assert metadata[:8] == b"ROFCOMP1" assert struct.unpack_from(" 0 original_payload = (payload_dir / (metadata[12:44].hex() + ".rpa")).read_bytes() assert original_payload[:8] == b"RPAYLD01" code_len = struct.unpack_from(" U64 + leaf = |limit| { + var $sum = 0 + var $i = 0 + while $i < limit { + $sum = $sum + $i + $i = $i + 1 + } + $sum + } +} +''') + def callers(edited): + for i in range(args.callers): + increment = i + int(edited and i == 0) + (work / f"Caller{i}.roc").write_text(f'''import Shared +Caller{i} := [].{{ + run : U64 -> U64 + run = |n| {{ + var $sum = {increment} + var $i = 0 + while $i < n {{ + $sum = $sum + Shared.leaf($i) + $i = $i + 1 + }} + $sum + }} +}} +''') + imports = "".join(f"import Caller{i}\n" for i in range(args.callers)) + expr = " + ".join(f"Caller{i}.run(n)" for i in range(args.callers)) + platform = os.path.relpath(args.platform.resolve(), work) + app = work / "main.roc" + app.write_text(f'''app [main!] {{ pf: platform "{platform}" }} +import pf.Stdout +{imports} +main! = |args| {{ + n = args.len().to_u64() + 3 + Stdout.line!(({expr}).to_str()) + Ok({{}}) +}} +''') + test = work / "Tests.roc" + def tests(edited): + test.write_text(f"Tests := []\n{imports}" + "".join( + f"expect Caller{i}.run(4) == {4 + i + int(edited and i == 0)}\n" + for i in range(args.callers))) + for command in ("build", "test"): + cache = work / (command + "-cache") + env = dict(os.environ, ROC_CACHE_DIR=str(cache), ROC_PACK_STATS="1") + cold = None + for phase, edited in (("cold", False), ("unchanged", False), ("one-caller edit", True), ("unchanged after edit", True)): + callers(edited) + tests(edited) + # Do not count completed-result hits as native publication. + for path in cache.rglob("*"): + if path.is_file() and (path.suffix == ".rpo" or "test" in path.relative_to(cache).parts): + path.unlink() + cmd = [str(roc), command, "--opt=dev", "--jobs=1", str(app if command == "build" else test)] + if command == "build": + cmd.append(f"--output={work / 'app'}") + run = subprocess.run(["/usr/bin/time", "-f", "%e %M", "-o", str(work / "time"), *cmd], env=env, capture_output=True, text=True) + if run.returncode: + raise RuntimeError(run.stdout + run.stderr) + publication = counters(run.stderr, "pack publication:") + acquired = counters(run.stderr, "pack acquisitions:") + if command == "build" and not publication: + raise RuntimeError("No native publication measured. Use a ReleaseFast compiler built with -Ddev-diagnostics.\n" + run.stdout + run.stderr) + if command == "build": + # argv contains the executable name, hence n == 4. + expected = 4 * args.callers + args.callers * (args.callers - 1) // 2 + int(edited) + assert subprocess.check_output([str(work / "app")], text=True) == f"{expected}\n" + if cold is None: + cold = publication + elif command == "build": + assert publication["validated_references"] > 0, publication + assert publication["payloads_encoded"] < cold["payloads_encoded"], (cold, publication) + wall, rss = (work / "time").read_text().split() + print(command, phase, f"wall={wall}s peak_rss={rss}KiB", f"acquired={acquired}", f"publication={publication}", flush=True) + + +if __name__ == "__main__": + main() From 304ccd23a7b3fe40337b145201f240237e101dc1 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 16:54:16 -0400 Subject: [PATCH 34/97] Preserve relocated type and default dependencies and validate replay --- .github/workflows/ci_zig.yml | 2 +- ci/test_outcome_cache.py | 19 +++++- design.md | 6 +- src/canonicalize/mod.zig | 2 + src/check/value_identity.zig | 111 +++++++++++++++++++++++++++++++---- src/cli/main.zig | 12 +++- src/eval/inspected.zig | 1 + 7 files changed, 134 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci_zig.yml b/.github/workflows/ci_zig.yml index 33b96394c4c..e2ce7970058 100644 --- a/.github/workflows/ci_zig.yml +++ b/.github/workflows/ci_zig.yml @@ -163,7 +163,7 @@ jobs: run: zig build run-test-cli -- --include-llvm --filter "[size]" --filter "[speed]" - name: Run focused test outcome cache regressions - run: zig build run-test-outcome-cache + run: zig build run-test-outcome-cache -j2 # run-test-dylib and run-test-archive are MiniCI phases, so PR CI runs # them on these same three OSes; they are not repeated here. diff --git a/ci/test_outcome_cache.py b/ci/test_outcome_cache.py index 416a32c7500..03d9d811669 100644 --- a/ci/test_outcome_cache.py +++ b/ci/test_outcome_cache.py @@ -27,7 +27,7 @@ def __init__(self, root, roc): self.root = root self.roc = roc self.cache = root / "cache" - self.env = dict(os.environ, ROC_CACHE_DIR=str(self.cache)) + self.env = dict(os.environ, ROC_CACHE_DIR=str(self.cache), ROC_TRACE_BOXY_WORK="1") self.path = root / "Main.roc" def write(self, text, name="Main.roc"): @@ -44,6 +44,7 @@ def run(self, *, cache=True, timings=False, mode="dev", work=False): result = subprocess.run(args, env=env, capture_output=True, timeout=120) assert result.returncode in (0, 1, 2), result.stderr.decode(errors="replace") assert b"panic" not in result.stderr, result.stderr.decode(errors="replace") + assert b"boxy-work:" not in result.stderr, result.stderr.decode(errors="replace") return result def check(self, hits=None, *, mode="dev"): @@ -129,6 +130,22 @@ def edit(source, hits, executed=0, name="Main.roc"): edit(typed.replace('module [make]\n', 'module [make]\nexpect 37 == 37\n'), 1, 1, name='Typed.roc') edit(typed.replace('value: 1', 'value: 2'), 0, 1, name='Typed.roc') + alias = ('module [make]\nThing : { value : U64 }\n' + 'make : {} -> Thing\nmake = |_| { value: 1 }\n') + f.write(alias, 'TypedAlias.roc') + f.write('import TypedAlias\nhelper = || TypedAlias.make({})\nexpect helper().value == 1\n') + f.check(1) + edit('# moved imported alias\n' + alias, 1, name='TypedAlias.roc') + edit(alias.replace('U64', 'I64'), 0, 1, name='TypedAlias.roc') + + default_helper = ('WithDefault := { value : U64 ?? compute() }.{}\n' + 'compute = || { expect True\n 1 }\n') + f.write(default_helper, 'WithDefault.roc') + f.write('import WithDefault\nexpect WithDefault.{}.value == 1\n') + f.check(1) + edit('# moved a default and its helper\n' + default_helper, 1, name='WithDefault.roc') + edit(default_helper.replace(' 1 }', ' 2 }'), 0, 1, name='WithDefault.roc') + defaults = ('Config := { value : U64 ?? 1 }\n' 'make = || Config.{}\n' 'expect make().value == 1\n') diff --git a/design.md b/design.md index 2eb9e0ff6d9..e7e14f5d241 100644 --- a/design.md +++ b/design.md @@ -23919,7 +23919,11 @@ references resolve their explicit declaration row and retain the representation owner's semantic type/method context, so location independence never removes nominal identity, backing declaration validation, or method precedence. This contract is specific to outcome identity; ordinary checked type identity and -compile-time value portability are unchanged. +compile-time value portability are unchanged. The semantic type environment +also includes the referenced-definition and import summaries of declared field +defaults: foreign construction materializes them without a local value-reference +edge. Default-expression observations have their declaring type as an explicit +reporting owner. The execution strategy, execution mode, compiler compatibility and payload version partition outcome admission. Rendering settings, elapsed time, root diff --git a/src/canonicalize/mod.zig b/src/canonicalize/mod.zig index 84fc8c46645..07aa1b01bc1 100644 --- a/src/canonicalize/mod.zig +++ b/src/canonicalize/mod.zig @@ -19,6 +19,8 @@ pub const ImportResolution = @import("ImportResolution.zig"); pub const resolveDeferredFileImports = ImportResolution.resolveDeferredFileImports; /// Drain a module's deferred import worklist. See `ImportResolution`. pub const resolveDeferredImports = ImportResolution.resolveDeferredImports; +/// Declared defaults materialized by nominal record construction. +pub const default_omissions = @import("default_omissions.zig"); /// Dependency graph and SCC (Strongly Connected Components) analysis pub const DependencyGraph = @import("DependencyGraph.zig"); /// Hosted function compiler - replaces annotation-only with hosted lambdas diff --git a/src/check/value_identity.zig b/src/check/value_identity.zig index b28b15b19ab..3db167927b5 100644 --- a/src/check/value_identity.zig +++ b/src/check/value_identity.zig @@ -100,6 +100,7 @@ pub const DefinitionIdentity = extern struct { value: [32]u8, }; +/// The module's value context, dependency summaries, root identities and report sites. pub const ModuleIdentities = struct { /// Digest of everything outside its plain definitions that dispatch on /// its types can select or depend on: the module context and the type @@ -205,6 +206,7 @@ pub fn nameDigest(name: []const u8) [32]u8 { const Owner = union(enum) { def: CIR.Def.Idx, expect: CIR.Statement.Idx, + type_default: CIR.Statement.Idx, }; const State = struct { @@ -297,14 +299,12 @@ const State = struct { if (self.inputs.test_dependencies) { // Failed-expect detail includes the complete mentioned binding, // including whitespace between its pattern and expression. - switch (env.store.getExpr(def.expr)) { - .e_lambda, .e_closure, .e_hosted_lambda => {}, - else => { - const start = env.store.getPatternRegion(def.pattern).start.offset; - const end = env.store.getExprRegion(def.expr).end.offset; - // This is the reporter's exact excerpt eligibility rule. - if (start < end and end <= self.source().len) hashBytes(&hasher, self.source()[start..end]); - }, + const expr_tag = std.meta.activeTag(env.store.getExpr(def.expr)); + if (expr_tag != .e_lambda and expr_tag != .e_closure and expr_tag != .e_hosted_lambda) { + const start = env.store.getPatternRegion(def.pattern).start.offset; + const end = env.store.getExprRegion(def.expr).end.offset; + // This is the reporter's exact excerpt eligibility rule. + if (start < end and end <= self.source().len) hashBytes(&hasher, self.source()[start..end]); } hasher.update(&try self.importedReferences(def.expr)); } @@ -407,6 +407,14 @@ const State = struct { => {}, } } + if (!self.inputs.test_dependencies) return; + for (env.store.sliceStatements(env.type_decls)) |statement| { + const fields = can.default_omissions.backingRecordFields(env, statement) orelse continue; + for (env.store.sliceAnnoRecordFields(fields)) |field| { + const expr = env.store.getAnnoRecordField(field).default_value orelse continue; + try self.mapOwner(expr, .{ .type_default = statement }); + } + } } fn mapOwner(self: *State, root: CIR.Expr.Idx, owner: Owner) Allocator.Error!void { @@ -451,7 +459,7 @@ const State = struct { if (owners.get(statement)) |owner| { const envelope = switch (owner) { .def => |def| env.store.getExprRegion(env.store.getDef(def).expr), - .expect => |root| env.store.getStatementRegion(root), + .expect, .type_default => |root| env.store.getStatementRegion(root), }; const owner_digest = try owner_digests.getOrPut(self.allocator, owner); if (!owner_digest.found_existing) { @@ -493,7 +501,7 @@ const State = struct { if (!anchor.found_existing) { const region = switch (owner) { .def => |def| env.store.getExprRegion(env.store.getDef(def).expr), - .expect => |statement| env.store.getStatementRegion(statement), + .expect, .type_default => |statement| env.store.getStatementRegion(statement), }; var hash = Sha256.init(.{}); hashTag(&hash, "roc.test-report-owner.v1"); @@ -547,8 +555,10 @@ const State = struct { } fn importedByIndex(self: *const State, index: CIR.Import.Idx) checked_artifact.ImportedModuleView { - const identity = self.inputs.env.importIdentity(index) orelse base.invariant("checked external lookup has no module identity", .{}); - return self.importedByIdentity(self.inputs.env.moduleIdentityHash(identity).*); + const module_idx = self.inputs.env.imports.getResolvedModule(index) orelse + base.invariant("checked external lookup has no resolved module", .{}); + for (self.inputs.imports) |entry| if (entry.module_idx == module_idx) return entry.view; + base.invariant("checked external lookup has no published imported module", .{}); } fn importedByIdentity(self: *const State, identity: [32]u8) checked_artifact.ImportedModuleView { @@ -583,7 +593,64 @@ const State = struct { try values.append(self.allocator, self.importedDefinition(self.importedByIdentity(identity.*), lookup.target_def_idx)); } }, - else => {}, + .e_num, + .e_frac_f32, + .e_frac_f64, + .e_dec, + .e_dec_small, + .e_num_from_numeral, + .e_typed_int, + .e_typed_frac, + .e_typed_num_from_numeral, + .e_str_segment, + .e_str, + .e_bytes_literal, + .e_lookup_local, + .e_deferred_import_ref, + .e_lookup_associated_local, + .e_lookup_associated, + .e_lookup_required, + .e_list, + .e_empty_list, + .e_tuple, + .e_match, + .e_if, + .e_call, + .e_record, + .e_empty_record, + .e_block, + .e_tag, + .e_nominal, + .e_nominal_external, + .e_zero_argument_tag, + .e_closure, + .e_lambda, + .e_binop, + .e_unary_minus, + .e_field_access, + .e_method_call, + .e_dispatch_call, + .e_interpolation, + .e_structural_eq, + .e_structural_hash, + .e_method_eq, + .e_type_method_call, + .e_type_dispatch_call, + .e_tuple_access, + .e_runtime_error, + .e_crash, + .e_dbg, + .e_expect_err, + .e_expect, + .e_ellipsis, + .e_anno_only, + .e_derived_method, + .e_return, + .e_break, + .e_for, + .e_hosted_lambda, + .e_run_low_level, + => {}, } try appendExprChildren(self.inputs.env, expr, &stack, self.allocator); } @@ -666,6 +733,22 @@ const State = struct { try texts.append(self.allocator, digestBytes("statement", self.regionText(env.store.getStatementRegion(statement_idx)))); } } + if (self.inputs.test_dependencies) { + // Foreign constructions materialize these defaults without a local + // value-reference edge. Publish their complete dependencies as part + // of the declaring module's semantic type environment. + for (env.store.sliceStatements(env.type_decls)) |statement| { + const fields = can.default_omissions.backingRecordFields(env, statement) orelse continue; + for (env.store.sliceAnnoRecordFields(fields)) |field| { + const expr = env.store.getAnnoRecordField(field).default_value orelse continue; + var hash = Sha256.init(.{}); + hashTag(&hash, "declared-default-dependencies"); + hashDigests(&hash, try self.referencedIdentities(expr)); + hash.update(&try self.importedReferences(expr)); + try texts.append(self.allocator, hash.finalResult()); + } + } + } sortDigests(texts.items); var methods = std.ArrayList([32]u8).empty; defer methods.deinit(self.allocator); @@ -763,6 +846,7 @@ const State = struct { return switch (resolved) { .def => |def_idx| digestBytes("owner.def", self.defName(def_idx)), .expect => |statement| digestBytes("owner.expect", self.regionText(env.store.getStatementRegion(statement))), + .type_default => |statement| digestBytes("owner.default", self.regionText(env.store.getStatementRegion(statement))), }; } @@ -820,6 +904,7 @@ const State = struct { hashBytes(&class, self.regionText(env.store.getStatementRegion(statement))); hashDigests(&class, try self.referencedIdentities(expectBody(env, statement))); }, + .type_default => |statement| hashBytes(&class, self.regionText(env.store.getStatementRegion(statement))), } else hashTag(&class, "unowned"); hashBytes(&class, if (root.hoisted_body) |body| @tagName(body) else "no-body"); hashBytes(&class, self.regionText(env.store.getExprRegion(hoisted.expr))); diff --git a/src/cli/main.zig b/src/cli/main.zig index e1b078d11e1..4e29ab5d168 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -16700,12 +16700,16 @@ fn runWatchCommand(ctx: *CliCtx, arg0: []const u8, command: WatchCommand) WatchC } } -fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestError!void { - defer if (base.dev_diagnostics.isSet(.test_command_work)) { +fn printCliTestWork(ctx: *CliCtx) void { + if (base.dev_diagnostics.isSet(.test_command_work)) { inline for (@typeInfo(base.dev_diagnostics.TestWork).@"enum".field_names) |field| { ctx.io.stderr().print("test-work {s}={d}\n", .{ field, base.dev_diagnostics.testWorkCount(@field(base.dev_diagnostics.TestWork, field)) }) catch {}; } - }; + } +} + +fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestError!void { + defer printCliTestWork(ctx); const trace = tracy.trace(@src()); defer trace.end(); @@ -16821,6 +16825,7 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr const elapsed: u64 = @intCast(std.Io.Timestamp.now(ctx.io.std_io, .awake).nanoseconds - start_time); try writeCliTestRunSummary(if (completed.total.allPassed()) stdout else stderr, completed.total, elapsed, true, testReportingConfig(ctx).shouldUseColors()); if (!completed.total.allPassed()) return error.TestsFailed; + if (warnings > 0) printCliTestWork(ctx); exitOnWarnings(ctx, warnings); return; } @@ -17134,6 +17139,7 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr try writeCliTestRunSummary(stdout, total, elapsed_ns, fully_cached, report_config.shouldUseColors()); // Same warning exit policy as check/build/run: passing tests with // compile warnings exit 2. + if (diag.warnings > 0) printCliTestWork(ctx); exitOnWarnings(ctx, diag.warnings); return; } diff --git a/src/eval/inspected.zig b/src/eval/inspected.zig index 55ea2ea5bb7..b97bbe153e7 100644 --- a/src/eval/inspected.zig +++ b/src/eval/inspected.zig @@ -489,6 +489,7 @@ pub const BoolRootEvalResult = struct { expect_observations: []ExpectObservation = &.{}, }; +/// The uncombined counts contributed by one root to an inline expect site. pub const ExpectObservation = struct { site: u32, counts: ExpectCounts, From 1269610c1bd2201093cf6ec79d9f6c6e85009f3c Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 17:58:03 -0400 Subject: [PATCH 35/97] Reconcile native cache contracts and publish package test discoveries --- ci/test_outcome_cache.py | 2 +- design.md | 6 ++ src/backend/dev/PackFile.zig | 10 +-- src/cli/completed_test.zig | 2 +- src/cli/main.zig | 44 +++++----- src/cli/pack_store.zig | 117 +++++++++++++++------------ src/cli/test/parallel_cli_runner.zig | 2 +- 7 files changed, 104 insertions(+), 79 deletions(-) diff --git a/ci/test_outcome_cache.py b/ci/test_outcome_cache.py index 03d9d811669..599472c7fa9 100644 --- a/ci/test_outcome_cache.py +++ b/ci/test_outcome_cache.py @@ -14,7 +14,7 @@ def is_outcome(path): if not path.is_file(): return False with path.open('rb') as source: - return source.read(len(b'ROC_TEST_OUTCOMES_V2')) == b'ROC_TEST_OUTCOMES_V2' + return source.read(len(b'ROC_TEST_OUTCOMES_V3')) == b'ROC_TEST_OUTCOMES_V3' def transcript(data): diff --git a/design.md b/design.md index 3edf18f5372..7e1f18afbb4 100644 --- a/design.md +++ b/design.md @@ -2833,6 +2833,12 @@ summaries load apart from the code packs, so a program whose every discovery request is settled reads no code pack. `--timings` counts these under "Summarized requests", which are included in the parked requests. +Test plans supply an explicit checked root and the complete ordered checked +module list to the cache provider before finalization. Package tests use that +root for discovery and compile-time pack publication; they do not require an +application package. The explicit provider list remains present when every +runtime test outcome is cached and only discovery requests remain. + #### Compile-Time Object Packs A runtime build fills the object cache with packs of dev code; compile-time diff --git a/src/backend/dev/PackFile.zig b/src/backend/dev/PackFile.zig index c088f89b501..658d0690e04 100644 --- a/src/backend/dev/PackFile.zig +++ b/src/backend/dev/PackFile.zig @@ -23,7 +23,7 @@ const Allocator = std.mem.Allocator; const magic = "RPCK"; /// Format version; bump whenever the encoding or artifact contents change. -pub const format_version: u32 = 16; +pub const format_version: u32 = 17; /// One specialization the pack can serve: its reservation-time key, the /// excerpt holding its procedure, and the ownership signature and @@ -381,7 +381,7 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) 1 => .absent, 2 => binding: { const index = try reader.word(); - if (index >= artifact_count) return error.MalformedPack; + if (index >= excerpt_count) return error.MalformedPack; break :binding .{ .local = index }; }, else => return error.MalformedPack, @@ -517,9 +517,9 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) } } - for (artifacts) |artifact| for (artifact.symbolic_refs) |ref| { + for (excerpts) |artifact| for (artifact.symbolic_refs) |ref| { if (ref.binding != .local) continue; - const kind = artifacts[ref.binding.local].kind; + const kind = excerpts[ref.binding.local].kind; const matches = switch (ref.target) { .proc => |identity| kind == .proc and std.meta.eql(identity, kind.proc), .boxy_thunk => |identity| kind == .boxy_thunk and std.meta.eql(identity, kind.boxy_thunk), @@ -528,7 +528,7 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) if (!matches) return error.MalformedPack; }; - const specs = try readSpecs(&reader, arena_allocator, spec_count, artifact_count); + const specs = try readSpecs(&reader, arena_allocator, spec_count, excerpt_count); if (reader.offset != bytes.len) return error.MalformedPack; return .{ diff --git a/src/cli/completed_test.zig b/src/cli/completed_test.zig index 47f3a3ed033..1c15b8ba808 100644 --- a/src/cli/completed_test.zig +++ b/src/cli/completed_test.zig @@ -7,7 +7,7 @@ const compile = @import("compile"); const Allocator = std.mem.Allocator; /// Prefix of every command record; changing the record format changes it. -pub const magic = "ROC_COMPLETED_DEV_TEST_V2"; +pub const magic = "ROC_COMPLETED_DEV_TEST_V3"; /// A SHA-256 content digest. pub const Digest = [32]u8; /// A file a producer read, with the content state it saw. diff --git a/src/cli/main.zig b/src/cli/main.zig index 809880c755e..ca016b1adee 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -359,8 +359,11 @@ const CompileTimePackParts = struct { fn decideDestination(cache: *CompileTimeObjectCache, publisher: pack_store.CompileTimePublisher) Allocator.Error!?Destination { const build_env = cache.build_env orelse return null; - const root_semantic = build_env.getExecutableRootSemanticData() orelse return null; - const root_artifact = root_semantic.checked_artifact orelse return null; + // A test plan supplies its own checked owner, including package roots. + const root_artifact = switch (publisher.command) { + .test_run => if (build_env.runtime_lowering) |config| config.root_module else null, + .build, .check => if (build_env.getExecutableRootSemanticData()) |root| root.checked_artifact else null, + } orelse return null; const placement = pack_store.programPackPlacement(build_env, root_artifact) orelse return null; const key = pack_store.compileTimePackKey(root_artifact.codeGenerationKey().bytes, publisher); if (try cache.store.hasIndexed(placement.origin, placement.identity, key)) return null; @@ -9300,7 +9303,7 @@ const PackOffers = struct { defer closure.deinit(); const rejected = try allocator.alloc(bool, set.excerpts.len); defer allocator.free(rejected); - for (rejected, 0..) |*mark, index| mark.* = converting_artifacts.isSet(index) or closureNamesProgramLocalSymbols(set, &.{@intCast(index)}); + for (rejected, 0..) |*mark, index| mark.* = converting_excerpts.isSet(index) or closureNamesProgramLocalSymbols(set, &.{@intCast(index)}); try backend.dev.CodeExcerpt.markReferrers(allocator, set, rejected, .follow); for ([_][]const Candidate{ self.keyed.items, self.by_identity.items }, [_]bool{ false, true }) |candidates, by_identity| { for (candidates) |offer| { @@ -9313,7 +9316,7 @@ const PackOffers = struct { // and a constant holding a code pointer names code the pack // may not carry; an entry that reaches either cannot be // linked elsewhere, so it is not offered. - if (rejected[artifact]) { + if (rejected[excerpt]) { withheld += 1; continue; } @@ -10910,7 +10913,7 @@ fn compileLlvmAppObject( } return .{ - .excerpt_dir = artifact_dir, + .artifact_dir = artifact_dir, .bitcode_path = bitcode_path, .object_path = object_path, }; @@ -11015,7 +11018,7 @@ fn rocBuildWasmLlvm( false, reporter, ); - defer if (!args.keep_temp) std.Io.Dir.cwd().deleteTree(ctx.io.std_io, app_object.excerpt_dir) catch {}; + defer if (!args.keep_temp) std.Io.Dir.cwd().deleteTree(ctx.io.std_io, app_object.artifact_dir) catch {}; reporter.end(); reporter.begin("Linking"); @@ -11027,7 +11030,7 @@ fn rocBuildWasmLlvm( if (link_type == .archive) { // Archives package whatever inputs the platform declared (possibly // just the app); no platform wasm file is required. - const combined_obj = try writeCombinedLlvmWasmObject(ctx, target, app_object.excerpt_dir, app_object.object_path, &lowered.lir_result, entrypoints, static_data_exports, args.opt, &owned_inputs); + const combined_obj = try writeCombinedLlvmWasmObject(ctx, target, app_object.artifact_dir, app_object.object_path, &lowered.lir_result, entrypoints, static_data_exports, args.opt, &owned_inputs); try writeArchiveOutput(ctx, target, final_output_path, link_inputs, &.{combined_obj}); return; } @@ -11037,8 +11040,8 @@ fn rocBuildWasmLlvm( return error.UnsupportedTarget; } - const combined_obj = try writeCombinedLlvmWasmObject(ctx, target, app_object.excerpt_dir, app_object.object_path, &lowered.lir_result, entrypoints, static_data_exports, args.opt, &owned_inputs); - try linkWasmOutput(ctx, args, target, link_inputs, final_output_path, combined_obj, app_object.excerpt_dir); + const combined_obj = try writeCombinedLlvmWasmObject(ctx, target, app_object.artifact_dir, app_object.object_path, &lowered.lir_result, entrypoints, static_data_exports, args.opt, &owned_inputs); + try linkWasmOutput(ctx, args, target, link_inputs, final_output_path, combined_obj, app_object.artifact_dir); } /// Base output path for `roc build`: `--output`, else the synthetic @@ -11438,14 +11441,14 @@ fn rocBuildLlvm(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResult &reporter, ); - if (args.keep_temp) try reportKeptTempDir(ctx, app_object.excerpt_dir); - defer if (!args.keep_temp) std.Io.Dir.cwd().deleteTree(ctx.io.std_io, app_object.excerpt_dir) catch {}; + if (args.keep_temp) try reportKeptTempDir(ctx, app_object.artifact_dir); + defer if (!args.keep_temp) std.Io.Dir.cwd().deleteTree(ctx.io.std_io, app_object.artifact_dir) catch {}; var static_data_obj_path: ?[]const u8 = null; if (static_data_exports.len > 0) { var object_compiler = backend.ObjectFileCompiler.init(ctx.gpa); const static_obj_filename = try std.fmt.allocPrint(ctx.arena, "roc_static_data_{s}.o", .{@tagName(target)}); - const static_obj_path = try std.fs.path.join(ctx.arena, &.{ app_object.excerpt_dir, static_obj_filename }); + const static_obj_path = try std.fs.path.join(ctx.arena, &.{ app_object.artifact_dir, static_obj_filename }); try object_compiler.compileStaticDataObjectAndWrite( static_data_exports, target, @@ -11463,10 +11466,10 @@ fn rocBuildLlvm(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResult try object_files.append(path); } if (enable_default_platform_runtime) { - try appendDefaultPlatformLinkInputs(ctx, &object_files, app_object.excerpt_dir, target); + try appendDefaultPlatformLinkInputs(ctx, &object_files, app_object.artifact_dir, target); } if (lirResultNeedsBoxyRuntime(&lowered.lir_result)) { - try appendBoxyRuntimeLinkInputs(ctx, &object_files, app_object.excerpt_dir, target, &lowered.lir_result, null, null); + try appendBoxyRuntimeLinkInputs(ctx, &object_files, app_object.artifact_dir, target, &lowered.lir_result, null, null); } reporter.end(); @@ -11522,7 +11525,7 @@ fn rocBuildLlvm(ctx: *CliCtx, args: cli_args.BuildArgs) CliMainError!BuildResult .can_exit_early = false, .disable_output = false, .platform_files_dir = link_inputs.platform_files_dir, - .scratch_dir = app_object.excerpt_dir, + .scratch_dir = app_object.artifact_dir, .macho_dwarf_object = if (target_os == .macos and link_type != .archive) app_object.object_path else @@ -13350,7 +13353,7 @@ fn storeCliCompletedTest( manager.storeFamilyBytes(.test_result, seed, plan_bytes, directory, "test command inputs"); } -const cli_test_cache_magic = "ROC_TEST_OUTCOMES_V2"; +const cli_test_cache_magic = "ROC_TEST_OUTCOMES_V3"; fn appendU32(bytes: *std.ArrayList(u8), allocator: std.mem.Allocator, value: u32) Allocator.Error!void { var buf: [4]u8 = undefined; @@ -17083,9 +17086,12 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr null; var native_cache_modules = std.ArrayList(*const check.CheckedArtifact.CheckedModuleArtifact).empty; defer native_cache_modules.deinit(ctx.gpa); - if (native_test_cache) |cache| { + if (build_env.compile_time_object_cache_owner) |owner| { + const cache: *CompileTimeObjectCache = @ptrCast(@alignCast(owner.context)); for (modules) |module| if (module.semantic.checked_artifact) |artifact| try native_cache_modules.append(ctx.gpa, artifact); cache.explicit_artifacts = native_cache_modules.items; + } + if (native_test_cache) |cache| { runtime_config.target.spec_cache = cache.packs.specCacheLookup(); runtime_config.target.code_provision = .host_test_dev_objects; runtime_config.target.spec_comptime_reads = true; @@ -17123,9 +17129,9 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr // compilations as a check does, under the module the object cache loads // a program's packs from. if (diag.errors == 0) { - if (build_env.getExecutableRootSemanticData()) |root_semantic| if (root_semantic.checked_artifact) |root_artifact| { + if (runtime_config.root_module) |root_artifact| { try publishCompileTimeResults(ctx, &build_env, root_artifact); - }; + } } try finishFrontEndPhase(ctx.arena, ctx.reportConfig(.stderr), &reporter, build_env.getTimingInfo()); reporter.recordCounters("Test result cache", &cliTestCacheCounters(cliTestCacheStats(&test_plan))); diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index b300bc34048..b7b5753c885 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -2490,7 +2490,7 @@ const IndexFixture = struct { const allocator = std.testing.allocator; const identity: [32]u8 = @splat(0xa1); - fn init(self: *@This()) (std.Io.Dir.RealPathFileAllocError || Allocator.Error)!void { + fn init(self: *@This()) (std.Io.Dir.RealPathFileAllocError || Store.InitError)!void { self.tmp = std.testing.tmpDir(.{}); self.arena = std.heap.ArenaAllocator.init(allocator); const root = try self.tmp.dir.realPathFileAlloc(std.testing.io, ".", allocator); @@ -2524,13 +2524,10 @@ const IndexFixture = struct { try self.store.writeIndexed(.local, identity, &.{ .arena = undefined, .excerpts = artifacts, .source_files = files }, specs); } - fn payloadPath(self: *@This(), value: backend.dev.CodeExcerpt.Excerpt) Allocator.Error![]u8 { + fn payloadPath(self: *@This(), value: backend.dev.CodeExcerpt.Excerpt) ![]u8 { const bytes = try Index.payload(allocator, value); defer allocator.free(bytes); - var record = (self.store.native.acquire(self.store.indexedIdentity(.local, @splat(0), Index.digest(bytes), .payload), self.store.native.now()) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - else => return error.OutOfMemory, - }) orelse return error.OutOfMemory; + var record = (try self.store.native.acquire(self.store.indexedIdentity(.local, @splat(0), Index.digest(bytes), .payload), self.store.native.now())) orelse return error.TestUnexpectedResult; defer record.deinit(); return allocator.dupe(u8, record.path); } @@ -2718,7 +2715,7 @@ test "indexed object cache interrupted publication exposes no offer and concurre // Simulate interruption after payload publication but before any offer. const unoffered = try Index.payload(F.allocator, F.excerpt(1, "code")); defer F.allocator.free(unoffered); - try fixture.store.native.write(fixture.store.indexedIdentity(.local, F.identity, Index.digest(unoffered), .payload), unoffered, fixture.store.native.now()); + try fixture.store.native.write(fixture.store.indexedIdentity(.local, @splat(0), Index.digest(unoffered), .payload), unoffered, fixture.store.native.now()); var interrupted = try fixture.loader(); defer interrupted.deinit(); try std.testing.expect(interrupted.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) == null); @@ -2870,9 +2867,10 @@ test "indexed object cache shares payloads and verifies immutable records before const artifacts = [_]backend.dev.CodeExcerpt.Excerpt{ first, second, F.excerpt(3, "shared dependency") }; const specs = [_]PackFile.SpecEntry{ F.spec(1, 0), F.spec(2, 1) }; try fixture.publish(&artifacts, &specs, &.{}); - const group = fixture.store.indexedIdentity(.local, F.identity, @splat(0), .metadata); + const group = fixture.store.indexedIdentity(.local, @splat(0), @splat(0), .metadata); const keys = try fixture.store.native.groupKeys(F.allocator, group); defer F.allocator.free(keys); + try std.testing.expect(keys.len > 0); var before = (try fixture.store.native.acquireKey(keys[0], fixture.store.native.now())).?; defer before.deinit(); try fixture.publish(&artifacts, &specs, &.{}); @@ -2888,7 +2886,6 @@ test "indexed object cache shares payloads and verifies immutable records before try std.testing.expectEqual(@as(u64, 3), packs.index_stats.decoded_artifacts); const left = packs.excerpts.get(@splat(1)).?; const right = packs.excerpts.get(@splat(2)).?; - try std.testing.expectEqual(left.set, right.set); try std.testing.expectEqual(left.set.excerpts[left.index].payload_record, right.set.excerpts[right.index].payload_record); } @@ -3005,7 +3002,7 @@ test "native publication reuses acquired payloads and offers across a one-caller defer combined.deinit(); var closure = try SpliceClosure.init(a, &combined); defer closure.deinit(); - for (&specs, 0..) |*spec, i| spec.excerpt = closure.offeredCodeExcerpt(lir.ProcIdentity.forTest(@intCast(i + 1))).?; + for (&specs, 0..) |*spec, i| spec.excerpt = closure.offeredProcExcerpt(lir.ProcIdentity.forTest(@intCast(i + 1))).?; stats = .{}; try fixture.store.writeIndexedRetained(.local, F.identity, &combined, &specs, null, &loaded); try std.testing.expectEqual(@as(u64, count), stats.offers_reused); @@ -3077,11 +3074,15 @@ test "native publication retained files survive cleanup until their last owner e defer F.allocator.free(path); loaded.deinit(); live = false; - try std.testing.expect(!base.retained_cache.remove(std.Io.Dir.cwd(), std.testing.io, path)); + const payload = try Index.payload(F.allocator, artifact); + defer F.allocator.free(payload); + const id = fixture.store.indexedIdentity(.local, @splat(0), Index.digest(payload), .payload); + try std.testing.expectError(error.Busy, fixture.store.native.invalidate(id)); try std.testing.expectEqualStrings("immutable bytes", copy.excerpts[0].code); copy.deinit(); copy_live = false; - try std.testing.expect(base.retained_cache.remove(std.Io.Dir.cwd(), std.testing.io, path)); + try fixture.store.native.invalidate(id); + try std.testing.expectError(error.FileNotFound, std.Io.Dir.cwd().openFile(std.testing.io, path, .{})); var missing = try fixture.loader(); defer missing.deinit(); try std.testing.expect(missing.specCacheLookup().lookup(spec.key, .dev_code, &.{}) == null); @@ -3212,56 +3213,68 @@ test "native publication shares a cyclic component and invalidates exact depende try std.testing.expectEqual(owned.excerpts[next].kind.proc, dependency.kind.proc); } -test "native publication pins dependencies until all offers are visible" { +test "native publication retains catalog generations through batch completion" { const F = IndexFixture; var fixture: F = undefined; try fixture.init(); defer fixture.deinit(); - const payload_path = try fixture.payloadPath(F.excerpt(1, "code")); - defer F.allocator.free(payload_path); - const Cleaner = struct { - payload: []const u8, - attempts: usize = 0, - write: @TypeOf(@as(CoreCtx, undefined).vtable.writeTempFile), - fn stage(ctx: ?*anyopaque, io: std.Io, allocator: Allocator, path: []const u8, bytes: []const u8) (Allocator.Error || CoreCtx.WriteError)![]u8 { - const self: *@This() = @ptrCast(@alignCast(ctx.?)); - if (std.mem.endsWith(u8, path, ".rpm") or std.mem.endsWith(u8, path, ".rpi")) { - self.attempts += 1; - if (base.retained_cache.remove(std.Io.Dir.cwd(), io, self.payload)) return error.AccessDenied; - } - return self.write(null, io, allocator, path, bytes); - } - }; - var cleaner = Cleaner{ .payload = payload_path, .write = fixture.store.roc_ctx.vtable.writeTempFile }; - fixture.store.roc_ctx.ctx = &cleaner; - fixture.store.roc_ctx.vtable.writeTempFile = Cleaner.stage; - try fixture.publish(&.{F.excerpt(1, "code")}, &.{F.spec(1, 0)}, &.{}); - try std.testing.expectEqual(@as(usize, 2), cleaner.attempts); - try std.testing.expect(base.retained_cache.remove(std.Io.Dir.cwd(), std.testing.io, payload_path)); + const bytes = try Index.payload(F.allocator, F.excerpt(1, "code")); + defer F.allocator.free(bytes); + const id = fixture.store.indexedIdentity(.local, @splat(0), Index.digest(bytes), .payload); + var pins = std.ArrayList(compile.artifacts.Artifact).empty; + defer { + for (pins.items) |*pin| pin.deinit(); + pins.deinit(F.allocator); + } + var stats: Store.PublicationStats = .{}; + _ = try fixture.store.publishIndexedHeld(id, bytes, &stats, &pins); + try std.testing.expectError(error.Busy, fixture.store.native.invalidate(id)); + const metadata = "publication still in progress"; + _ = try fixture.store.publishIndexedHeld(fixture.store.indexedIdentity(.local, @splat(0), Index.digest(metadata), .metadata), metadata, &stats, &pins); + try std.testing.expectError(error.Busy, fixture.store.native.invalidate(id)); + for (pins.items) |*pin| pin.deinit(); + pins.clearRetainingCapacity(); + try fixture.store.native.invalidate(id); + try std.testing.expect(try fixture.store.native.acquire(id, fixture.store.native.now()) == null); } -test "native publication failure at every record boundary exposes only complete offers" { +test "native publication interruption at each catalog boundary exposes only complete offers" { const F = IndexFixture; - const Fail = struct { - suffix: []const u8, - fn rename(ctx: ?*anyopaque, io: std.Io, old: []const u8, new: []const u8) CoreCtx.RenameError!void { - const self: *const @This() = @ptrCast(@alignCast(ctx.?)); - if (std.mem.endsWith(u8, new, self.suffix)) return error.AccessDenied; - try std.Io.Dir.cwd().rename(old, std.Io.Dir.cwd(), new, io); - } + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const set = backend.dev.CodeExcerpt.Set{ .arena = undefined, .excerpts = &.{F.excerpt(1, "code")} }; + const specs = [_]PackFile.SpecEntry{F.spec(1, 0)}; + var closure = try SpliceClosure.init(F.allocator, &set); + defer closure.deinit(); + var graph = try Index.ComponentGraph.init(F.allocator, &closure); + defer graph.deinit(); + var names = try backend.dev.CodeExcerpt.ContentNames.init(F.allocator, &set); + defer names.deinit(); + const payload = try Index.payload(F.allocator, set.excerpts[0]); + defer F.allocator.free(payload); + const hashes = [_][32]u8{Index.digest(payload)}; + var components: [1][32]u8 = undefined; + const metadata = try Index.encodeComponent(F.allocator, &set, &graph, &closure, 0, &components, &hashes, &names); + defer F.allocator.free(metadata); + components[0] = Index.digest(metadata); + const selection = try Index.encodeOffer(F.allocator, components[0], 0, set.excerpts[0].kind.proc, specs[0]); + defer F.allocator.free(selection); + const offer = try F.allocator.alloc(u8, 32 + selection.len); + defer F.allocator.free(offer); + @memcpy(offer[0..32], &F.identity); + @memcpy(offer[32..], selection); + const identities = [_]compile.artifacts.Identity{ + fixture.store.indexedIdentity(.local, @splat(0), hashes[0], .payload), + fixture.store.indexedIdentity(.local, @splat(0), components[0], .metadata), + fixture.store.indexedIdentity(.local, specs[0].key, Index.digest(offer), .offer), }; - for ([_][]const u8{ ".rpa", ".rpm", ".rpi" }) |suffix| { - var fixture: F = undefined; - try fixture.init(); - defer fixture.deinit(); - var fail = Fail{ .suffix = suffix }; - fixture.store.roc_ctx.ctx = &fail; - fixture.store.roc_ctx.vtable.rename = Fail.rename; - try std.testing.expectError(error.PackWriteFailed, fixture.publish(&.{F.excerpt(1, "code")}, &.{F.spec(1, 0)}, &.{})); + for (identities, [_][]const u8{ payload, metadata, offer }, 0..) |id, bytes, index| { + _ = try fixture.store.publishIndexed(id, bytes); var packs = try fixture.loader(); defer packs.deinit(); - try std.testing.expect(packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) == null); - try std.testing.expect(!try fixture.store.hasIndexed(.local, F.identity, @splat(1))); + const hit = packs.specCacheLookup().lookup(specs[0].key, .dev_code, &.{}); + try std.testing.expectEqual(index == 2, hit != null); } } diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 77fbd0860b9..9f40ce54ce8 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -14183,7 +14183,7 @@ fn damageCompletedTestCache(io: std.Io, allocator: Allocator, cache_path: []cons if (entry.kind != .file or std.mem.find(u8, entry.path, segment) == null) continue; const bytes = try directory.readFileAlloc(io, entry.path, allocator, .limited(16 * 1024 * 1024)); defer allocator.free(bytes); - const magic = if (remove_payload) "ROC_TEST_OUTCOMES_V2" else "ROC_COMPLETED_DEV_TEST_V2"; + const magic = if (remove_payload) "ROC_TEST_OUTCOMES_V3" else "ROC_COMPLETED_DEV_TEST_V3"; if (!std.mem.startsWith(u8, bytes, magic)) continue; if (remove_payload) try directory.deleteFile(io, entry.path) else try directory.writeFile(io, .{ .sub_path = entry.path, .data = bytes[0 .. bytes.len / 2] }); damaged = true; From a64197200655044187527b3b9c5fc2ee9384dce0 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Sun, 25 Oct 2026 19:34:25 -0400 Subject: [PATCH 36/97] Notify retained proofs only when their own dependencies change --- design.md | 29 +-- src/postcheck/lambda_solved/type.zig | 4 +- src/postcheck/monotype/solve.zig | 4 +- src/postcheck/proofs.zig | 288 ++++++++++++++++----------- 4 files changed, 195 insertions(+), 130 deletions(-) diff --git a/design.md b/design.md index 7e1f18afbb4..89e4db5f73d 100644 --- a/design.md +++ b/design.md @@ -18181,16 +18181,17 @@ function bodies or specializations. A certificate records every canonical node it reads and references to earlier certificates. Each dependency owns a reverse link. Producers record semantic -writes in a graph-owned mutation index: one slot per node in a list ordered by -its latest write. Repeated writes move that slot to the front, so recording -never allocates and history cannot grow beyond the live graph. An observer -visits only nodes written since its last epoch, then follows reverse links to -retire affected proofs and their dependents. The mutation index occupies 16 -bytes per allocated node slot, independent of the number of writes. Removing a reverse link is constant -work using its recorded slot; invalidation frees certificates only after all -references have been detached. Unrelated writes neither inspect nor retire -unaffected proofs. Validation costs changed nodes plus affected proof edges, -not retained graph size or the number of intervening mutations. Path compression +writes through graph-owned intrusive subscriptions: each node owns the head of +its readers, and each published dependency owns its subscription. A write visits +only readers of that node and queues their invalidation without allocating. Query +owners have stable notification storage even when their enclosing cache moves. +At a query boundary the owner drains its notifications and follows reverse proof +links, detaching all references before freeing any certificates. The graph must +outlive its subscribers; scope exit unsubscribes in constant time per dependency. +The graph's index occupies one pointer per node. There is no mutation history for +each body-local observer to scan. Unrelated writes do not visit those observers. +Validation costs affected dependency edges and retired facts, independent of +unrelated graph writes. Path compression directly shortens existing links without recording a semantic mutation. Lazy expansion or joining representatives during a query prevents publication of that traversal. The special untouched-leaf shortcut also reads mutable clone-context ownership; it remains a direct @@ -18205,10 +18206,10 @@ payload bytes account for certificate arrays and table payload capacity (allocat metadata and table control bytes are excluded); allocator tests separately assert that warmed repetition performs no allocation. On x86_64, the issue 12616 interleaving fixtures settle five completion pairs and six -inhabitation nodes into 12 facts (4,176 accounted proof bytes; 6,600 total -query-owned bytes including scratch, across 49 initial allocations), and three -compatibility pairs into three facts (1,928 accounted proof bytes; 2,500 total -query-owned bytes across 20 initial allocations). Repeating A/B 512 times adds +inhabitation nodes into 12 facts (3,224 accounted proof bytes; 5,608 total +query-owned bytes including scratch, across 38 initial allocations), and three +compatibility pairs into three facts (1,216 accounted proof bytes; 1,756 total +query-owned bytes across 14 initial allocations). Repeating A/B 512 times adds no expansions, allocations or retained bytes. These are small deterministic graph measurements, not an attribution of the entire compiler phase's time. `ROC_FRESH_TYPE_ANALYSIS`, available only through developer diagnostics, starts diff --git a/src/postcheck/lambda_solved/type.zig b/src/postcheck/lambda_solved/type.zig index 5e894be0a40..c4f7a6d8cab 100644 --- a/src/postcheck/lambda_solved/type.zig +++ b/src/postcheck/lambda_solved/type.zig @@ -351,8 +351,8 @@ pub const Store = struct { } /// Visit exactly the identities changed since a proof observer last ran. - pub fn analysisChangesSince(self: *const Store, epoch: u64) @import("../proofs.zig").Changes(TypeVarId).Iterator { - return self.analysis_changes.since(epoch); + pub fn proofChanges(self: *Store) *@import("../proofs.zig").Changes(TypeVarId) { + return &self.analysis_changes; } pub fn markNamedBacking(self: *Store, id: TypeVarId) void { diff --git a/src/postcheck/monotype/solve.zig b/src/postcheck/monotype/solve.zig index c3fa9526e1a..4dc7af238dd 100644 --- a/src/postcheck/monotype/solve.zig +++ b/src/postcheck/monotype/solve.zig @@ -4950,8 +4950,8 @@ pub const InstGraph = struct { } /// Exact changed identities for dependency invalidation; compression is absent. - pub fn analysisChangesSince(self: *const InstGraph, epoch: u64) @import("../proofs.zig").Changes(NodeId).Iterator { - return self.analysis_changes.since(epoch); + pub fn proofChanges(self: *InstGraph) *@import("../proofs.zig").Changes(NodeId) { + return &self.analysis_changes; } /// Current root for the node's union-find class. diff --git a/src/postcheck/proofs.zig b/src/postcheck/proofs.zig index 82ceab51d7f..fd0e82f48c7 100644 --- a/src/postcheck/proofs.zig +++ b/src/postcheck/proofs.zig @@ -3,16 +3,19 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -/// A bounded mutation index for a graph with dense u32-backed node identities. -/// Each node occurs once in a list ordered by its most recent mutation. Moving -/// it to the front records another write without accumulating mutation history. +/// Graph-owned subscriptions to semantic writes. A disconnected node has no +/// subscribers. Writes visit only proofs which actually read the changed node. pub fn Changes(comptime Node: type) type { return struct { const Self = @This(); - const none = std.math.maxInt(u32); - const Record = struct { previous: u32 = none, next: u32 = none, version: u64 = 0 }; - records: std.ArrayList(Record) = .empty, - head: u32 = none, + /// Intrusive subscription owned by a published proof's dependency array. + pub const Watch = struct { + previous: ?*Watch = null, + next: ?*Watch = null, + context: *anyopaque, + notify: *const fn (*anyopaque, u64) void, + }; + records: std.ArrayList(?*Watch) = .empty, epoch: u64 = 0, /// Reserve node slots before the producer publishes their identities. @@ -23,51 +26,42 @@ pub fn Changes(comptime Node: type) type { pub fn ensureUnusedCapacity(self: *Self, gpa: Allocator, count: usize) Allocator.Error!void { try self.records.ensureUnusedCapacity(gpa, count); } - /// A new disconnected node changes no existing proof. + /// New nodes have no existing readers. pub fn appendAssumeCapacity(self: *Self) void { - self.records.appendAssumeCapacity(.{}); + self.records.appendAssumeCapacity(null); } - /// Begin an unrelated graph; all old identities and observers are gone. + /// The graph's query owners must finish before its node identities end. pub fn clearRetainingCapacity(self: *Self) void { + for (self.records.items) |head| std.debug.assert(head == null); self.records.clearRetainingCapacity(); - self.head = none; self.epoch = 0; } - /// Release the per-node index. + /// Release the graph after its query owners have detached. pub fn deinit(self: *Self, gpa: Allocator) void { + self.clearRetainingCapacity(); self.records.deinit(gpa); } - /// Record a producer-authored change. This cannot allocate or fail. + /// Register already allocated dependency storage. No producer write allocates. + pub fn subscribe(self: *Self, node: Node, watch: *Watch) void { + const head = &self.records.items[@backingInt(node)]; + watch.previous = null; + watch.next = head.*; + if (head.*) |next| next.previous = watch; + head.* = watch; + } + /// Detach in constant time, including after the node column has grown. + pub fn unsubscribe(self: *Self, node: Node, watch: *Watch) void { + if (watch.previous) |previous| previous.next = watch.next else self.records.items[@backingInt(node)] = watch.next; + if (watch.next) |next| next.previous = watch.previous; + } + /// Queue invalidation only; active queries may still borrow certificates. pub fn mark(self: *Self, node: Node) void { - const index = @backingInt(node); - const record = &self.records.items[index]; - if (record.version != 0) { - if (record.previous == none) self.head = record.next else self.records.items[record.previous].next = record.next; - if (record.next != none) self.records.items[record.next].previous = record.previous; - } self.epoch += 1; - record.* = .{ .next = self.head, .version = self.epoch }; - if (self.head != none) self.records.items[self.head].previous = index; - self.head = index; - } - /// Visits each node changed since an observer's epoch at most once. - pub const Iterator = struct { - records: []const Record, - cursor: u32, - epoch: u64, - /// The next changed identity; iteration stops before older nodes. - pub fn next(self: *Iterator) ?Node { - if (self.cursor == none) return null; - const index = self.cursor; - const record = self.records[index]; - if (record.version <= self.epoch) return null; - self.cursor = record.next; - return @fromBackingInt(index); + var cursor = self.records.items[@backingInt(node)]; + while (cursor) |watch| { + watch.notify(watch.context, self.epoch); + cursor = watch.next; } - }; - /// Read the exact mutation suffix without changing another observer. - pub fn since(self: *const Self, epoch: u64) Iterator { - return .{ .records = self.records.items, .cursor = self.head, .epoch = epoch }; } }; } @@ -79,21 +73,49 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { const Self = @This(); /// A directly read canonical node or an earlier completed certificate. pub const Dependency = union(enum) { node: Node, proof: *Proof }; - const Link = struct { dependency: Dependency, owner: *Proof, slot: usize }; + const MutationIndex = Changes(Node); + const Link = struct { dependency: Dependency, owner: *Proof, slot: usize, watch: MutationIndex.Watch = undefined }; + // Heap-owned so moving a body-local cache does not move notification state. + const Notifications = struct { + head: ?*Proof = null, + last_epoch: u64 = 0, + invalidations: usize = 0, + changed_nodes: usize = 0, + edges: usize = 0, + }; /// A group settled together, with its facts and reverse dependencies. pub const Proof = struct { valid: bool = true, + pending_next: ?*Proof = null, + notifications: *Notifications, slot: usize, dependencies: []Link, keys: []Key, dependents: std.ArrayList(*Link) = .empty, + fn invalidate(self: *Proof) void { + if (!self.valid) return; + self.valid = false; + self.pending_next = self.notifications.head; + self.notifications.head = self; + self.notifications.invalidations += 1; + } + fn changed(context: *anyopaque, epoch: u64) void { + const self: *Proof = @ptrCast(@alignCast(context)); + if (self.notifications.last_epoch != epoch) { + self.notifications.last_epoch = epoch; + self.notifications.changed_nodes += 1; + } + self.notifications.edges += 1; + self.invalidate(); + } }; /// One settled outcome and its justification. pub const Fact = struct { value: u2, proof: *Proof }; /// An outcome prepared for atomic publication. pub const Entry = struct { key: Key, value: u2 }; facts: std.AutoHashMapUnmanaged(Key, Fact) = .empty, - watchers: std.AutoHashMapUnmanaged(Node, std.ArrayList(*Link)) = .empty, + changes: ?*MutationIndex = null, + notifications: ?*Notifications = null, groups: std.ArrayList(*Proof) = .empty, pending: std.ArrayList(*Proof) = .empty, epoch: u64 = 0, @@ -102,28 +124,25 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { invalidation_edges: usize = 0, dependency_bytes: usize = 0, dependent_capacity_bytes: usize = 0, - watcher_capacity_bytes: usize = 0, /// Release certificates, tables and retained capacity. pub fn deinit(self: *Self, gpa: Allocator) void { self.clear(gpa); self.facts.deinit(gpa); - self.watchers.deinit(gpa); + if (self.notifications) |state| gpa.destroy(state); self.groups.deinit(gpa); self.pending.deinit(gpa); } /// End the producer scope and release every certificate and reverse list. pub fn clear(self: *Self, gpa: Allocator) void { + for (self.groups.items) |proof| for (proof.dependencies) |*link| self.unlink(gpa, link); for (self.groups.items) |proof| self.freeProof(gpa, proof); - var lists = self.watchers.valueIterator(); - while (lists.next()) |list| list.deinit(gpa); + if (self.notifications) |state| state.head = null; self.groups.clearRetainingCapacity(); self.pending.clearRetainingCapacity(); - self.watchers.clearRetainingCapacity(); self.facts.clearRetainingCapacity(); self.dependency_bytes = 0; self.dependent_capacity_bytes = 0; - self.watcher_capacity_bytes = 0; } fn freeProof(_: *Self, gpa: Allocator, proof: *Proof) void { proof.dependents.deinit(gpa); @@ -131,36 +150,25 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { gpa.free(proof.keys); gpa.destroy(proof); } - fn invalidate(self: *Self, proof: *Proof) void { - if (!proof.valid) return; - proof.valid = false; - self.pending.appendAssumeCapacity(proof); - self.invalidations += 1; - } - /// Call only outside a query: a running recorder may borrow proofs. - /// Work is proportional to changed nodes and affected proof edges. + /// Drain exact producer notifications outside a query. No unrelated + /// node or another analysis owner's mutation history is inspected. pub fn refresh(self: *Self, gpa: Allocator, epoch: u64, graph: anytype) void { - if (self.epoch == epoch) return; - if (self.groups.items.len == 0) { - self.epoch = epoch; - return; - } - var changes = graph.analysisChangesSince(self.epoch); + const changes = graph.proofChanges(); + std.debug.assert(self.groups.items.len == 0 or self.changes == changes); + self.changes = changes; self.epoch = epoch; - while (changes.next()) |node| { - self.changed_nodes += 1; - if (self.watchers.get(node)) |links| for (links.items) |link| { - self.invalidation_edges += 1; - self.invalidate(link.owner); - }; - } - var cursor: usize = 0; - while (cursor < self.pending.items.len) : (cursor += 1) { - for (self.pending.items[cursor].dependents.items) |link| { - self.invalidation_edges += 1; - self.invalidate(link.owner); + const state = self.notifications orelse return; + while (state.head) |proof| { + state.head = proof.pending_next; + self.pending.appendAssumeCapacity(proof); + for (proof.dependents.items) |link| { + state.edges += 1; + link.owner.invalidate(); } } + self.invalidations = state.invalidations; + self.changed_nodes = state.changed_nodes; + self.invalidation_edges = state.edges; // Unlink everything before freeing any referenced proof. for (self.pending.items) |proof| for (proof.dependencies) |*link| self.unlink(gpa, link); for (self.pending.items) |proof| { @@ -177,18 +185,15 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { self.pending.clearRetainingCapacity(); } fn unlink(self: *Self, gpa: Allocator, link: *Link) void { - const list = switch (link.dependency) { - .node => |node| self.watchers.getPtr(node).?, - .proof => |proof| &proof.dependents, - }; - _ = list.swapRemove(link.slot); - if (link.slot < list.items.len) list.items[link.slot].slot = link.slot; - if (link.dependency == .node and list.items.len == 0) { - self.watcher_capacity_bytes -= list.capacity * @sizeOf(*Link); - list.deinit(gpa); - const removed = self.watchers.remove(link.dependency.node); - std.debug.assert(removed); + switch (link.dependency) { + .node => |node| self.changes.?.unsubscribe(node, &link.watch), + .proof => |proof| { + const list = &proof.dependents; + _ = list.swapRemove(link.slot); + if (link.slot < list.items.len) list.items[link.slot].slot = link.slot; + }, } + _ = gpa; } /// Find a fact after the caller has refreshed dependencies. pub fn get(self: *Self, key: Key) ?Fact { @@ -201,37 +206,33 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { try self.facts.ensureUnusedCapacity(gpa, @intCast(entries.len)); try self.groups.ensureUnusedCapacity(gpa, 1); try self.pending.ensureTotalCapacity(gpa, self.groups.items.len + 1); + if (self.notifications == null) { + const state = try gpa.create(Notifications); + state.* = .{}; + self.notifications = state; + } const proof = try gpa.create(Proof); errdefer gpa.destroy(proof); const links = try gpa.alloc(Link, dependencies.len); errdefer gpa.free(links); const keys = try gpa.alloc(Key, entries.len); errdefer gpa.free(keys); - proof.* = .{ .slot = self.groups.items.len, .dependencies = links, .keys = keys }; + proof.* = .{ .notifications = self.notifications.?, .slot = self.groups.items.len, .dependencies = links, .keys = keys }; var linked: usize = 0; errdefer for (links[0..linked]) |*link| self.unlink(gpa, link); for (dependencies, links) |dependency, *link| { - const list = switch (dependency) { - .node => |node| blk: { - const entry = try self.watchers.getOrPut(gpa, node); - if (!entry.found_existing) entry.value_ptr.* = .empty; - break :blk entry.value_ptr; - }, - .proof => |earlier| &earlier.dependents, - }; - const capacity = list.capacity; - link.* = .{ .dependency = dependency, .owner = proof, .slot = list.items.len }; - list.append(gpa, link) catch |err| { - if (dependency == .node and list.items.len == 0) { - list.deinit(gpa); - _ = self.watchers.remove(dependency.node); - } - return err; - }; - const growth = (list.capacity - capacity) * @sizeOf(*Link); + link.* = .{ .dependency = dependency, .owner = proof, .slot = 0 }; switch (dependency) { - .node => self.watcher_capacity_bytes += growth, - .proof => self.dependent_capacity_bytes += growth, + .node => |node| { + link.watch = .{ .context = proof, .notify = Proof.changed }; + self.changes.?.subscribe(node, &link.watch); + }, + .proof => |earlier| { + const capacity = earlier.dependents.capacity; + link.slot = earlier.dependents.items.len; + try earlier.dependents.append(gpa, link); + self.dependent_capacity_bytes += (earlier.dependents.capacity - capacity) * @sizeOf(*Link); + }, } linked += 1; } @@ -247,10 +248,10 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { /// alignment padding and hash-table control bytes. pub fn retainedPayloadBytes(self: *const Self) usize { return self.facts.capacity() * (@sizeOf(Key) + @sizeOf(Fact)) + - self.watchers.capacity() * (@sizeOf(Node) + @sizeOf(std.ArrayList(*Link))) + + (if (self.notifications != null) @as(usize, @sizeOf(Notifications)) else 0) + (self.groups.capacity + self.pending.capacity) * @sizeOf(*Proof) + self.groups.items.len * @sizeOf(Proof) + self.dependency_bytes + - self.dependent_capacity_bytes + self.watcher_capacity_bytes; + self.dependent_capacity_bytes; } }; } @@ -260,8 +261,8 @@ test "issue 12616 proof dependencies invalidate transitively without dropping di const Store = struct { changes: Changes(Node) = .{}, /// Read exact test mutation events, independent of proof state. - pub fn analysisChangesSince(self: *const @This(), epoch: u64) Changes(Node).Iterator { - return self.changes.since(epoch); + pub fn proofChanges(self: *@This()) *Changes(Node) { + return &self.changes; } }; const gpa = std.testing.allocator; @@ -272,21 +273,84 @@ test "issue 12616 proof dependencies invalidate transitively without dropping di for (0..3) |_| graph.changes.appendAssumeCapacity(); var proofs: Proofs = .{}; defer proofs.deinit(gpa); + proofs.refresh(gpa, graph.changes.epoch, &graph); try proofs.publish(gpa, &.{.{ .key = 0, .value = 1 }}, &.{.{ .node = .a }}); try proofs.publish(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .proof = proofs.get(0).?.proof }}); try proofs.publish(gpa, &.{.{ .key = 2, .value = 0 }}, &.{.{ .node = .c }}); for (0..100) |_| graph.changes.mark(.b); proofs.refresh(gpa, graph.changes.epoch, &graph); try std.testing.expectEqual(@as(usize, 3), proofs.facts.count()); - try std.testing.expectEqual(@as(usize, 1), proofs.changed_nodes); + try std.testing.expectEqual(@as(usize, 0), proofs.changed_nodes); try std.testing.expectEqual(@as(usize, 0), proofs.invalidation_edges); graph.changes.mark(.a); proofs.refresh(gpa, graph.changes.epoch, &graph); try std.testing.expect(proofs.get(0) == null and proofs.get(1) == null); try std.testing.expectEqual(@as(u2, 0), proofs.get(2).?.value); try std.testing.expectEqual(@as(usize, 1), proofs.groups.items.len); - try std.testing.expectEqual(@as(usize, 2), proofs.changed_nodes); + try std.testing.expectEqual(@as(usize, 1), proofs.changed_nodes); proofs.clear(gpa); try std.testing.expectEqual(@as(usize, 0), proofs.facts.count()); try std.testing.expectEqual(@as(usize, 0), proofs.groups.items.len); } + +test "proof observers ignore disjoint graph writes and survive cache movement and node growth" { + const Node = enum(u32) { _ }; + const Graph = struct { + changes: Changes(Node) = .{}, + pub fn proofChanges(self: *@This()) *Changes(Node) { + return &self.changes; + } + }; + const gpa = std.testing.allocator; + var graph: Graph = .{}; + defer graph.changes.deinit(gpa); + try graph.changes.ensureUnusedCapacity(gpa, 1); + graph.changes.appendAssumeCapacity(); + var cache: Cache(u32, Node) = .{}; + cache.refresh(gpa, 0, &graph); + try cache.publish(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .node = @fromBackingInt(@intCast(0)) }}); + var moved = cache; + cache = .{}; + defer moved.deinit(gpa); + try graph.changes.ensureUnusedCapacity(gpa, 4096); + for (0..4096) |_| graph.changes.appendAssumeCapacity(); + for (1..4097) |i| { + graph.changes.mark(@fromBackingInt(@intCast(i))); + moved.refresh(gpa, graph.changes.epoch, &graph); + try std.testing.expectEqual(@as(u2, 1), moved.get(1).?.value); + } + try std.testing.expectEqual(@as(usize, 0), moved.changed_nodes); + try std.testing.expectEqual(@as(usize, 0), moved.invalidation_edges); + graph.changes.mark(@fromBackingInt(@intCast(0))); + moved.refresh(gpa, graph.changes.epoch, &graph); + try std.testing.expect(moved.get(1) == null); + try std.testing.expectEqual(@as(usize, 1), moved.changed_nodes); + try std.testing.expectEqual(@as(usize, 1), moved.invalidation_edges); +} + +fn allocationFailureFixture(gpa: Allocator) !void { + const Node = enum(u32) { a, b }; + const Graph = struct { + changes: Changes(Node) = .{}, + pub fn proofChanges(self: *@This()) *Changes(Node) { + return &self.changes; + } + }; + var graph: Graph = .{}; + defer graph.changes.deinit(gpa); + try graph.changes.ensureUnusedCapacity(gpa, 2); + graph.changes.appendAssumeCapacity(); + graph.changes.appendAssumeCapacity(); + var cache: Cache(u32, Node) = .{}; + defer cache.deinit(gpa); + cache.refresh(gpa, 0, &graph); + try cache.publish(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .node = .a }}); + try cache.publish(gpa, &.{.{ .key = 2, .value = 1 }}, &.{ .{ .node = .b }, .{ .proof = cache.get(1).?.proof } }); + graph.changes.mark(.a); + cache.refresh(gpa, graph.changes.epoch, &graph); + try std.testing.expectEqual(@as(usize, 0), cache.facts.count()); +} + +test "proof publication allocation failures detach every graph subscription" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationFailureFixture, .{}); +} From feeaec18e6cabb99932ccbb2f45748032361b570 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 07:36:47 -0400 Subject: [PATCH 37/97] Checkpoint incremental dev compilation and cache correctness improvements Reuse compile-time discovery and checked values across edits, keep fresh and replayed closed-type identities consistent, and reduce repeated specialization, proof, interface-cache, ownership-analysis, and retention work. Retention writes are bounded batches protected by existing generation leases. Reject indexed native test artifacts whose observation ABI is incompatible. All three project test suites, cached failure/diagnostic checks, and focused allocation cases pass with the closed-occurrence compiler. Focused retention and native-cache checks pass with the newer batching and admission changes; full-project timing of batching is pending. Post-edit builds remain above the target, so this is a backup checkpoint of ongoing work, not a finished change. --- design.md | 336 ++++++++++++- src/base/retained_cache.zig | 2 +- src/check/checked_artifact.zig | 5 + src/check/const_store.zig | 72 ++- src/check/inference_output.zig | 71 ++- src/check/value_identity.zig | 87 +++- src/cli/main.zig | 73 +-- src/cli/pack_index.zig | 2 +- src/cli/pack_store.zig | 421 ++++++++++++----- src/compile/artifact_store.zig | 447 ++++++++++++++++-- src/compile/cache_index.zig | 51 +- src/compile/cache_maintenance.zig | 49 +- src/compile/canonicalized_cache_entry.zig | 3 +- src/compile/coordinator.zig | 442 +++++++++++++++++- src/compile/discovery_roots.zig | 117 +++++ src/compile/test/demand_driven_ctfe_test.zig | 50 ++ src/eval/compile_time_finalization.zig | 30 +- src/eval/compile_time_reuse.zig | 105 ++++- src/lir/arc.zig | 25 + src/lir/arc_solve.zig | 8 + src/lir/checked_pipeline.zig | 29 +- src/lir/loop_append_promote.zig | 14 + src/lir/pack_program.zig | 96 +++- src/postcheck/lambda_solved/solve.zig | 369 ++++++++++++++- src/postcheck/monotype/interface_summary.zig | 188 ++++++-- src/postcheck/monotype/lower.zig | 467 +++++++++++++++---- src/postcheck/monotype/solve.zig | 48 +- src/postcheck/proc_identity.zig | 143 +++++- src/postcheck/proofs.zig | 54 ++- src/postcheck/solved_lir_lower.zig | 2 +- 30 files changed, 3382 insertions(+), 424 deletions(-) create mode 100644 src/compile/discovery_roots.zig diff --git a/design.md b/design.md index 89e4db5f73d..733fa10f64e 100644 --- a/design.md +++ b/design.md @@ -635,6 +635,16 @@ checked module key. Editing the body of a closed app function therefore leaves every procedure reaching it unchanged, and every other procedure keeps one identity across apps. +Procedure type identity depends on the infinite type, never its allocation +sharing or strongly connected component boundaries. The identity reducer retains +completed canonical node descriptions (non-edge content and ordered child +identities). Before naming a new recursive component, it checks every matching +head against those descriptions with a complete bisimulation proof. A partial +recursive copy pointing both into itself and into an earlier component takes +the earlier type's identity. Acyclic unfolding alone is insufficient: equivalent +types can have different component decompositions. These immutable descriptions +belong to the solved program's identity memo, not to a serialized compiler graph. + Names a procedure's code carries for the procedures it was built from are content identities too. A fused call variant (`body_clone.cloneCallVariant`) digests its leading arguments' and return's layouts by content, never by @@ -812,14 +822,24 @@ existence does not mean that a process owns the lock. Process death releases kernel ownership; no PID timeout or persistent lock marker establishes liveness. The store has sixteen shard files with 512-byte metadata records and fixed-width -crit-bit indexes for logical artifacts, allocation generations, and recency. A -lookup visits at most 512 branch records regardless of cache size. Lookups and +crit-bit indexes for logical artifacts, allocation generations, and recency. +The first 256 key bits identify an artifact group; that group digest selects +one shard for every member. A group enumeration opens only that catalog and +walks its ordered key range under one metadata transaction. The group identity +domain is versioned when this placement contract changes; older records remain +available to bounded maintenance, never to current lookups through old routing. +A lookup visits at most 512 branch records regardless of cache size. Lookups and updates use positional I/O, not whole-catalog reads or rewrites. Transactions have at most 24 changed records in one checksummed redo record, published before changing catalog records. Recovery finishes that transaction before admitting readers. There is no accumulating log to replay at startup. Payload generation and streaming happen outside -catalog locks. A corrupt record or an I/O failure is an ordinary cache failure, +catalog locks. Foreground lookup, reservation, publication and enumeration +wait for these short metadata transactions: a competing compiler worker is not +a cache miss and must not cause a completed write to be dropped. Maintenance +uses a separate nonblocking acquisition and skips busy shards. Generation +leases remain nonblocking during retirement, so maintenance never waits for a +live artifact reader. A corrupt record or an I/O failure is an ordinary cache failure, never a compiler invariant. Corrupt catalogs disable that shard rather than trusting its paths or scanning all payloads during a foreground operation. These are process-interruption guarantees, not a promise of power-loss durability. @@ -844,6 +864,27 @@ An artifact disappearing before acquisition is a cache miss. Existing cache header, key, layout, and dependency-closure validation remains authoritative; maintenance metadata never makes an otherwise invalid cache hit acceptable. +An invocation-owned native pack reader records its opening timestamp as the +retention observation for every acquisition in that reader. Repeated reads do +not rewrite recency metadata. A later invocation advances the observation; +an older concurrent reader cannot move it backward. Publication records its +own current timestamp, and generation leases still protect live acquisitions +regardless of retention age. Watch invocations open new readers for each build. + +The generation's owner leaf is authoritative for retention. The logical artifact +index supplies its immutable generation and physical slot; a reader validates +that slot's stored generation and key before using it. A hit advances the exact +use timestamp with one owner-leaf update under the normal redo protocol, without +rewriting the logical index or moving the recency index. The recency position +records a separate lower bound on use time. Before size eviction, maintenance +repairs one stale position as a bounded unit and reselects the oldest candidate; +it never evicts using that stale position. Age expiration reads the authoritative +use time directly. Replacement and retirement remove the owner's indexed +position. This protocol uses the artifacts-v2 storage namespace. Catalog +transactions memoize a bounded set of decoded reads while holding the shard +lock, consulting pending writes first; the memo never crosses a transaction. + + Maintenance checkpoints name logical slots and operation phases, never opaque filesystem directory positions. A pass captures a finite allocation boundary; new allocations cannot extend that pass indefinitely, and reused slots behind @@ -933,6 +974,12 @@ corrupt or incompatible optional entries are misses, never a reason to compile a missing body after admission. Publication uses only the code actually emitted for required tests, and complete result hits never enter this runtime consumer. +A proof invalidated by a graph write detaches its node subscriptions immediately, +without allocation or freeing its certificate. Further writes in the same batch +cannot notify that dirty proof again. The owner drains proof-to-proof propagation +and releases certificates at its next query boundary; no active query loses the +storage it borrows. + ### Native publication analysis ownership Offer collection owns one analysis of the frozen LIR store for each program. @@ -943,8 +990,15 @@ unkeyed-procedure boundary. Worklists mark each procedure at most once per property, including cycles. These facts expire with that LIR graph; they are never reused across a mutation or inferred from native instructions. -Native offer admission similarly propagates program-local and literal-conversion -restrictions over the exact splice graph once. Publication gathers the union +Native offer admission similarly propagates program-local, literal-conversion, +and evaluation-owned-value restrictions over the exact splice graph once. +A compile-time producer marks every procedure reading a static value or failure +slot owned by evaluation, using the LIR producer's slot metadata. Its captured +bytes may precede initialization and cannot be published as immutable constants. +The mark follows native dependencies, including higher-order callees missing +from the pre-closure Monotype call graph. Runtime producers use their finalized +constants and do not have this evaluation-only restriction. Observation +eligibility propagates over the shared LIR call graph once as well. Publication gathers the union of selected roots in a single traversal. Neither boolean query materializes a transitive closure per candidate. Explicit closures needed by persisted records are accounted separately from eligibility analysis. @@ -2793,9 +2847,13 @@ discovery request otherwise. Literal roots registered in discovery bodies are evaluated exactly as before, so `roc check` reports every rejected or crashing conversion a build reports. -An LSS runtime consumer needs every runtime body specialized anyway and -continues the evaluation's program, so a build with one still names its -program roots in full. Its compile-time consumer lowers and evaluates the +An LSS runtime consumer needs its own runtime bodies specialized and continues +the evaluation's program. An application build names its entrypoints as runtime +roots; tests discovered in its modules are compile-time discovery roots, just +like tests whose runtime outcomes have already been cached. These test bodies +must still validate specialization-dependent literals and replay compile-time +observations, but do not become runtime demand merely because an unrelated +application entrypoint has a runtime consumer. Its compile-time consumer lowers and evaluates the same compile-time and literal roots, and closed specializations keep the same object-cache keys in either program, so compiled compile-time code serves both. @@ -2807,6 +2865,31 @@ parked, unparked, and upgraded requests, and never-run stubs. #### Discovery Summaries +Closed test roots also retain a discovery certificate under the checked +producer's semantic dependency identity (`test_rows`), native compile-time +target and platform/app relation. That identity covers all branches, referenced +implementations, types, literal conversions and dispatch, independently of +source positions and unrelated tests. After finalization reports no errors and +no compile-time observations (fresh or replayed), every test root whose discovery +was requested or whose runtime body was specialized receives a certificate. +An existing certificate settles discovery of the same semantic root without +lowering its signature or body. It does not authorize executing or omitting a +test: test execution still follows its separate outcome cache. Pending module +compile-time roots and their diagnostics remain ordinary finalization work. + +Certificates are compact, checksummed manifests per source placement, with one +semantic key per settled root, in the indexed discovery artifact family and +compiler compatibility namespace. A changed dependency, conversion or relation +misses naturally. Missing, corrupt or incompatible manifests settle nothing. +Literal-root messages emitted directly, without a checked root event, also +mark the finalization as observing and prevent both root certificates and +specialization discovery summaries from being published. +An unsuccessful or observing finalization publishes no certificates; therefore +omitting discovery never loses an error or compile-time message. An application +build and a test run may both produce these facts, since both perform literal +discovery for the same closed test bodies. + + A compilation whose evaluation succeeds settles the discovery of every keyed specialization it lowered: every literal root the specialization's closure registered converted without an error, and evaluation printed nothing a later @@ -3289,8 +3372,10 @@ are addressed by their position in the request list, and a program with such an exhaustiveness diagnostic because only evaluating every root decides whether that diagnostic is reported. Each exclusion is decided from the checked program and the command, never from cache state. Discovery roots -("Demand-Driven Compile-Time Specialization") exist only without an LSS -runtime consumer, so a demand-driven compilation never has them. +("Demand-Driven Compile-Time Specialization") may accompany an LSS runtime +consumer: tests of an application build discover literals without becoming +runtime entrypoints. Filtering parked compile-time roots translates the +discovery boundary into the retained request list explicitly. Monotype declares every requested compile-time root up front: it reserves the root's entry template, so a read anywhere, including in a parallel worker's @@ -3304,7 +3389,12 @@ walk does not follow it. After each drain of the queue, the walk extends from the program's entries, and from the definitions of demanded roots whose bodies that drain lowered, over the definitions the drain committed; every parked root a reachable definition reads moves into the queue, and the next -drain lowers it. The rounds end when nothing reachable demands another root. +drain lowers it. A discovery request can expose a parked procedure declaration before a later +round's evaluation request completes its body. Body publication explicitly +invalidates any earlier demand summary for that declaration, and a declaration +already reached joins the next demand round at its newly published body. No +caller graph is rescanned: unchanged summaries and reached edges remain valid. +The rounds end when nothing reachable demands another root. A definition nothing reachable references demands nothing, though it was committed: an immediate claim, for one, lowers a callee's reserved body for a caller that keeps its own copy. The roots whose bodies lowered are exactly the @@ -3385,6 +3475,14 @@ would without the previous entry. Per-program literal roots, which belong to no checked module, are reused the same way from a file their program keeps (below). +Restoring a stored constant's type into Monotype memoizes the immutable source +type by its owning `ConstTypeStore` and type id. The memo belongs to the exact +destination `Type.Store`: the coordinator and every specialization workspace +own separate maps. Only successfully committed destination ids are published; +transaction-local ids and ids from another workspace never enter that map. +This shares immutable type descriptions, not Lambda Solved's mutable row +occurrences, which continue to follow the Row Joins contract. + **Value identity.** Each compile-time root carries a value identity (`CompileTimeRootIdentity.value`), a digest computed when the checked module is written, before evaluation, from explicit checked data. Two roots with @@ -3408,11 +3506,17 @@ covers: dispatch target and every `to_inspect` override a root's evaluation can select is covered by name. - The root's own computation. For a top-level value or callable binding, its - definition identity. For a hoisted root, the definition identity of the - definition that encloses it, its expression's source bytes, the - definition identities of the definitions that expression names, and its - hoisted body kind. Two hoisted roots of one definition that agree in all of - these, and in type, compute the same value. For a literal conversion root, the + definition identity. For a hoisted root, its expression's source bytes, + the definition identities of the definitions that expression names, its + resolved imported references, its hoisted body kind and extraction patterns, + and the checked type graph of every expression it contains, with sharing + preserved across those types. A lexical binding read additionally names the + enclosing declaration's full identity; that declaration supplies the binding. + The enclosing declaration's name identifies the site, but its unrelated code + is not a value dependency of a root without lexical reads. Local type + declarations retain the type digest's version-specific provenance, including + when a local type occurs only inside the computation. Two hoisted roots of one + declaration that agree in these facts compute the same value. For a literal conversion root, the literal's source bytes alone: the literal is closed, and the conversion it runs is selected by its checked type. For a top-level `expect`, its statement's source bytes and the definition identities of the definitions @@ -3437,6 +3541,17 @@ bytes spell, so the identity is independent of node ids and source positions. Whitespace inside a definition is part of its source bytes and changes its identity; that is a missed reuse, never a wrong one. +A stored nominal value may retain a checked type occurrence in the module that +stored it even when a different, unchanged module declares its nominal type. +Across an edit, reuse admission can relocate that occurrence through the old +checked root's canonical type key to a root published by the new checked module. +Every local occurrence in the value and its stored type must have this explicit +correspondence before any copy is installed. The copy changes only the occurrence +module and checked root id. A missing key rejects reuse; a type declared by the +edited module, local defaults, functions and generated types retain their existing +exclusions. This relocation permits ordinary full-dependency value identities; +it does not relax the module-independent contract for reuse across imported edits. + **Site.** Each root also carries a site (`CompileTimeRootIdentity.site`), the position-stable name of the root within its module: a top-level root's definition name, or, for a hoisted or literal root, its enclosing @@ -3478,6 +3593,19 @@ covers that type's declaration and methods through the type world. A stored constant's type names every nominal it mentions, so a constant whose type mentions one of the module's own types is not portable. +A completed value with no module-version references may also reuse the checked +producer's semantic dependency identity (`test_rows`) when an imported artifact +changes. This identity includes the referenced implementations, semantic type +contexts, checking policy, and file inputs. Admission requires both the value +and its stored type to contain no named type, field default, callable or other +module-version reference, and its recorded root-read set to be empty. These +conditions are checked against the previous published result, not guessed from +source syntax. Debug observations are copied and replayed in the ordinary root +order. Values carrying version-specific references keep their artifact-key +identity contract; semantic equality alone does not authorize retaining an old +checked-type occurrence or root id. Verification builds independently evaluate +and compare these transplants just like other compile-time value reuse. + **Transplant.** On a checked module cache miss, before evaluation, the coordinator locates the module's previous entry through a pointer filed under the module's placement (its package's URL or root directory, and its path @@ -5534,6 +5662,11 @@ graph and are preserved; only their foreign declaration locators change. A reference without a published correspondence rejects the optional capsule. No name, printed signature or numerical coincidence supplies a missing mapping. This relocation changes no solved type relationship or dispatch decision. +Admission indexes the old owner parameters once, preserving their first +occurrence and equality partition, then binds each stored dense module identity +to its current owner ordinal and builtin status. Every foreign reference reads +that binding directly; it never rescans the owner list. An unavailable owner +remains unavailable and rejects a reference exactly as a missing slot does. Diagnostic provenance has a separate admission contract. Stored outcomes retain checking problems and canonicalization diagnostics in order, together with all @@ -10990,6 +11123,17 @@ narrower type would be joined through it by type identity rather than by flow, and one tag label standing in both with two payload types would then be unified. +A fresh callable-free cyclic leaf may join an existing instance without +expansion only after an exact clone proof. That proof reads the immutable +Monotype and the current solved graph, compares their complete shape and +nominal metadata, and verifies that every repeated mutable source type already +names one target class. Shape equivalence alone is insufficient: expanding a +cycle can tie otherwise distinct target rows. An unexpanded mutable target does not establish that proof, even when its +subtree is acyclic: a hidden occurrence can share a mutable cell with a sibling. +An inherited argument/clone context likewise requires its own explicit proof. Certificates +subscribe to every solved node they read and retire on semantic writes. An +unsuccessful proof leaves the ordinary unification relation unchanged. + **`.boxy`.** Boxy plans a representation per checked type identity, and its planner computes the joins before planning any type (`computeRowJoins`, in `analyzeProgram`). It seeds a union-find over `CheckedTypeIdentity` from @@ -15488,6 +15632,11 @@ preceding explicit relations. Applying defaults is never part of cache identity. Digests select buckets; exact input constraints and evidence are the collision authorities. +An interface identity omits the tag row's `tags_sorted` memo. It still names +every tag, payload, extension and relation in their captured order; whether a +producer already recorded that this order is sorted supplies no constraint. +Captured and replayed rows retain their own sorting memo for traversal. + A request's input omits the finished-type witness of every open class. The witness records how the requesting graph reached a class, not what the request constrains: a body lowered at its sealed request imports every cell of its root @@ -15521,7 +15670,13 @@ source-interface or constructor evidence, forced-dynamic iterator identity, or generated or iterator representation authority. Structure reaching a hole stays open instead of settling, so requests that differ only in their holes' contents share one input identity and one summary, and relating the summary back to a -request fills its holes. A cell that is unresolved or carries representation +request fills its holes. Captured holes carry the explicit `interface_parameter` +origin. This is completed relation data and survives persistent serialization; +it is distinct from an unfinished instantiation `placeholder`, which the cache +loader rejects. An interface parameter has no default and must be bound before +final demand validation or materialization, just as an unfinished placeholder +must be filled. Unification with a checked variable preserves the checked +variable's defaulting evidence. A cell that is unresolved or carries representation authority is captured as itself: relating it back would not be plain unification. An unfinished expansion is joined only by the instantiation it expands: each hole slot of the joining request names the class the expanding @@ -22214,6 +22369,12 @@ the callers with a code key, may demand its outcome variant (`Solution.outcomeSpanForCall`), while every other caller uses its pinned base. +A complete outcome table whose restitution masks are all zero declares no +additional ownership convention. The solver publishes an empty capability for +it: no caller can demand a restituting variant, no second source-liveness graph +is needed, and the table cannot prevent object-cache admission. A nonempty +capability retains every outcome row, including zero masks on consuming paths. + The initial closed capability is deliberately exact and total. It applies only to a direct Roc procedure whose committed return layout is a top-level tag union whose committed representation contains RC state (so the certifier can @@ -23436,6 +23597,12 @@ A loop-parameter write that brings an outside list into the chain also prevents slack-based loop versioning: its new allocation may have less spare capacity than the loop head measured, even when that write measures fresh metadata. +The procedure scan identifies loop back edges and their list parameters before +metadata-transfer analysis builds control-flow use ordering. A round with no +remaining list parameter to consider requires no transfer analysis. This includes +straight-line helpers, non-loop joins, and the final scan after every candidate +has been considered; already planned loop versioning still runs. + A byte append (`list_append_le_bytes`) is a promotion site like a checked append. Under a limit with at least a word of slack it runs unchecked, storing the whole word at the length and bumping the length by its count; otherwise @@ -25100,8 +25267,10 @@ demands, its Solved program is not the one a runtime consumer continues. The program is specialized again from the checked modules, now holding every demanded value stored, exactly as a compilation that found them all in the cache specializes it (`ProgramSession.RuntimeStages.respecialized`). -That specialization demands no compile-time root; it registers the -program's literal roots again and evaluates them, and their outcomes, which +Discovery-only test roots have already completed their obligations and do not +join this second specialization. Runtime roots still register every conversion +their own code uses. That specialization demands no compile-time root; it registers the +runtime program's literal roots again and evaluates them, and their outcomes, which the first evaluation already reported, report nowhere. A literal root is not stored by any compilation, so both kinds of compilation read literal roots' completed values from the stages that evaluated them, the same way. @@ -26921,3 +27090,132 @@ the pass/fail bar while the language is 128-bit-only. ordering compares on `U64x2` are omitted because no cataloged kernel uses them and hardware support is ragged; any of them can be added later without disturbing existing meaning. + + +### Successful Initializer Validation Without Value Rebinding + +A test command validates all module initializers, including initializers no +executing test reads. Successful silent evaluation also publishes a semantic +validation certificate under the checked producer's dependency identity, native +target and platform/app relation. This fact is independent of the stored value: +it contains no type-store positions or nominal occurrences to rebind. + +On an edit, previously validated initializers can remain unevaluated until a +runtime, compile-time or literal root reads their values. Every initializer +without a valid certificate is still mandatory, including an unused initializer +whose source or dependency changed. The producer receives this explicit set and +seeds demand with every uncertified root; dependency closure still evaluates any +certified value that a reader needs. This preserves test/check validation rules +without repeating unused nominal-value construction. Missing or corrupt records +certify nothing. Failed or observing finalizations publish no facts, and ordinary +cached compile-time observations continue to replay without demand filtering. + +Test execution metadata is translated through the producer's selected +compile-time prefix. Its source order and result slots remain unchanged. A +certificate never authorizes omitting test execution or copying a stored value. + + +The consumer's `specialize_arc_modes` policy controls general ARC mode +specialization independently of `inline_mode`. Dev's source-stable wrapper +inlining does not authorize optional ARC variants. Compile-time evaluation uses +the dev policy even when it shares a Solved producer with a speed/size consumer; +each LIR continuation receives its own ARC policy. Mandatory owned-field-take +and outcome-restitution forms remain enabled, as specified above. + + +A fresh context-free cyclic Monotype leaf may also use the exact clone proof +when it contains functions. A fresh function's callable cell is unbound, so +joining it with an existing function cell contributes no lambda member. The +proof still checks every argument/result shape and requires each repeated +mutable source type (including each function type) to name one existing class. +Thus it cannot lose a recursive tie or merge distinct callable occurrences. +Forced-dynamic types, enclosing argument scopes, and externally observable +clone contexts retain their ordinary relation. The read-only proof does not +expand target nodes; graph writes invalidate its recorded dependencies. + + +Lambda Solved memoizes whether a positional Monotype expansion can read a +binding in an argument scope. The answer depends only on the immutable +Monotype graph and that scope's binding-key set. The binding producer clears +the scope's answers whenever it adds a new key; changing a destination cell +cannot change the answer. Independent scopes have independent answers. This +avoids repeating a whole immutable shape traversal at every unification +without weakening the existing argument-tie condition. + +Persistent Monotype interface summaries are indexed by both checked provider +family and exact request identity. A lookup loads only that request's record, +validates its family, request and payload digest, then retains owned bytes. +It never reads other requests or their historical variants. Absence is cached +only until publication refreshes the lookup snapshot. Each changed request is +published independently, so concurrent writers of different requests cannot +replace one another's summaries. These records retain the native procedure +compatibility domain; the RIFACE03 boundary isolates the per-request format. + +A produced-value witness traversal retains completed acyclic container pairs +for the current graph mutation epoch. Repeated paths through a shared DAG +reuse the same witness. A graph mutation invalidates reuse, and a traversal +that depended on an active-path recursive assumption publishes no witness. +The table ends with the relation traversal; it carries no result between bodies. + + +Persistent interface records distinguish checked occurrence provenance from +nominal ownership. An occurrence of a closed checked type is serialized by its +canonical checked type key and its nominal owner's exact artifact key. Reopening +binds that key only to a current published closed root with the same key and +owner. Representatives are chosen deterministically by artifact-key order and +memoized for the immutable lowering input set. A missing key rejects the optional +record. Types containing identity variables keep their exact artifact and root id; +no variable identity is reconstructed or merged. Nominal declaration, generated +representation and default-expression validation remain mandatory after rebinding. + +Each Monotype builder indexes its immutable input views by module content +identity before lowering. The first view in root/import/relation order wins, +exactly as in the input's linear lookup. Workers build the same index over their +borrowed views. Persistent summary validation and default-owner resolution use +this index rather than scanning all modules for every named type. + +Compile-time root declaration is an immutable checked request ordinal, not an +eager Monotype specialization. Demand-driven runs publish the entire declaration +manifest before workers start, without lowering its initializer signatures or +probing their native cache entries. A read records its declared ordinal in its +private draft; ordered coordinator commit prepares that root's real function and +replaces the explicit intent before final Monotype emission. Validation demand +prepares roots through the same operation. Unread, unvalidated declarations create +no type graph, specialization, function, or crash stub. Prepared roots retain the +existing parked-body protocol and demand order; allocation failure aborts the +compilation. Worker-visible request ordinals never change during compilation. + + +Interface-cache synchronization follows request identity. The family map lock +publishes stable request records; each record has its own lock covering load, +validation, replacement and publication. Independent requests can perform file +I/O concurrently, including requests in the same module. Repeated concurrent +reads of one request share its loaded payload or its snapshot-local miss. +Refresh and destruction occur after specialization workers join. The map lock +is always acquired before any record lock when both are needed; a record owner +never acquires the map lock. Statistics are aggregated atomically. + + +Freshly lowered named types and persistent interface replay select the same +current checked occurrence for a closed checked type key and nominal owner. +The checker's `contains_identity_variables` fact determines whether an occurrence +has instantiation identity. Closed occurrences use the existing deterministic +artifact-key representative; open occurrences retain their exact source module +and type id. This changes only checked provenance: Monotype arguments, backing, +generated representation, declared layout and default expressions remain the +explicit products of their existing producers. Lambda solving can consequently +recognize identical closed shapes without differences introduced solely by +whether an interface arrived from a cache or from fresh lowering. + + +Native artifact acquisition owners may batch exact retention observations while +holding their generation leases. Each shard batch contains at most the catalog's +24 changed-record limit and commits owner-leaf updates through the same redo +protocol. The current generation, physical slot and artifact key are revalidated; +newer observations never move backward. The owner flushes before releasing any +retained record lease, including when captured code outlives the original loader. +Rejected acquisitions can release early; an observation for a subsequently retired +generation is discarded rather than applied to its replacement. Process death +releases the leases and may lose unpublished retention observations, but cannot +invalidate already accepted immutable bytes. Failed optional retention writes do +not fail compilation. No age approximation or whole-catalog scan is introduced. diff --git a/src/base/retained_cache.zig b/src/base/retained_cache.zig index e13bd795967..29b4983225d 100644 --- a/src/base/retained_cache.zig +++ b/src/base/retained_cache.zig @@ -60,7 +60,7 @@ pub const Owner = struct { self.metrics.bytes_read += bytes.len; self.metrics.bytes_hashed += bytes.len; var hash: [32]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(bytes, &hash, .{}); + @import("sha256.zig").Sha256.hash(bytes, &hash, .{}); if (!std.mem.eql(u8, &hash, &expected)) return error.Corrupt; const record = try self.allocator.create(Record); errdefer self.allocator.destroy(record); diff --git a/src/check/checked_artifact.zig b/src/check/checked_artifact.zig index a6b581ab60d..d1837ff079e 100644 --- a/src/check/checked_artifact.zig +++ b/src/check/checked_artifact.zig @@ -37945,12 +37945,16 @@ pub fn publishFromTypedModule( .names = &canonical_names, .types = checked_types.view(), .roots = compile_time_roots.roots, + .bodies = checked_bodies, .top_level_values = &top_level_values, .context = .{ .checking_context = hashCheckingContextIdentity(checking_context_identity), .direct_import_keys = hashDirectImportArtifactKeys(direct_import_module_keys), }, .own_identity = module_identity.stable_hash, + .imports = inputs.imports, + .available = inputs.available_artifacts, + .relations = inputs.relation_artifacts, }); defer allocator.free(module_identities.definitions); defer allocator.free(module_identities.report_sites); @@ -37975,6 +37979,7 @@ pub fn publishFromTypedModule( .names = &canonical_names, .types = checked_types.view(), .roots = compile_time_roots.roots, + .bodies = checked_bodies, .top_level_values = &top_level_values, .context = .{ .checking_context = hashCheckingContextIdentity(test_checking_context), diff --git a/src/check/const_store.zig b/src/check/const_store.zig index b10fbc0da9d..229cb4593c9 100644 --- a/src/check/const_store.zig +++ b/src/check/const_store.zig @@ -490,6 +490,10 @@ pub const ConstFn = struct { evidence_frame_head: ?u32 = null, }; +/// Explicit old-to-current checked occurrence correspondence, established by +/// the checked type producer's canonical keys before a stored value is copied. +pub const TypeOccurrenceMap = collections.DenseMap(checked_ids.CheckedTypeId, checked_ids.CheckedTypeId); + /// Named type owner for a stored nominal constant. pub const NamedType = struct { /// The checked module that declares the type. @@ -1056,12 +1060,24 @@ pub const ConstTypeStore = struct { /// named type or field default that module declares, and no named type /// whose checked occurrence is in that module's type store (design.md /// "Compile-Time Value Reuse Across Edits"). + /// Null ownership requires independence from every module version. pub fn typePortable( self: *const ConstTypeStore, type_names: *const names.NameStore, root: ConstTypeId, - own: names.CheckedModuleDigest, - own_identity: *const [32]u8, + own: ?names.CheckedModuleDigest, + own_identity: ?*const [32]u8, + ) Allocator.Error!bool { + return self.typePortableWithOccurrences(type_names, root, own, own_identity, null); + } + + pub fn typePortableWithOccurrences( + self: *const ConstTypeStore, + type_names: *const names.NameStore, + root: ConstTypeId, + own: ?names.CheckedModuleDigest, + own_identity: ?*const [32]u8, + occurrences: ?*const TypeOccurrenceMap, ) Allocator.Error!bool { var seen = collections.DenseMap(ConstTypeId, void).init(self.allocator); defer seen.deinit(); @@ -1078,7 +1094,8 @@ pub const ConstTypeStore = struct { .tuple => |span| try pending.appendSlice(self.allocator, self.typeSpan(span)), .record => |span| for (self.fieldSpan(span)) |field| { if (field.default) |default| { - if (std.meta.eql(type_names.moduleIdentityBytes(default.module).*, own_identity.*)) return false; + const identity = own_identity orelse return false; + if (std.meta.eql(type_names.moduleIdentityBytes(default.module).*, identity.*)) return false; } try pending.append(self.allocator, field.ty); if (field.value_ty) |value_ty| try pending.append(self.allocator, value_ty); @@ -1086,9 +1103,11 @@ pub const ConstTypeStore = struct { .tag_union => |span| for (self.tagSpan(span)) |tag| try pending.appendSlice(self.allocator, self.typeSpan(tag.payloads)), .named => |named| { if (named.def.generated != null) return false; - if (std.meta.eql(named.named_type.module.bytes, own.bytes)) return false; - if (std.meta.eql(named.named_type.ty_module.bytes, own.bytes)) return false; - if (std.meta.eql(type_names.moduleIdentityBytes(named.def.module).*, own_identity.*)) return false; + const module = own orelse return false; + if (std.meta.eql(named.named_type.module.bytes, module.bytes)) return false; + if (std.meta.eql(named.named_type.ty_module.bytes, module.bytes) and + (occurrences == null or !occurrences.?.contains(named.named_type.ty))) return false; + if (std.meta.eql(type_names.moduleIdentityBytes(named.def.module).*, own_identity.?.*)) return false; try pending.appendSlice(self.allocator, self.typeSpan(named.args)); for (self.declaredFieldSpan(named.declared_order)) |declared| switch (declared) { .named => {}, @@ -1761,7 +1780,12 @@ pub const ConstStore = struct { /// module declares, and no named type whose checked occurrence is in that /// module's type store (design.md "Compile-Time Value Reuse Across /// Edits"). - pub fn valuePortable(self: *const ConstStore, root: ConstNodeId, own: names.CheckedModuleDigest) Allocator.Error!bool { + /// Null ownership requires independence from every module version. + pub fn valuePortable(self: *const ConstStore, root: ConstNodeId, own: ?names.CheckedModuleDigest) Allocator.Error!bool { + return self.valuePortableWithOccurrences(root, own, null); + } + + pub fn valuePortableWithOccurrences(self: *const ConstStore, root: ConstNodeId, own: ?names.CheckedModuleDigest, occurrences: ?*const TypeOccurrenceMap) Allocator.Error!bool { var seen = collections.DenseMap(ConstNodeId, void).init(self.allocator); defer seen.deinit(); var pending = std.ArrayList(ConstNodeId).empty; @@ -1775,8 +1799,10 @@ pub const ConstStore = struct { .pending => constStoreInvariant("a pending node reached a portability walk"), .fn_value => return false, .nominal => |nominal| { - if (std.meta.eql(nominal.named_type.module.bytes, own.bytes)) return false; - if (std.meta.eql(nominal.named_type.ty_module.bytes, own.bytes)) return false; + const module = own orelse return false; + if (std.meta.eql(nominal.named_type.module.bytes, module.bytes)) return false; + if (std.meta.eql(nominal.named_type.ty_module.bytes, module.bytes) and + (occurrences == null or !occurrences.?.contains(nominal.named_type.ty))) return false; }, .zst, .scalar, .str, .crash, .checked_error, .box, .list, .tuple, .record, .tag => {}, } @@ -2377,3 +2403,31 @@ fn sameBytes(a: []const u8, b: []const u8) bool { } return true; } + +test "module-independent portability excludes nested version-bearing values and types" { + const allocator = std.testing.allocator; + var store = ConstStore.init(allocator); + defer store.deinit(); + var type_names = names.NameStore.init(allocator); + defer type_names.deinit(); + const scalar = try store.append(.{ .scalar = .{ .i64 = 17 } }); + const list = try store.append(.{ .list = .{ .nodes = &.{scalar} } }); + try std.testing.expect(try store.valuePortable(list, null)); + const foreign: NamedType = .{ .module = .{ .bytes = @splat(1) }, .ty = @fromBackingInt(0), .ty_module = .{ .bytes = @splat(2) } }; + const nominal = try store.append(.{ .nominal = .{ .named_type = foreign, .backing = scalar } }); + const boxed = try store.append(.{ .box = nominal }); + try std.testing.expect(try store.valuePortable(boxed, .{ .bytes = @splat(3) })); + try std.testing.expect(!try store.valuePortable(boxed, null)); + + const integer = try store.type_store.append(.{ .primitive = .i64 }); + const integer_list = try store.type_store.append(.{ .list = integer }); + try std.testing.expect(try store.type_store.typePortable(&type_names, integer_list, null, null)); + const named = try store.type_store.append(.{ .named = .{ + .named_type = foreign, + .def = .{ .module = @fromBackingInt(0), .type_name = @fromBackingInt(0) }, + .kind = .@"opaque", + .args = .{}, + } }); + const named_list = try store.type_store.append(.{ .list = named }); + try std.testing.expect(!try store.type_store.typePortable(&type_names, named_list, null, null)); +} diff --git a/src/check/inference_output.zig b/src/check/inference_output.zig index 16372b603ec..3004add8466 100644 --- a/src/check/inference_output.zig +++ b/src/check/inference_output.zig @@ -105,7 +105,7 @@ pub fn install(self: Self, checker: anytype, scope: *const dependencies.Scope) A error.OutOfMemory => return error.OutOfMemory, error.IncompatibleReferences => base.invariant("admitted inference references remain valid during diagnostic installation", .{}), }; - defer binding.references.deinit(allocator); + defer binding.deinit(allocator); for (0..checker.snapshots.record_fields.len()) |i| { const id = @as(snapshotFieldIndex(), @fromBackingInt(@as(u32, @intCast(i)))); var field = checker.snapshots.record_fields.get(id); @@ -136,7 +136,7 @@ pub fn install(self: Self, checker: anytype, scope: *const dependencies.Scope) A /// invariant or a guessed mapping. pub fn bindReferences(self: Self, env: *can.ModuleEnv, scope: *const dependencies.Scope) (Allocator.Error || error{IncompatibleReferences})!void { var binding = try self.makeBinding(env, scope); - defer binding.references.deinit(env.gpa); + defer binding.deinit(env.gpa); for (self.outcome.record_fields) |record| { var field = record; try types.store.rebindSourceReferenceValue(&field, &binding); @@ -146,7 +146,24 @@ pub fn bindReferences(self: Self, env: *can.ModuleEnv, scope: *const dependencie fn makeBinding(self: Self, env: *can.ModuleEnv, scope: *const dependencies.Scope) Binding.Error!Binding { var binding = Binding{ .env = env, .scope = scope }; - errdefer binding.references.deinit(env.gpa); + errdefer binding.deinit(env.gpa); + // All references share one owner domain. Preserve first-occurrence + // ordinals once, instead of scanning that domain for every foreign node. + var ordinals = std.AutoHashMapUnmanaged(base.ModuleIdentity.Hash, u32).empty; + defer ordinals.deinit(env.gpa); + for (env.checking_dependency.owners.items.items, 0..) |hash, ordinal| { + const entry = try ordinals.getOrPut(env.gpa, hash); + if (!entry.found_existing) entry.value_ptr.* = @intCast(ordinal); + } + binding.owners = try env.gpa.alloc(?Binding.Owner, env.module_identities.count()); + for (binding.owners, 0..) |*owner, index| { + owner.* = null; + const hash = env.moduleIdentityHash(@fromBackingInt(@as(u32, @intCast(index)))); + const ordinal = ordinals.get(hash.*) orelse continue; + if (ordinal >= scope.owners.items.len) continue; + const current = scope.environments.get(scope.owners.items[ordinal]) orelse continue; + owner.* = .{ .ordinal = ordinal, .builtin = current.module_role == .builtin }; + } for (self.foreign_references) |reference| { if (reference.owner >= scope.owners.items.len) return error.IncompatibleReferences; const current = scope.environments.get(scope.owners.items[reference.owner]) orelse return error.IncompatibleReferences; @@ -165,10 +182,17 @@ fn snapshotFieldIndex() type { const Binding = struct { pub const Error = Allocator.Error || error{IncompatibleReferences}; const Key = struct { owner: u32, node: u32 }; + const Owner = struct { ordinal: u32, builtin: bool }; env: *can.ModuleEnv, scope: *const dependencies.Scope, + owners: []?Owner = &.{}, references: std.AutoHashMapUnmanaged(Key, u32) = .empty, + fn deinit(self: *Binding, allocator: Allocator) void { + allocator.free(self.owners); + self.references.deinit(allocator); + } + pub fn importNode(self: *Binding, env: *const can.ModuleEnv, import_idx: u32, raw: u32) Error!u32 { const resolved = env.imports.getResolvedModule(@fromBackingInt(import_idx)) orelse return error.IncompatibleReferences; if (resolved >= self.scope.imported.len) return error.IncompatibleReferences; @@ -179,25 +203,28 @@ const Binding = struct { pub fn node(self: *Binding, owner: base.ModuleIdentity.Idx, raw: u32) Error!u32 { if (owner == self.env.selfModuleIdentity()) return raw; - const old = self.env.moduleIdentityHash(owner); - const previous = self.env.checking_dependency.owners.items.items; - const ordinal = for (previous, 0..) |hash, i| { - if (base.ModuleIdentity.eql(&hash, old)) break i; - } else return error.IncompatibleReferences; - if (ordinal >= self.scope.owners.items.len) return error.IncompatibleReferences; - const current = self.scope.environments.get(self.scope.owners.items[ordinal]) orelse return error.IncompatibleReferences; - if (current.module_role == .builtin) return raw; - return self.references.get(.{ .owner = @intCast(ordinal), .node = raw }) orelse error.IncompatibleReferences; + const bound = self.owners[@backingInt(owner)] orelse return error.IncompatibleReferences; + if (bound.builtin) return raw; + return self.references.get(.{ .owner = bound.ordinal, .node = raw }) orelse error.IncompatibleReferences; } }; test "inference references require exact producer slots" { + try testInferenceReferenceBinding(false); +} + +test "inference reference indexes release partial allocation state" { + try testInferenceReferenceBinding(true); +} + +fn testInferenceReferenceBinding(allocation_failures: bool) !void { const allocator = std.testing.allocator; var env = try can.ModuleEnv.init(allocator, ""); defer env.deinit(); try env.initCIRFields("Importer"); try env.setContentIdentity(@splat(1)); const foreign = try env.internModuleIdentity(&@as(base.ModuleIdentity.Hash, @splat(2)), base.Ident.Idx.NONE); + const missing = try env.internModuleIdentity(&@as(base.ModuleIdentity.Hash, @splat(3)), base.Ident.Idx.NONE); var dependency = try can.ModuleEnv.init(allocator, ""); defer dependency.deinit(); try dependency.initCIRFields("Dependency"); @@ -207,19 +234,35 @@ test "inference references require exact producer slots" { var scope = try dependencies.Scope.init(allocator, @splat(1)); defer scope.deinit(); try scope.appendDependency(&dependency); + // Repeated owner parameters must still resolve through the first slot. + _ = try scope.appendOwner(@splat(2)); env.checking_dependency.owners = try scope.copyOwners(allocator); var references = [_]ForeignReference{.{ .owner = 1, .node = 17, .slot = 0 }}; const output = Self{ .foreign_references = &references }; + if (allocation_failures) { + // Admission only reads the already-owned source and dependency data. + // Fail allocations in the new binding, independently of fixture setup. + return std.testing.checkAllAllocationFailures(allocator, testBindingAllocation, .{ &env, &scope, output }); + } var binding = try output.makeBinding(&env, &scope); - defer binding.references.deinit(allocator); + defer binding.deinit(allocator); try std.testing.expectEqual(@as(u32, 42), try binding.node(foreign, 17)); + try std.testing.expectEqual(@as(u32, 17), try binding.node(env.selfModuleIdentity(), 17)); + try std.testing.expectError(error.IncompatibleReferences, binding.node(missing, 17)); try std.testing.expectError(error.IncompatibleReferences, binding.node(foreign, 42)); references[0].slot = 1; try std.testing.expectError(error.IncompatibleReferences, output.makeBinding(&env, &scope)); - references[0] = .{ .owner = 2, .node = 17, .slot = 0 }; + references[0] = .{ .owner = 3, .node = 17, .slot = 0 }; try std.testing.expectError(error.IncompatibleReferences, output.makeBinding(&env, &scope)); references[0] = .{ .owner = 1, .node = 17, .slot = 0 }; const duplicates = Self{ .foreign_references = &.{ references[0], references[0] } }; try std.testing.expectError(error.IncompatibleReferences, duplicates.makeBinding(&env, &scope)); } + +fn testBindingAllocation(allocator: Allocator, env: *const can.ModuleEnv, scope: *const dependencies.Scope, output: Self) !void { + var borrowed = env.*; + borrowed.gpa = allocator; + var binding = try output.makeBinding(&borrowed, scope); + defer binding.deinit(allocator); +} diff --git a/src/check/value_identity.zig b/src/check/value_identity.zig index dcc692448cd..30d5bb9ed70 100644 --- a/src/check/value_identity.zig +++ b/src/check/value_identity.zig @@ -57,6 +57,7 @@ pub const Inputs = struct { names: *const canonical.CanonicalNameStore, types: CheckedTypeStoreView, roots: []const CompileTimeRoot, + bodies: *const checked_artifact.CheckedBodyStore, top_level_values: *const checked_artifact.TopLevelValueTable, /// The checked artifact key's inputs outside the module's own source: /// the checking context and the direct imports' keys. @@ -77,7 +78,7 @@ pub const ContextHashes = struct { direct_import_keys: [32]u8, }; -const domain = "roc.compile_time_value_identity.v1"; +const domain = "roc.compile_time_value_identity.v2"; /// The definition identity of a definition the module exports, by the /// digest of its name. @@ -868,6 +869,48 @@ const State = struct { return ordinal; } + /// A hoisted expression is typed independently of its owner's unrelated + /// expressions. Retain the checked types of its complete expression graph, + /// including sharing between those types. A read of a lexical binding + /// additionally names its enclosing declaration, which supplies that binding. + fn hoistedTypeContext(self: *State, root: CIR.Expr.Idx, hash: *Sha256) Allocator.Error!bool { + var pending = std.ArrayList(CIR.Expr.Idx).empty; + defer pending.deinit(self.allocator); + var seen = collections.DenseMap(CIR.Expr.Idx, void).init(self.allocator); + defer seen.deinit(); + var types = std.ArrayList(CheckedTypeId).empty; + defer types.deinit(self.allocator); + var lexical = false; + try pending.append(self.allocator, root); + while (pending.pop()) |expr| { + if ((try seen.getOrPut(expr)).found_existing) continue; + const expression = self.inputs.env.store.getExpr(expr); + const id = self.inputs.bodies.exprIdForSource(expr) orelse + base.invariant("hoisted expression has no published checked body", .{}); + const body = self.inputs.bodies.expr(id); + hashBytes(hash, @tagName(expression)); + hashBytes(hash, @tagName(body.data)); + try types.append(self.allocator, body.ty); + switch (expression) { + .e_lookup_local => |lookup| if (!self.pattern_to_def.contains(lookup.pattern_idx)) { + lexical = true; + }, + .e_closure => |closure| for (self.inputs.env.store.sliceCaptures(closure.captures)) |capture_id| { + const capture = self.inputs.env.store.getCapture(capture_id); + if (!self.pattern_to_def.contains(capture.pattern_idx)) lexical = true; + }, + else => {}, + } + try appendExprChildren(self.inputs.env, expr, &pending, self.allocator); + } + hash.update(&try self.type_walk.digestRoots(types.items)); + return lexical; + } + + fn hashHoistedPattern(self: *const State, hash: *Sha256, pattern: CIR.Pattern.Idx) void { + hashBytes(hash, self.regionText(self.inputs.env.store.getPatternRegion(pattern))); + } + fn rootIdentity( self: *State, root: CompileTimeRoot, @@ -903,17 +946,32 @@ const State = struct { hashTag(&class, "hoisted"); class.update(&self.ownerKey(owner)); hashBytes(&class, @tagName(root.kind)); - if (owner) |resolved| switch (resolved) { - .def => |def_idx| class.update(&self.definitionIdentity(def_idx)), - .expect => |statement| { - hashBytes(&class, self.regionText(env.store.getStatementRegion(statement))); - hashDigests(&class, try self.referencedIdentities(expectBody(env, statement))); - }, - .type_default => |statement| hashBytes(&class, self.regionText(env.store.getStatementRegion(statement))), - } else hashTag(&class, "unowned"); + const lexical = try self.hoistedTypeContext(hoisted.expr, &class); + if (lexical) { + const resolved = owner orelse base.invariant("a hoisted lexical dependency has no enclosing declaration", .{}); + switch (resolved) { + .def => |def_idx| class.update(&self.definitionIdentity(def_idx)), + .expect => |statement| { + hashBytes(&class, self.regionText(env.store.getStatementRegion(statement))); + hashDigests(&class, try self.referencedIdentities(expectBody(env, statement))); + if (self.inputs.test_dependencies) class.update(&try self.importedReferences(expectBody(env, statement))); + }, + .type_default => |statement| hashBytes(&class, self.regionText(env.store.getStatementRegion(statement))), + } + } hashBytes(&class, if (root.hoisted_body) |body| @tagName(body) else "no-body"); + if (root.hoisted_body) |body| switch (body) { + .expr => {}, + .pattern_extraction => |extraction| { + self.hashHoistedPattern(&class, extraction.scrutinee_pattern); + self.hashHoistedPattern(&class, extraction.result_pattern); + }, + .pattern_validation => |validation| self.hashHoistedPattern(&class, validation.scrutinee_pattern), + .pattern_error, .valueless_binding => |pattern| self.hashHoistedPattern(&class, pattern), + }; hashBytes(&class, self.regionText(env.store.getExprRegion(hoisted.expr))); hashDigests(&class, try self.referencedIdentities(hoisted.expr)); + class.update(&try self.importedReferences(hoisted.expr)); class.update(&type_digest); const class_digest = class.finalResult(); value.update(&class_digest); @@ -989,11 +1047,20 @@ const TypeDigest = struct { } fn digest(self: *TypeDigest, root: CheckedTypeId) Allocator.Error![32]u8 { + return self.digestRoots(&.{root}); + } + + fn digestRoots(self: *TypeDigest, roots: []const CheckedTypeId) Allocator.Error![32]u8 { self.visited.clearRetainingCapacity(); self.stack.clearRetainingCapacity(); var hasher = Sha256.init(.{}); hashTag(&hasher, "checked-type"); - try self.stack.append(self.allocator, root); + hashU32(&hasher, @intCast(roots.len)); + var root_index = roots.len; + while (root_index > 0) { + root_index -= 1; + try self.stack.append(self.allocator, roots[root_index]); + } var next_position: u32 = 0; // Children are pushed in reverse so they pop, and are written, in order. var children = std.ArrayList(CheckedTypeId).empty; diff --git a/src/cli/main.zig b/src/cli/main.zig index ca016b1adee..9551369e350 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -7389,8 +7389,8 @@ fn publishedShimLibraryPath( /// leased scratch directory, so subsequent linker/process opens cannot race GC. fn cachedFileIdentity(family: compile.artifacts.Family, name: []const u8) compile.artifacts.Identity { var id: compile.artifacts.Identity = .{ .namespace = undefined, .family = family, .key = undefined }; - std.crypto.hash.sha2.Sha256.hash(build_options.compiler_compatibility_id, &id.namespace, .{}); - std.crypto.hash.sha2.Sha256.hash(name, &id.key, .{}); + base.Sha256.hash(build_options.compiler_compatibility_id, &id.namespace, .{}); + base.Sha256.hash(name, &id.key, .{}); return id; } @@ -9142,15 +9142,15 @@ const PackOffers = struct { /// differently, so evaluation's code for an entry that reads one is not /// the code another program compiles, and such an entry is withheld. evaluation: bool, - observation_independent: []const bool = &.{}, offered_count: ?*usize = null, /// Every keyed specialization, in program order. keyed: std.ArrayList(Candidate) = .empty, /// Every procedure offered by its identity (`Common.procIdentityCacheKey`), /// which serves specializations no reservation-time key names. by_identity: std.ArrayList(Candidate) = .empty, - /// The procedures that convert a specialized custom literal when they run. - literal_converters: std.AutoHashMap(lir.ProcIdentity, void), + /// Procedures with literal conversions or evaluation-owned value reads. + /// Their native callers are rejected over the exact captured graph. + rejected_procedures: std.AutoHashMap(lir.ProcIdentity, void), /// Every key gathered. A key two programs offer names one procedure, /// offered once, from the first program; identity keys are hashed apart /// from specialization keys, so the two kinds never share one. @@ -9180,14 +9180,14 @@ const PackOffers = struct { return .{ .arena = base.SingleThreadArena.init(allocator), .evaluation = evaluation, - .literal_converters = std.AutoHashMap(lir.ProcIdentity, void).init(allocator), + .rejected_procedures = std.AutoHashMap(lir.ProcIdentity, void).init(allocator), .keys = std.AutoHashMap([32]u8, ?[32]u8).init(allocator), }; } fn deinit(self: *PackOffers) void { self.keys.deinit(); - self.literal_converters.deinit(); + self.rejected_procedures.deinit(); self.arena.deinit(); } @@ -9197,17 +9197,17 @@ const PackOffers = struct { lowered: *const lir.CheckedPipeline.LoweredProgram, comptime_reads: *const lir.CheckedPipeline.SpecComptimeReads, ) Allocator.Error!void { - const allocator = self.literal_converters.allocator; + const allocator = self.rejected_procedures.allocator; const store = &lowered.lir_result.store; - const independent = try lir.PackProgram.observationIndependentProcs(allocator, store); - defer allocator.free(independent); - self.observation_independent = independent; - defer self.observation_independent = &.{}; - var facts = try lir.PackProgram.OfferFacts.init(allocator, store); + var facts = try lir.PackProgram.OfferFacts.initProgram(allocator, &lowered.lir_result); defer facts.deinit(); if (dev_diagnostics.isSet(.pack_stats)) std.debug.print("pack offer analysis: statements={d} call_edges={d} propagation_edges={d}\n", .{ facts.statements_visited, facts.call_edges, facts.propagation_edges }); - for (store.getProcSpecs(), facts.literal_conversion) |proc, converts| { - if (converts) try self.literal_converters.put(proc.identity, {}); + for (store.getProcSpecs(), facts.literal_conversion, facts.reads_evaluation_value) |proc, converts, evaluation_read| { + // Evaluation-owned slots are mutable and may still be empty when + // a level's native image is captured. They cannot become readonly + // constants in another program. Admission follows the actual native + // dependency graph, including specialized higher-order callees. + if (converts or (self.evaluation and evaluation_read)) try self.rejected_procedures.put(proc.identity, {}); } for (lowered.lir_result.spec_procs.items) |spec_proc| { const key_entry = try self.keys.getOrPut(spec_proc.key); @@ -9223,7 +9223,10 @@ const PackOffers = struct { // under its code key; any other is offered under its identity only // when its identity names all of its code and all the code the // pack carries with it. - if (carries_site_code) continue; + if (carries_site_code) { + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("pack withheld-site proc={x} {s}\n", .{ proc.identity.bytes, store.procDebugName(id) orelse "" }); + continue; + } const key = postcheck.Common.procIdentityCacheKey(if (proc.code_key) |code| code else proc.identity.bytes); const key_entry = try self.keys.getOrPut(key); if (key_entry.found_existing) continue; @@ -9254,22 +9257,18 @@ const PackOffers = struct { // entry rather than calling it, and has no body for an entry, so it // sees the entry outside every call cycle and would reach it with an // ordinary call where the cycle needs a frame-replacing one. - const closure = try lir.PackProgram.procCallClosure(self.literal_converters.allocator, store, id); - defer self.literal_converters.allocator.free(closure); - var test_compatible = true; - for (closure, self.observation_independent) |reached, independent| { - if (reached and !independent) test_compatible = false; - } + const test_compatible = !facts.reaches_observation[@backingInt(id)]; const withheld = (lowered.lir_result.expect_sites.items.len != 0 and !test_compatible) or !reads.offerable or (self.evaluation and reads.evaluated) or proc.rc_variant_demandable or facts.reaches_linked[@backingInt(id)] or proc.inlined_at_calls or proc.tail_group != null; + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("pack admission key={x} proc={x} withheld={} reads={} evaluated={} variant={} linked={} inline={} tail={} {s}\n", .{ key, proc.identity.bytes, withheld, reads.offerable, self.evaluation and reads.evaluated, proc.rc_variant_demandable, facts.reaches_linked[@backingInt(id)], proc.inlined_at_calls, proc.tail_group != null, store.procDebugName(id) orelse "" }); // An entry this program spliced from a pack is offered again with // its producer's code; only a body lowered here has a fingerprint. const fingerprint: ?u64 = if (dev_diagnostics.isSet(.pack_trace) and proc.body != null) - try procFingerprint(self.literal_converters.allocator, lowered, id) + try procFingerprint(self.rejected_procedures.allocator, lowered, id) else null; return .{ @@ -9297,7 +9296,7 @@ const PackOffers = struct { errdefer specs.deinit(allocator); const trace = dev_diagnostics.isSet(.pack_trace); var withheld: usize = 0; - var converting_excerpts = try literalConvertingExcerpts(allocator, set, &self.literal_converters); + var converting_excerpts = try markedProcedureExcerpts(allocator, set, &self.rejected_procedures); defer converting_excerpts.deinit(allocator); var closure = try SpliceClosure.init(allocator, set); defer closure.deinit(); @@ -9317,6 +9316,7 @@ const PackOffers = struct { // may not carry; an entry that reaches either cannot be // linked elsewhere, so it is not offered. if (rejected[excerpt]) { + if (trace) std.debug.print("pack withheld-native key={x} proc={x} {s}\n", .{ offer.key, offer.identity.bytes, offer.name }); withheld += 1; continue; } @@ -9393,17 +9393,17 @@ fn procFingerprint(allocator: Allocator, lowered: *const lir.CheckedPipeline.Low /// indexed once; each query reuses the walk's storage. const SpliceClosure = pack_store.SpliceClosure; -/// The excerpts of `set` whose procedure converts a specialized custom -/// literal when it runs (`PackProgram.procConvertsLiteralAtRuntime`). -fn literalConvertingExcerpts( +/// Excerpts whose procedures carry explicit reasons to withhold a cache offer: +/// runtime literal conversion or reads of evaluation-owned mutable slots. +fn markedProcedureExcerpts( allocator: Allocator, set: *const backend.dev.CodeExcerpt.Set, - literal_converters: *const std.AutoHashMap(lir.ProcIdentity, void), + rejected_procedures: *const std.AutoHashMap(lir.ProcIdentity, void), ) Allocator.Error!std.DynamicBitSetUnmanaged { var converting = try std.DynamicBitSetUnmanaged.initEmpty(allocator, set.excerpts.len); - if (literal_converters.count() == 0) return converting; + if (rejected_procedures.count() == 0) return converting; for (set.excerpts, 0..) |excerpt, index| switch (excerpt.kind) { - .proc => |identity| if (literal_converters.contains(identity)) converting.set(index), + .proc => |identity| if (rejected_procedures.contains(identity)) converting.set(index), .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => {}, }; return converting; @@ -9512,7 +9512,7 @@ test "pack withholds an entry whose stable references reach a literal-converting var converters = std.AutoHashMap(lir.ProcIdentity, void).init(std.testing.allocator); defer converters.deinit(); try converters.put(converter, {}); - var converting_excerpts = try literalConvertingExcerpts(std.testing.allocator, &set, &converters); + var converting_excerpts = try markedProcedureExcerpts(std.testing.allocator, &set, &converters); defer converting_excerpts.deinit(std.testing.allocator); var closure = try SpliceClosure.init(std.testing.allocator, &set); defer closure.deinit(); @@ -14418,6 +14418,7 @@ fn checkedRuntimeLoweringConfig( .erased_capture_prefix = devCapturePrefix(roots, opt, target), .specialization_strategy = specialization_strategy, .inline_mode = postCheckInlineModeForOpt(opt), + .specialize_arc_modes = optimizeLirForOpt(opt), .spec_constr_clone_inlining = specConstrCloneInliningForOpt(opt), .consume_dead_boxes = switch (roots) { .linked_output => true, @@ -21930,3 +21931,13 @@ test "bidi controls in diagnostic summary paths remain visible" { var iter = base.bidi.Iterator{ .bytes = output.written() }; try std.testing.expect(iter.next() == null); } + +test "dev ARC specialization is independent of source inlining" { + for ([_]cli_args.OptLevel{ .dev, .interpreter, .speed, .size }) |opt| { + const config = checkedRuntimeLoweringConfig(.linked_output, opt, .lss, .x64glibc, false); + const expected = opt == .speed or opt == .size; + try std.testing.expectEqual(expected, config.target.specialize_arc_modes); + try std.testing.expectEqual(expected, lir.CheckedPipeline.LirPolicy.fromTarget(config.target).specialize_arc_modes); + if (opt == .dev) try std.testing.expect(config.target.inline_mode != .none); + } +} diff --git a/src/cli/pack_index.zig b/src/cli/pack_index.zig index 8f86c81e504..b4cea12415d 100644 --- a/src/cli/pack_index.zig +++ b/src/cli/pack_index.zig @@ -16,7 +16,7 @@ pub const Error = PF.ReadError; /// Identity and integrity digest of immutable record bytes. pub fn digest(bytes: []const u8) [32]u8 { var result: [32]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(bytes, &result, .{}); + base.Sha256.hash(bytes, &result, .{}); return result; } diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index b7b5753c885..80ded54f01d 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -36,6 +36,8 @@ pub const Store = struct { /// Logical scope for procedure packs and discovery summaries. procedure_root: []u8, native: compile.artifacts.Store, + /// One retention observation for this invocation-owned cache reader. + read_timestamp: i64, variant: [32]u8, verbose: bool, publication_stats: ?*PublicationStats = null, @@ -77,7 +79,8 @@ pub const Store = struct { errdefer allocator.free(procedure_root); const cache_root = try config.getEffectiveCacheDir(allocator); defer allocator.free(cache_root); - return .{ .allocator = allocator, .roc_ctx = config.roc_ctx, .root = root, .procedure_root = procedure_root, .variant = compile.artifacts.hashBytes(opt_name), .native = try compile.artifacts.Store.init(allocator, config.roc_ctx.std_io, cache_root), .verbose = config.verbose, .domain = backend.dev.NativeProcDomain.init(target, strategy).key() }; + const native = try compile.artifacts.Store.init(allocator, config.roc_ctx.std_io, cache_root); + return .{ .allocator = allocator, .roc_ctx = config.roc_ctx, .root = root, .procedure_root = procedure_root, .variant = compile.artifacts.hashBytes(opt_name), .native = native, .read_timestamp = native.now(), .verbose = config.verbose, .domain = backend.dev.NativeProcDomain.init(target, strategy).key() }; } pub fn deinit(self: *Store) void { @@ -88,8 +91,8 @@ pub const Store = struct { fn artifactIdentity(self: *const Store, family: compile.artifacts.Family, module: [32]u8, key: [32]u8) compile.artifacts.Identity { var result: compile.artifacts.Identity = .{ .namespace = undefined, .family = family, .group = undefined, .key = key, .pack_format = PackFile.format_version, .variant = self.variant }; - std.crypto.hash.sha2.Sha256.hash(@import("build_options").compiler_compatibility_id, &result.namespace, .{}); - var hash = std.crypto.hash.sha2.Sha256.init(.{}); + base.Sha256.hash(@import("build_options").compiler_compatibility_id, &result.namespace, .{}); + var hash = base.Sha256.init(.{}); hash.update(switch (family) { .local_pack, .package_pack, .local_discovery, .package_discovery => self.procedure_root, .checked, .canonicalized, .placement, .literals, .pairing, .executable, .test_result, .wasm_host, .glue_plugin, .optimized_pack, .program_output, .scratch => self.root, @@ -103,17 +106,17 @@ pub const Store = struct { fn indexedIdentity(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, key: [32]u8, kind: IndexedKind) compile.artifacts.Identity { var id = self.packIdentity(origin, module, key); - var hash = std.crypto.hash.sha2.Sha256.init(.{}); + var hash = base.Sha256.init(.{}); hash.update(&id.variant); hash.update("roc-indexed-procedure-v1"); - hash.update(@tagName(kind)); + hash.update(if (kind == .interface) "interface-request-v3" else @tagName(kind)); hash.final(&id.variant); return id; } pub fn linkObjectIdentity(self: *const Store, digest: [32]u8) compile.artifacts.Identity { var id = self.artifactIdentity(.program_output, @splat(0), digest); - var hash = std.crypto.hash.sha2.Sha256.init(.{}); + var hash = base.Sha256.init(.{}); hash.update(&id.variant); hash.update("roc-native-link-object-v1"); hash.final(&id.variant); @@ -121,7 +124,7 @@ pub const Store = struct { } fn readPrefix(self: *const Store, id: compile.artifacts.Identity, len: usize, allocator: Allocator) Allocator.Error!?[]u8 { - var artifact = (self.native.acquire(id, self.native.now()) catch |err| switch (err) { + var artifact = (self.native.acquire(id, self.read_timestamp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return null, }) orelse return null; @@ -148,7 +151,7 @@ pub const Store = struct { } fn read(self: *const Store, allocator: Allocator, id: compile.artifacts.Identity) Allocator.Error!?[]u8 { - return self.native.read(allocator, id, self.native.now()) catch |err| switch (err) { + return self.native.read(allocator, id, self.read_timestamp) catch |err| switch (err) { error.OutOfMemory => error.OutOfMemory, else => null, }; @@ -171,7 +174,7 @@ pub const Store = struct { pub fn writeDiscovery(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { var key: [32]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(bytes, &key, .{}); + base.Sha256.hash(bytes, &key, .{}); try self.writeEntry(self.artifactIdentity(if (origin == .local) .local_discovery else .package_discovery, module, key), bytes); } @@ -194,7 +197,7 @@ pub const Store = struct { } pub fn has(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, key: [32]u8) Allocator.Error!bool { - var artifact = (self.native.acquire(self.packIdentity(origin, module, key), self.native.now()) catch |err| switch (err) { + var artifact = (self.native.acquire(self.packIdentity(origin, module, key), self.read_timestamp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return false, }) orelse return false; @@ -345,7 +348,7 @@ pub const Store = struct { } fn retainedIdentity(self: *const Store, key: [64]u8) [32]u8 { - var hash = std.crypto.hash.sha2.Sha256.init(.{}); + var hash = base.Sha256.init(.{}); hash.update(self.native.root); hash.update(&key); return hash.finalResult(); @@ -363,8 +366,8 @@ pub const Store = struct { } }; - fn acquireRetained(self: *const Store, owner: *base.retained_cache.Owner, key: [64]u8, kind: base.retained_cache.Kind) (Allocator.Error || error{ Unavailable, Corrupt })!*const base.retained_cache.Record { - var acquired = (self.native.acquireKey(key, self.native.now()) catch |err| switch (err) { + fn acquireRetained(self: *const Store, owner: *base.retained_cache.Owner, batch: *compile.artifacts.RetentionBatch, key: [64]u8, kind: base.retained_cache.Kind) (Allocator.Error || error{ Unavailable, Corrupt })!*const base.retained_cache.Record { + var acquired = (batch.acquireKey(key, self.read_timestamp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.Unavailable, }) orelse return error.Unavailable; @@ -389,7 +392,7 @@ pub const Store = struct { fn publishIndexedHeld(self: *const Store, id: compile.artifacts.Identity, bytes: []const u8, stats: *PublicationStats, pins: ?*std.ArrayList(compile.artifacts.Artifact)) (Allocator.Error || error{PackWriteFailed})!bool { if (pins) |held| try held.ensureUnusedCapacity(self.allocator, 1); stats.records_read += 1; - if (self.native.acquire(id, self.native.now()) catch |err| switch (err) { + if (self.native.acquire(id, self.read_timestamp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.PackWriteFailed, }) |value| { @@ -415,7 +418,7 @@ pub const Store = struct { else => return error.PackWriteFailed, }; if (pins) |held| { - var acquired = (self.native.acquire(id, self.native.now()) catch |err| switch (err) { + var acquired = (self.native.acquire(id, self.read_timestamp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.PackWriteFailed, }) orelse return error.PackWriteFailed; @@ -444,7 +447,7 @@ pub const Store = struct { }; defer self.allocator.free(keys); for (keys) |key| { - var artifact = (self.native.acquireKey(key, self.native.now()) catch |err| switch (err) { + var artifact = (self.native.acquireKey(key, self.read_timestamp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => continue, }) orelse continue; @@ -686,8 +689,8 @@ test "native procedure domain shares packs but isolates final outputs and linked defer right.deinit(); try left.write(.pkg, identity, key, "linked-only package"); try std.testing.expect(!try right.has(.pkg, identity, key)); - const left_key = left.identity(.optimized_pack, identity, identity).indexedKey(); - const right_key = right.identity(.optimized_pack, identity, identity).indexedKey(); + const left_key = left.artifactIdentity(.optimized_pack, identity, identity).indexedKey(); + const right_key = right.artifactIdentity(.optimized_pack, identity, identity).indexedKey(); try std.testing.expect(!std.mem.eql(u8, &left_key, &right_key)); } } @@ -955,18 +958,23 @@ pub const LoadedPacks = struct { }; const IndexedResult = enum { hit, miss, unavailable }; - /// One checked module/context provider, shared by its procedure requests. - const InterfaceFamily = struct { - records: std.AutoArrayHashMapUnmanaged([32]u8, []const u8) = .empty, + /// A request owns its payload and its synchronization. Independent requests + /// can read and validate their files concurrently, including in one module. + const InterfaceRecord = struct { + mutex: std.Io.Mutex = .init, + payload: ?[]const u8 = null, loaded: bool = false, dirty: bool = false, }; + const InterfaceFamily = struct { + records: std.AutoArrayHashMapUnmanaged([32]u8, *InterfaceRecord) = .empty, + }; const Provider = struct { store: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8 }; const IndexRoot = struct { store: *const Store, origin: compile.BuildEnv.PackOrigin }; interface_mutex: std.Io.Mutex = .init, - /// Worker reads update this under interface_mutex; reported after joining. + /// Worker reads update these atomically; reported after joining. interface_bytes_loaded: u64 = 0, - interface_invalid_families: u64 = 0, + interface_invalid_records: u64 = 0, interface_families: std.AutoHashMapUnmanaged([32]u8, *InterfaceFamily) = .empty, interface_store: ?*const Store = null, allocator: Allocator, @@ -981,6 +989,7 @@ pub const LoadedPacks = struct { negative: std.AutoHashMapUnmanaged(MissKey, void) = .empty, record_owner: ?*base.retained_cache.Owner = null, + retention_batches: std.StringHashMapUnmanaged(*compile.artifacts.RetentionBatch) = .empty, record_domain: [32]u8 = @splat(0), payloads: std.AutoHashMapUnmanaged([32]u8, *const base.retained_cache.Record) = .empty, retained_offers: std.AutoHashMapUnmanaged([32]u8, *const base.retained_cache.Record) = .empty, @@ -1045,6 +1054,26 @@ pub const LoadedPacks = struct { return owner; } + fn retentionBatch(self: *LoadedPacks, store: *const Store) Allocator.Error!*compile.artifacts.RetentionBatch { + if (self.retention_batches.get(store.native.root)) |batch| return batch; + const owner = try self.recordOwner(store.roc_ctx.std_io); + const batch = try store.allocator.create(compile.artifacts.RetentionBatch); + errdefer store.allocator.destroy(batch); + batch.* = try compile.artifacts.RetentionBatch.init(&store.native); + errdefer batch.deinit(); + try self.retention_batches.ensureUnusedCapacity(self.allocator, 1); + try owner.adopt(.{ .context = batch, .deinit = releaseRetentionBatch }); + self.retention_batches.putAssumeCapacity(batch.store.root, batch); + return batch; + } + + fn releaseRetentionBatch(context: *anyopaque) void { + const batch: *compile.artifacts.RetentionBatch = @ptrCast(@alignCast(context)); + const allocator = batch.store.allocator; + batch.deinit(); + allocator.destroy(batch); + } + fn reusesOffer(self: *const LoadedPacks, store: *const Store, spec: PackFile.SpecEntry, provider: compile.BuildEnv.PackPlacement, component: [32]u8, ordinal: u32) bool { const offer = self.retained_offers.get(spec.key) orelse return false; const old = self.excerpts.get(spec.key) orelse return false; @@ -1110,7 +1139,11 @@ pub const LoadedPacks = struct { pub fn refreshIndex(self: *LoadedPacks) void { self.negative.clearRetainingCapacity(); var families = self.interface_families.valueIterator(); - while (families.next()) |family| family.*.loaded = false; + while (families.next()) |family| { + for (family.*.records.values()) |record| if (record.payload == null) { + record.loaded = false; + }; + } } /// Register freshly published offers in the checked program's provider order. @@ -1140,7 +1173,7 @@ pub const LoadedPacks = struct { const allocator = self.allocator; self.index_stats.metadata_opens += 1; const owner = try self.recordOwner(store.roc_ctx.std_io); - const record = store.acquireRetained(owner, store.indexedIdentity(.local, @splat(0), hash, .metadata).indexedKey(), .native_component_v1) catch |err| switch (err) { + const record = store.acquireRetained(owner, try self.retentionBatch(store), store.indexedIdentity(.local, @splat(0), hash, .metadata).indexedKey(), .native_component_v1) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Corrupt => { self.index_stats.corrupt += 1; @@ -1361,7 +1394,7 @@ pub const LoadedPacks = struct { if (keys.len == 0) self.index_stats.bucket_absent += 1; for (keys) |catalog_key| { const owner = try self.recordOwner(root.store.roc_ctx.std_io); - const record = root.store.acquireRetained(owner, catalog_key, .native_offer_v3) catch |err| switch (err) { + const record = root.store.acquireRetained(owner, try self.retentionBatch(root.store), catalog_key, .native_offer_v3) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Corrupt => { self.index_stats.corrupt += 1; @@ -1404,6 +1437,7 @@ pub const LoadedPacks = struct { }; var adopted = false; defer if (!adopted) selection.pack.deinit(); + if (takes == .test_dev_code and !selection.pack.specs[0].test_compatible) continue; const metadata = try self.loadMetadata(store, selection.component) orelse { cacheable = false; continue; @@ -1461,7 +1495,7 @@ pub const LoadedPacks = struct { if (self.payloads.get(hash)) |record| return record; self.index_stats.payload_opens += 1; const owner = try self.recordOwner(store.roc_ctx.std_io); - const record = store.acquireRetained(owner, store.indexedIdentity(.local, @splat(0), hash, .payload).indexedKey(), .native_payload_v1) catch |err| switch (err) { + const record = store.acquireRetained(owner, try self.retentionBatch(store), store.indexedIdentity(.local, @splat(0), hash, .payload).indexedKey(), .native_payload_v1) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.Corrupt => { self.index_stats.corrupt += 1; @@ -1894,10 +1928,13 @@ pub const LoadedPacks = struct { if (self.record_owner) |owner| std.debug.print("pack acquisitions: {any}\n", .{owner.metrics}); std.debug.print("pack index: index_bytes={d} bucket_opens={d} bucket_absent={d} negative_hits={d} metadata_opens={d} metadata_bytes={d} candidates={d} duplicates={d} payload_opens={d} payload_files={d} payload_bytes={d} decoded_artifacts={d} requested_artifacts={d} served_artifacts={d} allocation_failures={d} requested={d} served={d} misses={d} source_ineligible={d} linked_ineligible={d} corrupt={d} missing={d}\n", .{ stats.index_bytes, stats.bucket_opens, stats.bucket_absent, stats.negative_hits, stats.metadata_opens, stats.metadata_bytes, stats.candidates, stats.duplicates, stats.payload_opens, stats.payload_files, stats.payload_bytes, stats.decoded_artifacts, stats.requested_artifacts, stats.served_artifacts, stats.allocation_failures, stats.requested, stats.served, stats.misses, stats.source_ineligible, stats.linked_ineligible, stats.corrupt, stats.missing }); } - if (base.dev_diagnostics.isSet(.pack_stats)) std.debug.print("pack interfaces: loaded_bytes={d} invalid_families={d}\n", .{ self.interface_bytes_loaded, self.interface_invalid_families }); + if (base.dev_diagnostics.isSet(.pack_stats)) std.debug.print("pack interfaces: loaded_bytes={d} invalid_records={d}\n", .{ self.interface_bytes_loaded, self.interface_invalid_records }); var interface_families = self.interface_families.valueIterator(); while (interface_families.next()) |family| { - for (family.*.records.values()) |bytes| self.allocator.free(bytes); + for (family.*.records.values()) |record| { + if (record.payload) |bytes| self.allocator.free(bytes); + self.allocator.destroy(record); + } family.*.records.deinit(self.allocator); self.allocator.destroy(family.*); } @@ -1914,6 +1951,7 @@ pub const LoadedPacks = struct { self.index_dirs.deinit(self.allocator); self.payloads.deinit(self.allocator); self.retained_offers.deinit(self.allocator); + self.retention_batches.deinit(self.allocator); defer if (self.record_owner) |owner| owner.release(); var settlements = self.discovery.valueIterator(); while (settlements.next()) |list| list.deinit(self.allocator); @@ -1950,9 +1988,9 @@ pub const LoadedPacks = struct { return if (self.pending) |pending| pending.store else self.interface_store; } - /// Caller holds interface_mutex. One read per family and snapshot, including - /// absence. Publications update the resident family directly. - fn interfaceFamily(self: *LoadedPacks, store: *const Store, key: [32]u8) Allocator.Error!*InterfaceFamily { + /// Caller holds interface_mutex. Families own requested records and misses; + /// finding one request never reads or decodes its siblings or their history. + fn interfaceFamily(self: *LoadedPacks, key: [32]u8) Allocator.Error!*InterfaceFamily { const entry = try self.interface_families.getOrPut(self.allocator, key); if (!entry.found_existing) { errdefer _ = self.interface_families.remove(key); @@ -1960,86 +1998,80 @@ pub const LoadedPacks = struct { family.* = .{}; entry.value_ptr.* = family; } - const family = entry.value_ptr.*; - if (family.loaded) return family; - const bytes = (try store.read(self.allocator, store.indexedIdentity(.local, key, @splat(0), .interface))) orelse { - family.loaded = true; - return family; - }; - defer self.allocator.free(bytes); - self.interface_bytes_loaded +|= bytes.len; - self.readInterfaceFamily(family, key, bytes) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - error.InvalidSummary => { - self.interface_invalid_families += 1; - family.loaded = true; - return family; - }, - }; - family.loaded = true; - return family; + return entry.value_ptr.*; + } + + /// The map lock protects allocation and lookup only. Record addresses stay + /// stable until all specialization workers have joined and this owner dies. + fn interfaceRecord(self: *LoadedPacks, io: std.Io, family_key: [32]u8, request: [32]u8) Allocator.Error!*InterfaceRecord { + self.interface_mutex.lockUncancelable(io); + defer self.interface_mutex.unlock(io); + const family = try self.interfaceFamily(family_key); + const entry = try family.records.getOrPut(self.allocator, request); + if (!entry.found_existing) { + errdefer _ = family.records.swapRemove(request); + const record = try self.allocator.create(InterfaceRecord); + record.* = .{}; + entry.value_ptr.* = record; + } + return entry.value_ptr.*; } const InterfaceDecodeError = Allocator.Error || error{InvalidSummary}; + const interface_header_size = 104; - fn readInterfaceFamily(self: *LoadedPacks, family: *InterfaceFamily, key: [32]u8, bytes: []const u8) InterfaceDecodeError!void { - if (bytes.len < 76 or !std.mem.eql(u8, bytes[0..8], "RIFACE02") or !std.mem.eql(u8, bytes[8..40], &key)) return error.InvalidSummary; - if (!std.mem.eql(u8, &Index.digest(bytes[72..]), bytes[40..72])) return error.InvalidSummary; - const count = std.mem.readInt(u32, bytes[72..76], .little); - var offset: usize = 76; - // Validate the whole container before installing any member. - for (0..count) |_| { - if (bytes.len - offset < 36) return error.InvalidSummary; - const len = std.mem.readInt(u32, bytes[offset + 32 ..][0..4], .little); - offset += 36; - if (len > bytes.len - offset) return error.InvalidSummary; - offset += len; - } - if (offset != bytes.len) return error.InvalidSummary; - offset = 76; - for (0..count) |_| { - const request = bytes[offset..][0..32].*; - const len = std.mem.readInt(u32, bytes[offset + 32 ..][0..4], .little); - offset += 36; - if (!family.records.contains(request)) { - const owned = try self.allocator.dupe(u8, bytes[offset..][0..len]); - errdefer self.allocator.free(owned); - try family.records.put(self.allocator, request, owned); - } - offset += len; - } + fn readInterfaceRecord(family: [32]u8, request: [32]u8, bytes: []const u8) InterfaceDecodeError![]const u8 { + if (bytes.len < interface_header_size or !std.mem.eql(u8, bytes[0..8], "RIFACE03") or + !std.mem.eql(u8, bytes[8..40], &family) or !std.mem.eql(u8, bytes[40..72], &request)) return error.InvalidSummary; + const payload = bytes[interface_header_size..]; + if (!std.mem.eql(u8, bytes[72..104], &Index.digest(payload))) return error.InvalidSummary; + return payload; } fn loadInterface(context: *anyopaque, allocator: Allocator, family_key: [32]u8, request: [32]u8) Allocator.Error!?[]u8 { const self: *LoadedPacks = @ptrCast(@alignCast(context)); if (self.defer_code_lookup) return null; const store = self.interfaceStore() orelse return null; - self.interface_mutex.lockUncancelable(store.roc_ctx.std_io); - defer self.interface_mutex.unlock(store.roc_ctx.std_io); - const family = try self.interfaceFamily(store, family_key); - const bytes = family.records.get(request) orelse return null; - return try allocator.dupe(u8, bytes); + const record = try self.interfaceRecord(store.roc_ctx.std_io, family_key, request); + record.mutex.lockUncancelable(store.roc_ctx.std_io); + defer record.mutex.unlock(store.roc_ctx.std_io); + if (record.loaded) return if (record.payload) |bytes| try allocator.dupe(u8, bytes) else null; + const bytes = (try store.read(self.allocator, store.indexedIdentity(.local, family_key, request, .interface))) orelse { + record.loaded = true; + return null; + }; + defer self.allocator.free(bytes); + _ = @atomicRmw(u64, &self.interface_bytes_loaded, .Add, bytes.len, .monotonic); + const payload = readInterfaceRecord(family_key, request, bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidSummary => { + _ = @atomicRmw(u64, &self.interface_invalid_records, .Add, 1, .monotonic); + record.loaded = true; + return null; + }, + }; + record.payload = try self.allocator.dupe(u8, payload); + record.loaded = true; + return try allocator.dupe(u8, record.payload.?); } fn recordInterface(context: *anyopaque, family_key: [32]u8, request: [32]u8, bytes: []const u8) Allocator.Error!void { const self: *LoadedPacks = @ptrCast(@alignCast(context)); if (self.defer_code_lookup) return; const store = self.interfaceStore() orelse return; - self.interface_mutex.lockUncancelable(store.roc_ctx.std_io); - defer self.interface_mutex.unlock(store.roc_ctx.std_io); - const family = try self.interfaceFamily(store, family_key); - if (family.records.get(request)) |existing| if (std.mem.eql(u8, existing, bytes)) return; + const record = try self.interfaceRecord(store.roc_ctx.std_io, family_key, request); + record.mutex.lockUncancelable(store.roc_ctx.std_io); + defer record.mutex.unlock(store.roc_ctx.std_io); + if (record.payload) |existing| if (std.mem.eql(u8, existing, bytes)) return; const owned = try self.allocator.dupe(u8, bytes); - errdefer self.allocator.free(owned); - const entry = try family.records.getOrPut(self.allocator, request); - if (entry.found_existing) self.allocator.free(entry.value_ptr.*); - entry.value_ptr.* = owned; - family.dirty = true; + if (record.payload) |old| self.allocator.free(old); + record.payload = owned; + record.loaded = true; + record.dirty = true; } - /// Publish once per changed module/context provider, within the reported cache - /// publication phase. Concurrent writers can lose optional variants, never - /// mix partial records: every replacement is complete and atomic. + /// Each request is an independently published immutable summary. Concurrent + /// producers of unrelated requests cannot overwrite one another's records. pub fn publishInterfaces(self: *LoadedPacks) Allocator.Error!void { const store = self.interfaceStore() orelse return; self.interface_mutex.lockUncancelable(store.roc_ctx.std_io); @@ -2047,27 +2079,24 @@ pub const LoadedPacks = struct { var families = self.interface_families.iterator(); while (families.next()) |entry| { const family = entry.value_ptr.*; - if (!family.dirty) continue; - var bytes = std.ArrayList(u8).empty; - defer bytes.deinit(self.allocator); - try bytes.appendSlice(self.allocator, "RIFACE02"); - try bytes.appendSlice(self.allocator, entry.key_ptr); - try bytes.appendNTimes(self.allocator, 0, 32); - var number: [4]u8 = undefined; - std.mem.writeInt(u32, &number, @intCast(family.records.count()), .little); - try bytes.appendSlice(self.allocator, &number); - for (family.records.keys(), family.records.values()) |request, payload| { + for (family.records.keys(), family.records.values()) |request, record| { + record.mutex.lockUncancelable(store.roc_ctx.std_io); + defer record.mutex.unlock(store.roc_ctx.std_io); + if (!record.dirty) continue; + const payload = record.payload.?; + var bytes = std.ArrayList(u8).empty; + defer bytes.deinit(self.allocator); + try bytes.appendSlice(self.allocator, "RIFACE03"); + try bytes.appendSlice(self.allocator, entry.key_ptr); try bytes.appendSlice(self.allocator, &request); - std.mem.writeInt(u32, &number, @intCast(payload.len), .little); - try bytes.appendSlice(self.allocator, &number); + try bytes.appendSlice(self.allocator, &Index.digest(payload)); try bytes.appendSlice(self.allocator, payload); + _ = store.publishIndexed(store.indexedIdentity(.local, entry.key_ptr.*, request, .interface), bytes.items) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.PackWriteFailed => continue, + }; + record.dirty = false; } - bytes.items[40..72].* = Index.digest(bytes.items[72..]); - _ = store.publishIndexed(store.indexedIdentity(.local, entry.key_ptr.*, @splat(0), .interface), bytes.items) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - error.PackWriteFailed => continue, - }; - family.dirty = false; } } @@ -2298,11 +2327,15 @@ test "native test indexed admission pins verified payloads and rejects observati try std.testing.expect(lookup.lookup(@splat(12), .dev_code, &.{}) != null); try std.testing.expect(lookup.lookup(key, .test_dev_code, &.{}) != null); try std.testing.expectEqual(@as(u64, 1), packs.index_stats.payload_files); - // Eviction cannot revoke a promise already made to specialization. + // Even external deletion cannot revoke already acquired immutable bytes. const payload_bytes = try Index.payload(a, set.excerpts[0]); defer a.free(payload_bytes); - const payload_id = store.indexedIdentity(.local, identity, Index.digest(payload_bytes), .payload); - try store.native.invalidate(payload_id); + const payload_id = store.indexedIdentity(.local, @splat(0), Index.digest(payload_bytes), .payload); + { + var artifact = (try store.native.acquire(payload_id, store.read_timestamp)).?; + defer artifact.deinit(); + try std.Io.Dir.cwd().deleteFile(io, artifact.path); + } const source = packs.spliceSource(); try std.testing.expect(source.find(source.context, key) != null); try std.testing.expect(lookup.lookup(key, .test_dev_code, &.{}) != null); @@ -2931,9 +2964,9 @@ test "persistent interface families invalidate misses, share records, and reject try LoadedPacks.recordInterface(&producer, family, second, "two"); try producer.publishInterfaces(); try std.testing.expectEqual(@as(usize, 1), producer.interface_families.count()); - try std.testing.expect(!producer.interface_families.get(family).?.dirty); + try std.testing.expect(!producer.interface_families.get(family).?.records.get(first).?.dirty); try LoadedPacks.recordInterface(&producer, family, first, "one"); - try std.testing.expect(!producer.interface_families.get(family).?.dirty); + try std.testing.expect(!producer.interface_families.get(family).?.records.get(first).?.dirty); var consumer = LoadedPacks.init(allocator); defer consumer.deinit(); consumer.interface_store = &fixture.store; @@ -2952,13 +2985,15 @@ test "persistent interface families invalidate misses, share records, and reject const three = (try LoadedPacks.loadInterface(&consumer, allocator, family, third)).?; defer allocator.free(three); try std.testing.expectEqualStrings("three", three); - try fixture.store.native.write(fixture.store.indexedIdentity(.local, family, @splat(0), .interface), "RIFACE02truncated", fixture.store.native.now()); + try fixture.store.native.write(fixture.store.indexedIdentity(.local, family, first, .interface), "RIFACE03truncated", fixture.store.native.now()); var corrupt = LoadedPacks.init(allocator); defer corrupt.deinit(); corrupt.interface_store = &fixture.store; try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&corrupt, allocator, family, first)); - try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&corrupt, allocator, family, second)); - try std.testing.expectEqual(@as(u64, 1), corrupt.interface_invalid_families); + const intact = (try LoadedPacks.loadInterface(&corrupt, allocator, family, second)).?; + defer allocator.free(intact); + try std.testing.expectEqualStrings("two", intact); + try std.testing.expectEqual(@as(u64, 1), corrupt.interface_invalid_records); try LoadedPacks.recordInterface(&corrupt, family, first, "repaired"); try corrupt.publishInterfaces(); var repaired = LoadedPacks.init(allocator); @@ -3319,3 +3354,151 @@ test "native publication preserves symbolic bindings across historical definitio try std.testing.expectEqual(@as(u64, 1), stats.metadata_encoded); try std.testing.expectEqual(@as(u64, 2), stats.records_written); } + +test "pack readers record retention once per invocation without overwriting newer readers" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var store = try Store.init(allocator, .{ .cache_dir = root, .roc_ctx = CoreCtx.default(allocator, allocator, io) }, .x64glibc, "dev"); + defer store.deinit(); + const id = store.packIdentity(.local, @splat(1), @splat(2)); + try store.native.write(id, "retained", 1); + const Inspect = struct { + fn read(s: *Store, identity: compile.artifacts.Identity) !struct { sequence: u64, used: i64 } { + var counters = compile.artifacts.catalog.Counters{}; + var shard = try compile.artifacts.Shard.openRequired(&s.native, compile.artifacts.Store.shardFor(identity.indexedKey()), false, &counters); + defer shard.close(); + var tx = shard.index.transaction(); + const record = (try tx.find(.artifacts, identity.indexedKey())).?; + return .{ .sequence = shard.index.header.sequence, .used = (try compile.artifacts.currentMetadata(&tx, identity.indexedKey(), record.value)).used }; + } + }; + store.read_timestamp = 20; + { + const bytes = (try store.read(allocator, id)).?; + defer allocator.free(bytes); + try std.testing.expectEqualStrings("retained", bytes); + } + const first = try Inspect.read(&store, id); + try std.testing.expectEqual(20, first.used); + { + const bytes = (try store.read(allocator, id)).?; + defer allocator.free(bytes); + } + const second = try Inspect.read(&store, id); + try std.testing.expectEqual(first.sequence, second.sequence); + store.read_timestamp = 30; + { + const bytes = (try store.read(allocator, id)).?; + defer allocator.free(bytes); + } + const newer = try Inspect.read(&store, id); + try std.testing.expectEqual(30, newer.used); + try std.testing.expect(newer.sequence > second.sequence); + store.read_timestamp = 20; + { + const bytes = (try store.read(allocator, id)).?; + defer allocator.free(bytes); + } + const older = try Inspect.read(&store, id); + try std.testing.expectEqual(newer.sequence, older.sequence); + try std.testing.expectEqual(30, older.used); +} + +test "interface requests read only their record and independent publications preserve siblings" { + var fixture: IndexFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + const allocator = std.testing.allocator; + const family: [32]u8 = @splat(37); + var first = LoadedPacks.init(allocator); + defer first.deinit(); + first.interface_store = &fixture.store; + var second = LoadedPacks.init(allocator); + defer second.deinit(); + second.interface_store = &fixture.store; + const target: [32]u8 = @splat(255); + try std.testing.expectEqual(null, try LoadedPacks.loadInterface(&second, allocator, family, target)); + for (0..64) |i| { + const request: [32]u8 = @splat(@intCast(i)); + const payload: [1024]u8 = @splat(@intCast(i)); + try LoadedPacks.recordInterface(&first, family, request, &payload); + } + try first.publishInterfaces(); + // This writer observed absence before the unrelated records existed. + try LoadedPacks.recordInterface(&second, family, target, "requested"); + try second.publishInterfaces(); + var reader = LoadedPacks.init(allocator); + defer reader.deinit(); + reader.interface_store = &fixture.store; + const bytes = (try LoadedPacks.loadInterface(&reader, allocator, family, target)).?; + defer allocator.free(bytes); + try std.testing.expectEqualStrings("requested", bytes); + try std.testing.expectEqual(LoadedPacks.interface_header_size + bytes.len, reader.interface_bytes_loaded); + try std.testing.expectEqual(1, reader.interface_families.get(family).?.records.count()); + const sibling = (try LoadedPacks.loadInterface(&reader, allocator, family, @splat(32))).?; + defer allocator.free(sibling); + try std.testing.expectEqualSlices(u8, &@as([1024]u8, @splat(32)), sibling); +} + +test "interface requests share same-key reads while independent workers publish and load" { + const a = std.testing.allocator; + var fixture: IndexFixture = undefined; + try fixture.init(); + defer fixture.deinit(); + var producer = LoadedPacks.init(a); + defer producer.deinit(); + producer.interface_store = &fixture.store; + var reader = LoadedPacks.init(a); + defer reader.deinit(); + reader.interface_store = &fixture.store; + const Worker = struct { + packs: *LoadedPacks, + index: u8, + record: bool, + failed: bool = false, + fn run(self: *@This()) void { + self.work() catch { + self.failed = true; + }; + } + fn work(self: *@This()) !void { + const family: [32]u8 = @splat(61); + const shared: [32]u8 = @splat(254); + for (0..32) |i| { + var key: [32]u8 = @splat(self.index); + key[0] = @intCast(i); + if (self.record) { + try LoadedPacks.recordInterface(self.packs, family, key, &key); + try LoadedPacks.recordInterface(self.packs, family, shared, &shared); + } + const bytes = (try LoadedPacks.loadInterface(self.packs, std.testing.allocator, family, key)) orelse return error.TestUnexpectedResult; + defer std.testing.allocator.free(bytes); + try std.testing.expectEqualSlices(u8, &key, bytes); + const common = (try LoadedPacks.loadInterface(self.packs, std.testing.allocator, family, shared)) orelse return error.TestUnexpectedResult; + defer std.testing.allocator.free(common); + try std.testing.expectEqualSlices(u8, &shared, common); + } + } + }; + for ([_]*LoadedPacks{ &producer, &reader }, 0..) |packs, pass| { + var workers: [8]Worker = undefined; + var threads: [8]std.Thread = undefined; + var started: usize = 0; + { + defer for (threads[0..started]) |thread| thread.join(); + for (&workers, &threads, 0..) |*worker, *thread, i| { + worker.* = .{ .packs = packs, .index = @intCast(i + 1), .record = pass == 0 }; + thread.* = try std.Thread.spawn(.{}, Worker.run, .{worker}); + started += 1; + } + } + for (workers) |worker| try std.testing.expect(!worker.failed); + if (pass == 0) try producer.publishInterfaces(); + } + // Every request is read from storage once, even when all lanes ask for it. + try std.testing.expectEqual(@as(u64, (8 * 32 + 1) * (LoadedPacks.interface_header_size + 32)), reader.interface_bytes_loaded); +} diff --git a/src/compile/artifact_store.zig b/src/compile/artifact_store.zig index 5ec3e798d91..5e08b7de23e 100644 --- a/src/compile/artifact_store.zig +++ b/src/compile/artifact_store.zig @@ -3,6 +3,7 @@ //! acquisition; payload production never holds a catalog lock. const std = @import("std"); +const base = @import("base"); pub const catalog = @import("cache_index.zig"); const Io = std.Io; const Dir = Io.Dir; @@ -11,7 +12,7 @@ const put = catalog.put; const get = catalog.get; /// Storage-format namespace shared by all compiler compatibility identities. -pub const format_directory = "artifacts-v1"; +pub const format_directory = "artifacts-v2"; /// Fixed number of independently coordinated catalogs. pub const shard_count = 16; /// One minute in the timestamp units used by retention. @@ -81,8 +82,8 @@ pub const Identity = struct { variant: [32]u8 = @splat(0), pub fn indexedKey(self: Identity) catalog.Key { - var hash = std.crypto.hash.sha2.Sha256.init(.{}); - hash.update("roc-artifact-group-v1"); + var hash = base.Sha256.init(.{}); + hash.update("roc-artifact-group-v2"); hash.update(&self.namespace); hash.update(&self.variant); hash.update(&.{@backingInt(self.family)}); @@ -100,7 +101,7 @@ pub const Error = catalog.Error || error{ Busy, Missing, Changed, InvalidArtifac /// Hash explicit identity components with SHA-256. pub fn hashBytes(bytes: []const u8) [32]u8 { var result: [32]u8 = undefined; - std.crypto.hash.sha2.Sha256.hash(bytes, &result, .{}); + base.Sha256.hash(bytes, &result, .{}); return result; } @@ -117,13 +118,15 @@ pub const Metadata = struct { size: u64, created: i64, used: i64, + /// Lower bound published in the recency tree. Only maintenance advances it. + indexed_used: i64, key: catalog.Key, slot: u64, variant: [32]u8 = @splat(0), pub fn encode(self: Metadata) catalog.Value { var bytes: catalog.Value = @splat(0); - @memcpy(bytes[0..8], "ROCART01"); + @memcpy(bytes[0..8], "ROCART02"); bytes[8] = @backingInt(self.state); bytes[9] = @backingInt(self.family); put(u32, &bytes, 12, self.pack_format); @@ -135,11 +138,12 @@ pub const Metadata = struct { @memcpy(bytes[80..144], &self.key); put(u64, &bytes, 144, self.slot); @memcpy(bytes[152..184], &self.variant); + put(i64, &bytes, 184, self.indexed_used); return bytes; } pub fn decode(bytes: catalog.Value) Error!Metadata { - if (!std.mem.eql(u8, bytes[0..8], "ROCART01")) return error.CorruptCatalog; + if (!std.mem.eql(u8, bytes[0..8], "ROCART02") or get(i64, &bytes, 184) > get(i64, &bytes, 72)) return error.CorruptCatalog; return .{ .state = std.enums.fromInt(State, bytes[8]) orelse return error.CorruptCatalog, .family = std.enums.fromInt(Family, bytes[9]) orelse return error.CorruptCatalog, @@ -149,6 +153,7 @@ pub const Metadata = struct { .size = get(u64, &bytes, 56), .created = get(i64, &bytes, 64), .used = get(i64, &bytes, 72), + .indexed_used = get(i64, &bytes, 184), .key = bytes[80..144].*, .slot = get(u64, &bytes, 144), .variant = bytes[152..184].*, @@ -164,7 +169,7 @@ pub const Metadata = struct { pub fn recency(self: Metadata, slot: u64) catalog.Key { var key: catalog.Key = @splat(0); - std.mem.writeInt(u64, key[0..8], @as(u64, @bitCast(self.used)) ^ (@as(u64, 1) << 63), .big); + std.mem.writeInt(u64, key[0..8], @as(u64, @bitCast(self.indexed_used)) ^ (@as(u64, 1) << 63), .big); std.mem.writeInt(u64, key[8..16], slot, .big); return key; } @@ -197,11 +202,11 @@ pub const Store = struct { } pub fn shardFor(key: catalog.Key) u4 { - return @truncate(key[32]); + return @truncate(key[0]); } pub fn reserve(self: *const Store, identity: Identity, size: u64, timestamp: i64) Error!Staging { - return self.reserveWith(identity, size, timestamp, false); + return self.reserveWith(identity, size, timestamp, true); } fn reserveWith(self: *const Store, identity: Identity, size: u64, timestamp: i64, required: bool) Error!Staging { @@ -223,6 +228,7 @@ pub const Store = struct { .size = size, .created = timestamp, .used = timestamp, + .indexed_used = timestamp, .key = key, .slot = 0, }; @@ -284,15 +290,19 @@ pub const Store = struct { } pub fn acquireKey(self: *const Store, key: catalog.Key, timestamp: i64) Error!?Artifact { + return self.acquireKeyObserved(key, timestamp, null); + } + + fn acquireKeyObserved(self: *const Store, key: catalog.Key, timestamp: i64, deferred: ?*?RetentionBatch.Observation) Error!?Artifact { var counters = catalog.Counters{}; - var shard = Shard.open(self, shardFor(key), false, &counters) catch |err| switch (err) { + var shard = Shard.openRequired(self, shardFor(key), false, &counters) catch |err| switch (err) { error.Missing, error.Busy => return null, else => return err, }; defer shard.close(); var tx = shard.index.transaction(); const found = (try tx.find(.artifacts, key)) orelse return null; - var metadata = try Metadata.decode(found.value); + var metadata = try currentMetadata(&tx, key, found.value); if (metadata.state != .published or metadata.family == .scratch) return null; const path = try self.payloadPath(shard.number, metadata.slot, ".data"); errdefer self.allocator.free(path); @@ -322,14 +332,13 @@ pub const Store = struct { // Acquire storage before releasing metadata protection. An accepted // reader owns this open file even if maintenance unlinks its name. if (metadata.used < timestamp) { - if (!try tx.remove(.recency, metadata.recency(metadata.slot))) return error.CorruptCatalog; - metadata.used = timestamp; - _ = try tx.putValue(.artifacts, key, metadata.encode()); - _ = try tx.putValue(.owners, storageKey(metadata.generation), metadata.encode()); - var value: catalog.Value = @splat(0); - @memcpy(value[0..64], &key); - _ = try tx.putValue(.recency, metadata.recency(metadata.slot), value); - try tx.commit(); + if (deferred) |observation| { + observation.* = .{ .key = key, .slot = metadata.slot, .generation = metadata.generation, .timestamp = timestamp }; + } else { + metadata.used = timestamp; + try tx.replaceValue(metadata.slot, storageKey(metadata.generation), metadata.encode()); + try tx.commit(); + } } const actual_size = file.length(self.io) catch return error.IoFailure; return .{ .store = self, .file = file, .lease = lease, .path = path, .size = actual_size }; @@ -340,11 +349,11 @@ pub const Store = struct { pub fn invalidate(self: *const Store, identity: Identity) Error!void { const key = identity.indexedKey(); var counters = catalog.Counters{}; - var shard = try Shard.open(self, shardFor(key), false, &counters); + var shard = try Shard.openRequired(self, shardFor(key), false, &counters); defer shard.close(); var tx = shard.index.transaction(); const entry = (try tx.find(.artifacts, key)) orelse return; - const metadata = try Metadata.decode(entry.value); + const metadata = try currentMetadata(&tx, key, entry.value); _ = try shard.retire(key, metadata.generation); } @@ -354,37 +363,29 @@ pub const Store = struct { return try artifact.read(allocator); } - /// Enumerating offers is foreground work. The key index supplies stable - /// ordering within a shard; the caller sorts the combined keys as required - /// by the existing deterministic pack-selection contract. + /// Group placement is explicit in the indexed key. Enumerating one group + /// visits only its shard, under one metadata transaction; unrelated groups + /// and historical compiler namespaces require no probes. pub fn groupKeys(self: *const Store, allocator: Allocator, identity: Identity) Error![]catalog.Key { var keys: std.ArrayList(catalog.Key) = .empty; errdefer keys.deinit(allocator); - const group = identity.indexedKey()[0..32].*; - for (0..shard_count) |number| { - var cursor: catalog.Key = @splat(0); - @memcpy(cursor[0..32], &group); - while (true) { - var counters = catalog.Counters{}; - var shard = Shard.open(self, @intCast(number), false, &counters) catch |err| switch (err) { - error.Missing, error.Busy => break, - else => return err, - }; - defer shard.close(); - var tx = shard.index.transaction(); - const entry = (try tx.lowerBound(.artifacts, cursor)) orelse break; - if (!std.mem.eql(u8, entry.key[0..32], &group)) break; - const metadata = try Metadata.decode(entry.value); - if (metadata.state == .published) try keys.append(allocator, entry.key); - cursor = entry.key; - if (!advanceKey(&cursor)) break; - } + const group_key = identity.indexedKey(); + var counters = catalog.Counters{}; + var shard = Shard.openRequired(self, shardFor(group_key), false, &counters) catch |err| switch (err) { + error.Missing => return keys.toOwnedSlice(allocator), + else => return err, + }; + defer shard.close(); + var tx = shard.index.transaction(); + var cursor: catalog.Key = @splat(0); + @memcpy(cursor[0..32], group_key[0..32]); + while (try tx.lowerBound(.artifacts, cursor)) |entry| { + if (!std.mem.eql(u8, entry.key[0..32], group_key[0..32])) break; + const metadata = try Metadata.decode(entry.value); + if (metadata.state == .published) try keys.append(allocator, entry.key); + cursor = entry.key; + if (!advanceKey(&cursor)) break; } - std.mem.sort(catalog.Key, keys.items, {}, struct { - fn less(_: void, a: catalog.Key, b: catalog.Key) bool { - return std.mem.order(u8, &a, &b) == .lt; - } - }.less); return keys.toOwnedSlice(allocator); } @@ -395,6 +396,128 @@ pub const Store = struct { } }; +/// Bounded retention observations for a reader retaining generation leases. +/// Callers flush before releasing their leases. A failed acquisition may close +/// its lease early; exact generation validation discards an obsolete observation. +/// The batch owns its store path and may outlive the Store that created it. +pub const RetentionBatch = struct { + const Observation = struct { key: catalog.Key, slot: u64, generation: u64, timestamp: i64 }; + store: Store, + pending: [shard_count][catalog.max_changes]Observation = undefined, + counts: [shard_count]u8 = @splat(0), + + pub fn init(store: *const Store) Allocator.Error!RetentionBatch { + return .{ .store = .{ .allocator = store.allocator, .io = store.io, .root = try store.allocator.dupe(u8, store.root) } }; + } + + pub fn deinit(self: *RetentionBatch) void { + // Retention publication is optional cache I/O. A failed write never + // turns validated code into a failed compilation; leases remain held + // until this flush attempt has finished. + self.flush() catch {}; + self.store.deinit(); + } + + pub fn acquireKey(self: *RetentionBatch, key: catalog.Key, timestamp: i64) Error!?Artifact { + const number = Store.shardFor(key); + if (self.counts[number] == catalog.max_changes) try self.flushShard(number); + var observation: ?Observation = null; + const artifact = try self.store.acquireKeyObserved(key, timestamp, &observation); + if (observation) |value| { + self.pending[number][self.counts[number]] = value; + self.counts[number] += 1; + } + return artifact; + } + + pub fn flush(self: *RetentionBatch) Error!void { + for (0..shard_count) |number| try self.flushShard(@intCast(number)); + } + + fn flushShard(self: *RetentionBatch, number: u4) Error!void { + const count = self.counts[number]; + if (count == 0) return; + var counters = catalog.Counters{}; + var shard = try Shard.openRequired(&self.store, number, false, &counters); + defer shard.close(); + var tx = shard.index.transaction(); + for (self.pending[number][0..count]) |observation| { + // A rejected payload may have released its lease. Never apply that + // observation to a replacement generation reusing the same slot. + if (observation.slot == 0 or observation.slot >= tx.header.next) continue; + const row = try tx.node(observation.slot); + const expected = storageKey(observation.generation); + if (row.kind != .leaf or !std.mem.eql(u8, &row.key, &expected)) continue; + var metadata = try Metadata.decode(row.value); + if (metadata.slot != observation.slot or metadata.generation != observation.generation or + !std.mem.eql(u8, &metadata.key, &observation.key)) return error.CorruptCatalog; + if (metadata.state != .published or metadata.used >= observation.timestamp) continue; + metadata.used = observation.timestamp; + try tx.replaceValue(metadata.slot, expected, metadata.encode()); + } + if (tx.count != 0) try tx.commit(); + self.counts[number] = 0; + } +}; + +/// The artifact index supplies the immutable generation and physical owner slot. +/// Its copied retention fields are not authoritative. Reads and maintenance use +/// the owner leaf, whose use timestamp a foreground hit updates in one write. +pub fn currentMetadata(tx: *catalog.Transaction, key: catalog.Key, pointer: catalog.Value) Error!Metadata { + const address = try Metadata.decode(pointer); + if (!std.mem.eql(u8, &address.key, &key)) return error.CorruptCatalog; + const owner = try tx.node(address.slot); + const expected = storageKey(address.generation); + if (owner.kind != .leaf or !std.mem.eql(u8, &owner.key, &expected)) return error.CorruptCatalog; + const metadata = try Metadata.decode(owner.value); + if (metadata.slot != address.slot or metadata.generation != address.generation or + !std.mem.eql(u8, &metadata.key, &key)) return error.CorruptCatalog; + return metadata; +} + +test "artifact reads update only the authoritative owner and replacement uses repaired recency" { + const a = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realPathFileAlloc(io, ".", a); + defer a.free(path); + var store = try Store.init(a, io, path); + defer store.deinit(); + const identity: Identity = .{ .namespace = @splat(1), .family = .local_pack, .key = @splat(2) }; + const key = identity.indexedKey(); + try store.write(identity, "old", 10); + { + var read = (try store.acquire(identity, 100)).?; + defer read.deinit(); + } + { + var counters = catalog.Counters{}; + var shard = try Shard.openRequired(&store, Store.shardFor(key), false, &counters); + defer shard.close(); + var tx = shard.index.transaction(); + const pointer = (try tx.find(.artifacts, key)).?.value; + try std.testing.expectEqual(10, (try Metadata.decode(pointer)).used); + const current = try currentMetadata(&tx, key, pointer); + try std.testing.expectEqual(100, current.used); + try std.testing.expectEqual(10, current.indexed_used); + try std.testing.expect(!current.expired(day_ns + 50, identity.pack_format)); + try shard.refreshRecency(current); + var refreshed = shard.index.transaction(); + const after = try currentMetadata(&refreshed, key, pointer); + try std.testing.expectEqual(100, after.indexed_used); + var next = after; + next.used = 200; + try refreshed.replaceValue(next.slot, storageKey(next.generation), next.encode()); + try std.testing.expectEqual(@as(usize, 1), refreshed.count); + try refreshed.commit(); + } + try store.write(identity, "new", 300); + const bytes = (try store.read(a, identity, 400)).?; + defer a.free(bytes); + try std.testing.expectEqualStrings("new", bytes); +} + /// POSIX flock belongs to the open file description and survives inheritance. /// Windows byte-range locks belong to the locking process; use sharing denial /// for leases instead, which lasts until the last inherited handle is closed. @@ -431,10 +554,32 @@ pub const Shard = struct { index: catalog.Index, number: u4, + /// Repair one stale recency lower bound. This is one bounded maintenance + /// unit, separate from eviction; a freshly read artifact is never selected + /// using the older position it still occupies in the recency index. + pub fn refreshRecency(self: *Shard, metadata: Metadata) Error!void { + var tx = self.index.transaction(); + var current = metadata; + if (!try tx.remove(.recency, current.recency(current.slot))) return error.CorruptCatalog; + current.indexed_used = current.used; + try tx.replaceValue(current.slot, storageKey(current.generation), current.encode()); + var value: catalog.Value = @splat(0); + @memcpy(value[0..64], ¤t.key); + _ = try tx.putValue(.recency, current.recency(current.slot), value); + try tx.commit(); + } + + /// Opportunistic maintenance never waits for a foreground transaction. pub fn open(store: *const Store, number: u4, create: bool, counters: *catalog.Counters) Error!Shard { return openWith(store, number, create, counters, false); } + /// Foreground readers and publishers wait for short metadata transactions. + /// Contention is not absence, corruption, or a reason to discard a write. + pub fn openRequired(store: *const Store, number: u4, create: bool, counters: *catalog.Counters) Error!Shard { + return openWith(store, number, create, counters, true); + } + fn openWith(store: *const Store, number: u4, create: bool, counters: *catalog.Counters, required: bool) Error!Shard { var root = try store.openRoot(create); defer root.close(store.io); @@ -526,7 +671,7 @@ pub const Staging = struct { pub fn publish(self: *Staging) Error!void { var counters = catalog.Counters{}; - var shard = try Shard.open(self.store, Store.shardFor(self.key), false, &counters); + var shard = try Shard.openRequired(self.store, Store.shardFor(self.key), false, &counters); defer shard.close(); var tx = shard.index.transaction(); const found = (try tx.find(.owners, storageKey(self.generation))) orelse return error.Changed; @@ -538,7 +683,7 @@ pub const Staging = struct { defer self.store.allocator.free(final_path); Dir.cwd().rename(self.path, Dir.cwd(), final_path, self.store.io) catch return error.IoFailure; if (try tx.find(.artifacts, self.key)) |previous| { - var old = try Metadata.decode(previous.value); + var old = try currentMetadata(&tx, self.key, previous.value); if (old.state != .published or tx.header.user[1] < old.size) return error.CorruptCatalog; if (!try tx.remove(.recency, old.recency(old.slot))) return error.CorruptCatalog; tx.header.user[1] -= old.size; @@ -936,3 +1081,205 @@ test "scratch ownership survives the parent closing its inherited lease" { defer shard.close(); try std.testing.expect(try shard.retire(key, generation)); } + +test "foreground artifact contention never becomes a cache miss or lost publication" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var store = try Store.init(allocator, io, root); + defer store.deinit(); + const id: Identity = .{ .namespace = @splat(17), .family = .checked, .key = @splat(23) }; + try store.write(id, "validated bytes", 1); + const workers = 8; + const Shared = struct { + store: *const Store, + identity: Identity, + ready: std.atomic.Value(usize) = .init(0), + start: std.atomic.Value(bool) = .init(false), + failed: std.atomic.Value(bool) = .init(false), + fn run(self: *@This()) void { + _ = self.ready.fetchAdd(1, .release); + while (!self.start.load(.acquire)) std.atomic.spinLoopHint(); + for (0..16) |iteration| { + const bytes = (self.store.read(self.store.allocator, self.identity, @intCast(iteration + 2)) catch { + self.failed.store(true, .release); + return; + }) orelse { + self.failed.store(true, .release); + return; + }; + defer self.store.allocator.free(bytes); + if (!std.mem.eql(u8, bytes, "validated bytes")) self.failed.store(true, .release); + self.store.write(self.identity, "validated bytes", @intCast(iteration + 2)) catch { + self.failed.store(true, .release); + return; + }; + } + } + }; + var shared = Shared{ .store = &store, .identity = id }; + var threads: [workers]std.Thread = undefined; + var spawned: usize = 0; + defer { + shared.start.store(true, .release); + for (threads[0..spawned]) |thread| thread.join(); + } + for (&threads) |*thread| { + thread.* = try std.Thread.spawn(.{}, Shared.run, .{&shared}); + spawned += 1; + } + while (shared.ready.load(.acquire) != workers) std.atomic.spinLoopHint(); + shared.start.store(true, .release); + for (threads) |thread| thread.join(); + spawned = 0; + try std.testing.expect(!shared.failed.load(.acquire)); + const bytes = (try store.read(allocator, id, 20)).?; + defer allocator.free(bytes); + try std.testing.expectEqualStrings("validated bytes", bytes); +} + +test "artifact groups route all members to one shard and enumerate only published members" { + const allocator = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const root = try tmp.dir.realPathFileAlloc(io, ".", allocator); + defer allocator.free(root); + var store = try Store.init(allocator, io, root); + defer store.deinit(); + const identity: Identity = .{ .namespace = @splat(7), .family = .local_pack, .group = @splat(11), .key = @splat(0) }; + const shard = Store.shardFor(identity.indexedKey()); + for (0..32) |i| { + var member = identity; + member.key[0] = @intCast(31 - i); + try std.testing.expectEqual(shard, Store.shardFor(member.indexedKey())); + var stage = try store.reserve(member, 1, 1); + defer stage.deinit(); + try Dir.cwd().writeFile(io, .{ .sub_path = stage.path, .data = "x" }); + try stage.publish(); + } + var unfinished = identity; + unfinished.key[0] = 32; + var stage = try store.reserve(unfinished, 1, 1); + defer stage.deinit(); + var other = identity; + other.group = @splat(12); + var other_stage = try store.reserve(other, 1, 1); + defer other_stage.deinit(); + try Dir.cwd().writeFile(io, .{ .sub_path = other_stage.path, .data = "y" }); + try other_stage.publish(); + const keys = try store.groupKeys(allocator, identity); + defer allocator.free(keys); + try std.testing.expectEqual(32, keys.len); + for (keys, 0..) |key, i| { + try std.testing.expectEqual(i, key[32]); + var reader = (try store.acquireKey(key, 2)).?; + defer reader.deinit(); + const bytes = try reader.read(allocator); + defer allocator.free(bytes); + try std.testing.expectEqualStrings("x", bytes); + } + const other_keys = try store.groupKeys(allocator, other); + defer allocator.free(other_keys); + try std.testing.expectEqual(1, other_keys.len); +} + +test "retained reads batch one exact owner update per generation before releasing leases" { + const a = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realPathFileAlloc(io, ".", a); + defer a.free(path); + var store = try Store.init(a, io, path); + defer store.deinit(); + var batch = try RetentionBatch.init(&store); + defer batch.deinit(); + var ids: [6]Identity = undefined; + var held: [6]?Artifact = @splat(null); + defer for (&held) |*item| if (item.*) |*artifact| artifact.deinit(); + for (&ids, 0..) |*id, i| { + id.* = .{ .namespace = @splat(7), .family = .local_pack, .group = @splat(5), .key = @splat(@intCast(i)) }; + try store.write(id.*, "payload", 10); + } + for (ids, &held) |id, *item| item.* = (try batch.acquireKey(id.indexedKey(), 20)).?; + const key = ids[0].indexedKey(); + var counters = catalog.Counters{}; + var sequence: u64 = undefined; + { + var shard = try Shard.openRequired(&store, Store.shardFor(key), false, &counters); + defer shard.close(); + sequence = shard.index.header.sequence; + var tx = shard.index.transaction(); + for (ids) |id| { + const k = id.indexedKey(); + const ptr = (try tx.find(.artifacts, k)).?; + const m = try currentMetadata(&tx, k, ptr.value); + try std.testing.expectEqual(10, m.used); + try std.testing.expectError(error.Busy, shard.retire(k, m.generation)); + } + } + try batch.flush(); + { + var shard = try Shard.openRequired(&store, Store.shardFor(key), false, &counters); + defer shard.close(); + try std.testing.expectEqual(sequence + 1, shard.index.header.sequence); + var tx = shard.index.transaction(); + for (ids) |id| { + const k = id.indexedKey(); + const ptr = (try tx.find(.artifacts, k)).?; + try std.testing.expectEqual(20, (try currentMetadata(&tx, k, ptr.value)).used); + } + } + // A newer concurrent reader wins even when an older batch flushes last. + var older = (try batch.acquireKey(key, 25)).?; + defer older.deinit(); + var newer = (try store.acquireKey(key, 30)).?; + defer newer.deinit(); + try batch.flush(); + { + var shard = try Shard.openRequired(&store, Store.shardFor(key), false, &counters); + defer shard.close(); + var tx = shard.index.transaction(); + const ptr = (try tx.find(.artifacts, key)).?; + try std.testing.expectEqual(30, (try currentMetadata(&tx, key, ptr.value)).used); + } +} + +test "retention batches discard observations of a retired generation" { + const a = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realPathFileAlloc(io, ".", a); + defer a.free(path); + var store = try Store.init(a, io, path); + defer store.deinit(); + var batch = try RetentionBatch.init(&store); + defer batch.deinit(); + const id: Identity = .{ .namespace = @splat(8), .family = .local_pack, .key = @splat(3) }; + const key = id.indexedKey(); + try store.write(id, "rejected", 10); + var rejected = (try batch.acquireKey(key, 40)).?; + rejected.deinit(); + var counters = catalog.Counters{}; + { + var shard = try Shard.openRequired(&store, Store.shardFor(key), false, &counters); + defer shard.close(); + var tx = shard.index.transaction(); + const ptr = (try tx.find(.artifacts, key)).?; + try std.testing.expect(try shard.retire(key, (try currentMetadata(&tx, key, ptr.value)).generation)); + } + try store.write(id, "replacement", 20); + try batch.flush(); + { + var shard = try Shard.openRequired(&store, Store.shardFor(key), false, &counters); + defer shard.close(); + var tx = shard.index.transaction(); + const ptr = (try tx.find(.artifacts, key)).?; + try std.testing.expectEqual(20, (try currentMetadata(&tx, key, ptr.value)).used); + } +} diff --git a/src/compile/cache_index.zig b/src/compile/cache_index.zig index 590f0f7a719..a57cb9aaaba 100644 --- a/src/compile/cache_index.zig +++ b/src/compile/cache_index.zig @@ -210,11 +210,21 @@ pub const Transaction = struct { header: Header, changes: [max_changes]struct { id: RecordId, node: Node } = undefined, count: usize = 0, + // The shard lock excludes other writers for this transaction's lifetime. + // Pending writes are consulted first. A bounded memo avoids reading the + // same catalog path separately for each tree lookup/update in a transaction. + read_ids: [64]RecordId = @splat(0), + read_nodes: [64]Node = undefined, pub fn node(self: *Transaction, id: RecordId) Error!Node { if (id == 0 or id >= self.header.next) return error.CorruptCatalog; for (self.changes[0..self.count]) |change| if (change.id == id) return change.node; - return self.index.node(id); + const slot = id % self.read_ids.len; + if (self.read_ids[slot] == id) return self.read_nodes[slot]; + const read = try self.index.node(id); + self.read_nodes[slot] = read; + self.read_ids[slot] = id; + return read; } fn set(self: *Transaction, id: RecordId, value: Node) void { @@ -229,6 +239,15 @@ pub const Transaction = struct { self.count += 1; } + /// Update a leaf whose physical identity an earlier indexed lookup supplied. + /// The stored key is checked before any mutation; catalog slots are untrusted. + pub fn replaceValue(self: *Transaction, id: RecordId, key: Key, value: Value) Error!void { + var current = try self.node(id); + if (current.kind != .leaf or !std.mem.eql(u8, ¤t.key, &key)) return error.CorruptCatalog; + current.value = value; + self.set(id, current); + } + fn allocate(self: *Transaction, value: Node) Error!RecordId { const id = if (self.header.free != 0) blk: { const id = self.header.free; @@ -534,3 +553,33 @@ test "corrupt redo is rejected before writing catalog pages" { try std.testing.expectError(error.CorruptCatalog, index.recover()); try std.testing.expectEqual(writes, counters.writes); } + +test "catalog acquisition retains paths and sees pending writes" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var counters = Counters{}; + var index = try Index.open(tmp.dir, io, &counters); + defer index.close(); + var key: Key = @splat(1); + { + var tx = index.transaction(); + _ = try tx.putValue(.artifacts, key, @splat(1)); + try tx.commit(); + } + var tx = index.transaction(); + const found = (try tx.find(.artifacts, key)).?; + const reads = counters.reads; + for (0..10) |_| try std.testing.expectEqual(found.value, (try tx.find(.artifacts, key)).?.value); + try std.testing.expectEqual(reads, counters.reads); + _ = try tx.putValue(.artifacts, key, @splat(2)); + try std.testing.expectEqual(@as(Value, @splat(2)), (try tx.find(.artifacts, key)).?.value); + try std.testing.expect(try tx.remove(.artifacts, key)); + try std.testing.expectEqual(null, try tx.find(.artifacts, key)); + key[0] = 2; + _ = try tx.putValue(.artifacts, key, @splat(3)); + try std.testing.expectEqual(@as(Value, @splat(3)), (try tx.find(.artifacts, key)).?.value); + try tx.commit(); + var fresh = index.transaction(); + try std.testing.expectEqual(@as(Value, @splat(3)), (try fresh.find(.artifacts, key)).?.value); +} diff --git a/src/compile/cache_maintenance.zig b/src/compile/cache_maintenance.zig index 0e47678f134..5d97ab0b8b7 100644 --- a/src/compile/cache_maintenance.zig +++ b/src/compile/cache_maintenance.zig @@ -286,7 +286,15 @@ fn evict(store: *const artifacts.Store, limits: Limits, now: i64, checkpoint: *C } const key = oldest.value[0..64].*; const entry = (try tx.find(.artifacts, key)) orelse return error.CorruptCatalog; - const metadata = try artifacts.Metadata.decode(entry.value); + const metadata = try artifacts.currentMetadata(&tx, key, entry.value); + if (!std.mem.eql(u8, &oldest.key, &metadata.recency(metadata.slot))) return error.CorruptCatalog; + if (metadata.indexed_used != metadata.used) { + try shard.refreshRecency(metadata); + var refreshed = shard.index.transaction(); + const next = try refreshed.lowerBound(.recency, @splat(0)); + checkpoint.candidates[number] = if (next) |item| item.key else null; + return; + } if (shard.retire(key, metadata.generation)) |removed| { if (removed) stats.deletions += 1; checkpoint.bytes[number] = shardBytes(&shard); @@ -329,6 +337,40 @@ const Fixture = struct { } }; +test "maintenance repairs stale recency before evicting the actual oldest generation" { + var fixture = try Fixture.init(); + defer fixture.deinit(); + const hot = Fixture.identity(1); + const number = artifacts.Store.shardFor(hot.indexedKey()); + var other: u32 = 2; + while (artifacts.Store.shardFor(Fixture.identity(other).indexedKey()) != number) : (other += 1) {} + const cold = Fixture.identity(other); + try fixture.store.write(hot, "hot!", 10); + try fixture.store.write(cold, "cold", 20); + { + var read = (try fixture.store.acquire(hot, 100)).?; + defer read.deinit(); + } + var checkpoint: Checkpoint = .{ .phase = .evict }; + { + var counters = catalog.Counters{}; + var shard = try artifacts.Shard.open(&fixture.store, number, false, &counters); + defer shard.close(); + checkpoint.bytes[number] = shardBytes(&shard); + var tx = shard.index.transaction(); + checkpoint.candidates[number] = (try tx.lowerBound(.recency, @splat(0))).?.key; + } + var stats = Stats{}; + const limits: Limits = .{ .max_size_bytes = 4, .object_pack_format_version = 1 }; + try evict(&fixture.store, limits, 100, &checkpoint, &stats); + try std.testing.expectEqual(@as(usize, 0), stats.deletions); + try evict(&fixture.store, limits, 100, &checkpoint, &stats); + try std.testing.expectEqual(@as(usize, 1), stats.deletions); + try std.testing.expectEqual(null, try fixture.store.acquire(cold, 100)); + var retained = (try fixture.store.acquire(hot, 100)).?; + defer retained.deinit(); +} + test "short saturated invocations checkpoint bounded units and reach the end of a large shard" { var fixture = try Fixture.init(); defer fixture.deinit(); @@ -545,8 +587,9 @@ test "a deletion whose checkpoint fails is safe to repeat" { try fixture.store.write(Fixture.identity(0), "expired", 1); var root = try fixture.store.openRoot(false); defer root.close(std.testing.io); - var checkpoint: Checkpoint = .{ .phase = .age }; - checkpoint.boundaries[0] = 1; + const shard = artifacts.Store.shardFor(Fixture.identity(0).indexedKey()); + var checkpoint: Checkpoint = .{ .phase = .age, .shard = shard }; + checkpoint.boundaries[shard] = 1; try checkpoint.save(root, std.testing.io); try root.createDir(std.testing.io, "maintenance.tmp", .default_dir); const limits: Limits = .{ .max_size_bytes = 100, .object_pack_format_version = 1 }; diff --git a/src/compile/canonicalized_cache_entry.zig b/src/compile/canonicalized_cache_entry.zig index 8c20b5a3182..e33584dee8c 100644 --- a/src/compile/canonicalized_cache_entry.zig +++ b/src/compile/canonicalized_cache_entry.zig @@ -24,6 +24,7 @@ //! package root on a hit exactly as it does on a miss. const std = @import("std"); +const Sha256 = @import("base").Sha256; const parse = @import("parse"); const AST = parse.AST; @@ -73,7 +74,7 @@ pub fn pinValidationMatches(stamp: ?PinValidation, version: ?[]const u8) bool { /// checked artifact's semantic key and its target-independent identity. pub fn pinValidationHash(has_pin: bool, version: ?[]const u8) [32]u8 { if (!has_pin) return @splat(0); - var hash = std.crypto.hash.sha2.Sha256.init(.{}); + var hash = Sha256.init(.{}); hash.update("roc-source-pin-validation-v1"); if (recognizedPinVersion(version)) |text| hash.update(text); return hash.finalResult(); diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index 24b2e48564d..e4625cbd513 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -56,6 +56,7 @@ const compile_build = @import("compile_build.zig"); const module_discovery = @import("module_discovery.zig"); const cache_manager_mod = @import("cache_manager.zig"); const cache_versioning = @import("cache_versioning.zig"); +const discovery_roots = @import("discovery_roots.zig"); const cache_key_mod = @import("cache_key.zig"); const canonicalized_cache_entry = @import("canonicalized_cache_entry.zig"); const package_source = @import("package_source.zig"); @@ -247,6 +248,8 @@ const checked_module_cache_zero_padding: [CompactWriter.SERIALIZATION_ALIGNMENT. /// Counts of compile-time value reuse across module edits in one compilation. pub const CtfeReuseStats = struct { + settled_discovery_roots: u32 = 0, + validated_comptime_roots: u32 = 0, /// Freshly checked modules whose previous cache entry was found. previous_entries: u32 = 0, /// Roots of those modules awaiting evaluation when they were checked. @@ -3316,7 +3319,13 @@ pub const Coordinator = struct { else &.{}; defer if (!explicit_runtime and program_root != null) self.gpa.free(entrypoint_requests); - // Then the program's tests, each owned by the module declaring it. + // Runtime entrypoints and compile-time discovery are distinct demands. + // Tests are executable roots only when an explicit test consumer names + // them; build/check retain their literal-validation obligations below. + var discovery_requests = std.ArrayList(CheckedArtifact.RootRequest).empty; + defer discovery_requests.deinit(self.gpa); + var discovery_sources = std.ArrayList(CheckedArtifact.ModuleId).empty; + defer discovery_sources.deinit(self.gpa); var program_requests = std.ArrayList(CheckedArtifact.RootRequest).empty; defer program_requests.deinit(self.gpa); var program_sources = std.ArrayList(CheckedArtifact.ModuleId).empty; @@ -3329,8 +3338,8 @@ pub const Coordinator = struct { coordinatorInvariant("program test module has no checked module data", .{}); for (artifact.root_requests.requests) |request| { if (request.kind != .test_expect) continue; - try program_requests.append(self.gpa, request); - try program_sources.append(self.gpa, key.digest()); + try discovery_requests.append(self.gpa, request); + try discovery_sources.append(self.gpa, key.digest()); } } } @@ -3354,7 +3363,60 @@ pub const Coordinator = struct { options.object_cache = self.compile_time_object_cache; options.keep_artifacts = self.compile_time_object_cache != null or self.keep_compile_time_artifacts; options.cached_debug_modules = cached_debug_modules.items; - if (self.runtime_lowering) |config| options.discovery_roots = config.compile_time_discovery_roots; + if (self.runtime_lowering) |config| { + const configured = config.compile_time_discovery_roots; + try discovery_requests.appendSlice(self.gpa, configured.requests); + if (configured.source_modules.len == 0) { + try discovery_sources.appendNTimes(self.gpa, root.key.digest(), configured.requests.len); + } else { + try discovery_sources.appendSlice(self.gpa, configured.source_modules); + } + } + // A validation fact does not copy a stored value or its old checked + // type references. It permits deferring that value until actually read. + var validation_cache = discovery_roots.Cache{ .allocator = self.gpa, .manager = self.cache_manager, .namespace = "semantic-initializer-validation-v1" }; + defer validation_cache.deinit(); + var validated_roots = std.AutoHashMapUnmanaged(lir.CheckedPipeline.ComptimeDemandRoot, void).empty; + defer validated_roots.deinit(self.gpa); + for (entries.items) |entry| { + const artifact = entry.mod.checkedArtifact().?; + const placement = checkedPlacement(entry.pkg, entry.mod); + for (artifact.root_requests.compile_time_requests) |request| { + const key = discovery_roots.rootKey(artifact, request); + if (try validation_cache.lookup(placement, key)) { + try validation_cache.retain(placement, key); + try validated_roots.put(self.gpa, .{ .module = artifact.key.digest(), .root = request.compile_time_root.? }, {}); + } + } + } + self.ctfe_reuse_stats.validated_comptime_roots += @intCast(validated_roots.count()); + if (eval.CompileTimeReuse.traceEnabled()) std.debug.print("ctfe-reuse validated initializers: {d} roots\n", .{validated_roots.count()}); + // Explicit tests retain check-like validation of every initializer; + // uncertified roots remain mandatory even when no test executes. + if (explicit_runtime) options.validated_comptime_roots = &validated_roots; + var discovery_cache = discovery_roots.Cache{ .allocator = self.gpa, .manager = self.cache_manager }; + defer discovery_cache.deinit(); + // Runtime tests settle discovery too, but their execution is never + // omitted on the strength of a discovery-only certificate. + for (program_roots.requests, 0..) |request, index| { + const owner = if (program_roots.source_modules.len == 0) root.key.digest() else program_roots.source_modules[index]; + _ = try self.registerTestDiscovery(&discovery_cache, request, owner); + } + var retained: usize = 0; + for (discovery_requests.items, discovery_sources.items) |request, owner| { + if (try self.registerTestDiscovery(&discovery_cache, request, owner)) { + discovery_cache.hits += 1; + continue; + } + discovery_requests.items[retained] = request; + discovery_sources.items[retained] = owner; + retained += 1; + } + discovery_requests.shrinkRetainingCapacity(retained); + discovery_sources.shrinkRetainingCapacity(retained); + self.ctfe_reuse_stats.settled_discovery_roots += discovery_cache.hits; + if (eval.CompileTimeReuse.traceEnabled()) std.debug.print("ctfe-reuse settled test discovery: {d} roots\n", .{discovery_cache.hits}); + options.discovery_roots = .{ .requests = discovery_requests.items, .source_modules = discovery_sources.items }; options.defer_debug_replay = !replay_cached_debug; if (self.completion_arena != null) options.completion_sink = .{ .context = self, .demand = captureDemand, .debug = captureDebug }; options.debug_observer = self.debug_observer; @@ -3390,6 +3452,28 @@ pub const Coordinator = struct { if (literal_values) |entry| try self.storeProgramLiteralValues(entry, literal_records_before); for (entries.items) |entry| try self.commitPreparedModule(entry.pkg, entry.mod); try unfinalized.commit(); + if (!self.hasUserErrors() and self.program_session.?.discovery_silent) { + try discovery_cache.publish(); + for (entries.items) |entry| { + const artifact = entry.mod.checkedArtifact().?; + const placement = checkedPlacement(entry.pkg, entry.mod); + for (artifact.root_requests.compile_time_requests) |request| { + switch (artifact.compile_time_roots.root(request.compile_time_root.?).payload) { + .const_node, .discarded => try validation_cache.retain(placement, discovery_roots.rootKey(artifact, request)), + .pending, .unevaluated, .fn_value, .expect, .runtime => {}, + } + } + } + try validation_cache.publish(); + } + } + + fn registerTestDiscovery(self: *Coordinator, cache: *discovery_roots.Cache, request: CheckedArtifact.RootRequest, owner: CheckedArtifact.ModuleId) Allocator.Error!bool { + if (request.kind != .test_expect) return false; + const location = self.checked_artifact_index.get(owner.bytes) orelse return false; + const module = location.module(); + const artifact = module.checkedArtifact().?; + return cache.register(checkedPlacement(location.pkg, module), discovery_roots.rootKey(artifact, request)); } /// The checked module of this compilation whose key is `key`. @@ -8111,10 +8195,14 @@ const ReuseRun = struct { /// The checked occurrence of every named type in each root's stored /// value and type, by name, in walk order. occurrences: [][]NamedOccurrence, + hoisted: []HoistedIdentity, + const HoistedIdentity = struct { text: []u8, value: [32]u8 }; const NamedOccurrence = struct { ty_module: [32]u8, ty: u32 }; fn deinit(self: *ReuseRun, allocator: Allocator) void { + for (self.hoisted) |entry| allocator.free(entry.text); + allocator.free(self.hoisted); for (self.occurrences) |list| allocator.free(list); allocator.free(self.occurrences); for (self.literals) |literal| allocator.free(literal.text); @@ -8138,6 +8226,11 @@ const ReuseRun = struct { return self.identities[self.index(name)]; } + fn hoistedIdentity(self: *const ReuseRun, text: []const u8) [32]u8 { + for (self.hoisted) |entry| if (std.mem.eql(u8, entry.text, text)) return entry.value; + std.debug.panic("no hoisted root for {s}", .{text}); + } + fn value(self: *const ReuseRun, name: []const u8) ?i64 { return self.values[self.index(name)]; } @@ -8224,10 +8317,24 @@ fn compileForReuseWith(allocator: Allocator, cache_dir: []const u8, app_path: [] for (occurrences.items) |list| allocator.free(list); occurrences.deinit(allocator); } + var hoisted = std.ArrayList(ReuseRun.HoistedIdentity).empty; + errdefer { + for (hoisted.items) |entry| allocator.free(entry.text); + hoisted.deinit(allocator); + } for (app.compile_time_roots.roots) |root| { const def = switch (root.source) { .def => |def| def, - .expr, .statement, .required_binding, .hoisted => continue, + .hoisted => |source| { + const region = env.store.getExprRegion(source.expr); + const text = try allocator.dupe(u8, env.getSourceAll()[region.start.offset..region.end.offset]); + hoisted.append(allocator, .{ .text = text, .value = app.compile_time_root_identities.rows[@backingInt(root.id)].value }) catch |err| { + allocator.free(text); + return err; + }; + continue; + }, + .expr, .statement, .required_binding => continue, }; const pattern = env.store.getPattern(env.store.getDef(def).pattern); if (pattern != .assign) continue; @@ -8267,6 +8374,7 @@ fn compileForReuseWith(allocator: Allocator, cache_dir: []const u8, app_path: [] .values = try values.toOwnedSlice(allocator), .literals = try literals.toOwnedSlice(allocator), .occurrences = try occurrences.toOwnedSlice(allocator), + .hoisted = try hoisted.toOwnedSlice(allocator), }; } @@ -8525,13 +8633,19 @@ test "a transplanted value carries no checked type occurrence of the version tha var first = try compileForReuse(allocator, cache, app); defer first.deinit(allocator); + // All values are unchanged, including the nominal whose checked type + // occurrence belongs to the app rather than the imported declaration. + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "app.roc", .data = occurrence_app ++ "# unchanged values\n" }); + var unchanged = try compileForReuseVerifying(allocator, cache, app, true); + defer unchanged.deinit(allocator); + try std.testing.expectEqual(unchanged.stats.pending_roots, unchanged.stats.transplanted_roots); // A definition ahead of them shifts the app's checked type ids, so an // occurrence the previous version's app types held would name another // type, or none. const shifted = try std.mem.replaceOwned(u8, allocator, occurrence_app, "import Counter\n", "import Counter\n\npad : { a : Str, b : List(U8), d : (I64, Str) }\npad = { a: \"x\", b: [], d: (1, \"y\") }\n"); defer allocator.free(shifted); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "app.roc", .data = shifted }); - for ([_]bool{ std.debug.runtime_safety, false }, 0..) |verify, index| { + for ([_]bool{ true, false }, 0..) |verify, index| { const edited = try std.fmt.allocPrint(allocator, "{s}# edit {d}\n", .{ shifted, index }); defer allocator.free(edited); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "app.roc", .data = edited }); @@ -8559,6 +8673,45 @@ test "a transplanted value carries no checked type occurrence of the version tha } } +test "hoisted constants depend on their typed expressions instead of unrelated owner code" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const source = + \\app [answer] { pf: platform "./platform.roc" } + \\base = 10.I64 + \\compute = |n| { + \\ fixed = [base, 20.I64] + \\ local = 30.I64 + \\ captured = [local, 40.I64] + \\ n + List.len(fixed).to_i64_wrap() + List.len(captured).to_i64_wrap() + \\} + \\answer = compute(1.I64) + \\ + ; + try writeReuseFixture(&tmp, source); + const cache = try tmp.dir.realPathFileAlloc(std.testing.io, "cache", allocator); + defer allocator.free(cache); + const app = try tmp.dir.realPathFileAlloc(std.testing.io, "app.roc", allocator); + defer allocator.free(app); + var first = try compileForReuseVerifying(allocator, cache, app, true); + defer first.deinit(allocator); + const edits = [_]struct { from: []const u8, to: []const u8, fixed_changes: bool, captured_changes: bool }{ + .{ .from = "n + List.len(fixed)", .to = "n + 1.I64 + List.len(fixed)", .fixed_changes = false, .captured_changes = true }, + .{ .from = "base = 10.I64", .to = "base = 11.I64", .fixed_changes = true, .captured_changes = true }, + .{ .from = "local = 30.I64", .to = "local = 31.I64", .fixed_changes = false, .captured_changes = true }, + }; + for (edits) |edit| { + const edited = try std.mem.replaceOwned(u8, allocator, source, edit.from, edit.to); + defer allocator.free(edited); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "app.roc", .data = edited }); + var current = try compileForReuseVerifying(allocator, cache, app, true); + defer current.deinit(allocator); + try std.testing.expectEqual(edit.fixed_changes, !std.mem.eql(u8, &first.hoistedIdentity("[base, 20.I64]"), ¤t.hoistedIdentity("[base, 20.I64]"))); + try std.testing.expectEqual(edit.captured_changes, !std.mem.eql(u8, &first.hoistedIdentity("[local, 40.I64]"), ¤t.hoistedIdentity("[local, 40.I64]"))); + } +} + test "compile-time roots that call a generated method have value identities and are reused" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); @@ -10581,7 +10734,7 @@ test "Coordinator corrupt checked module cache entries compile from source" { // well leaves source as the only admissible input. const inference_cache_dir = try config.getInferenceCacheDir(allocator); defer allocator.free(inference_cache_dir); - const overwritten_inference = try overwriteFilesUnderDir(allocator, inference_cache_dir, "not an inference cache entry"); + const overwritten_inference = try overwriteFilesUnderDir(allocator, cache_dir, inference_cache_dir, .inference, "not an inference cache entry"); try std.testing.expect(overwritten_inference > 0); const second = try compileAppWithCheckedModuleCache(allocator, cache_dir, "test/str/app_message.roc"); @@ -10611,7 +10764,7 @@ test "Coordinator corrupt checked module cache env relocations compile from sour // well leaves source as the only admissible input. const inference_cache_dir = try config.getInferenceCacheDir(allocator); defer allocator.free(inference_cache_dir); - const overwritten_inference = try overwriteFilesUnderDir(allocator, inference_cache_dir, "not an inference cache entry"); + const overwritten_inference = try overwriteFilesUnderDir(allocator, cache_dir, inference_cache_dir, .inference, "not an inference cache entry"); try std.testing.expect(overwritten_inference > 0); const second = try compileAppWithCheckedModuleCache(allocator, cache_dir, "test/str/app_message.roc"); @@ -13092,3 +13245,276 @@ test "app discovery retains tokenizer rejection reports before platform resoluti try std.testing.expect(reports.next() == null); } } + +test "module-independent compile-time values survive an imported test edit" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const source = + \\app [answer] { pf: platform "./platform.roc" } + \\import Value + \\answer = Value.get({}) + \\fixed = 17.I64 + \\wrapped = Value.Token.Token(3.I64) + ; + const dependency = + \\Value := {}.{ + \\ Token := [Token(I64)] + \\ get : {} -> I64 + \\ get = |_| 41.I64 + \\} + ; + try writeReuseFixture(&tmp, source); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "Value.roc", .data = dependency }); + const cache = try tmp.dir.realPathFileAlloc(std.testing.io, "cache", allocator); + defer allocator.free(cache); + const app = try tmp.dir.realPathFileAlloc(std.testing.io, "app.roc", allocator); + defer allocator.free(app); + var first = try compileForReuseVerifying(allocator, cache, app, true); + defer first.deinit(allocator); + try std.testing.expectEqual(@as(?i64, 41), first.value("answer")); + + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "Value.roc", .data = dependency ++ "\nexpect True\n" }); + // Verification evaluates the transplanted roots independently and checks + // their values, read dependencies and observations before installation. + var edited = try compileForReuseVerifying(allocator, cache, app, true); + defer edited.deinit(allocator); + try std.testing.expect(edited.stats.transplanted_roots > 0); + try std.testing.expectEqual(@as(?i64, 41), edited.value("answer")); + try std.testing.expectEqual(@as(?i64, 17), edited.value("fixed")); + try std.testing.expect(!std.mem.eql(u8, &first.identity("fixed"), &edited.identity("fixed"))); + + const changed = try std.mem.replaceOwned(u8, allocator, dependency, "41.I64", "42.I64"); + defer allocator.free(changed); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "Value.roc", .data = changed }); + var implementation = try compileForReuseVerifying(allocator, cache, app, true); + defer implementation.deinit(allocator); + // Changing a method also changes the imported semantic type context, + // so this case requires invalidation rather than a particular reuse count. + try std.testing.expectEqual(@as(?i64, 42), implementation.value("answer")); + try std.testing.expectEqual(@as(?i64, 17), implementation.value("fixed")); +} + +test "application builds keep test bodies in discovery rather than runtime demand" { + if (is_freestanding) return error.SkipZigTest; + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeCacheKeyPurityFixture(&tmp, "build_discovery"); + try tmp.dir.writeFile(std.testing.io, .{ + .sub_path = "build_discovery/app/main.roc", + .data = + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\main! = |_args| Ok({}) + \\expect List.len([1.I64, 2, 3]) == 3 + \\expect List.len([4.I64, 5]) == 2 + , + }); + const app_path = try tmp.dir.realPathFileAlloc(std.testing.io, "build_discovery/app/main.roc", allocator); + defer allocator.free(app_path); + var coord = try Coordinator.init(allocator, .single_threaded, 1, roc_target.RocTarget.detectNative(), try sharedBuiltinModules(), build_options.compiler_compatibility_id, null, CoreCtx.os(allocator, allocator, std.testing.io)); + defer coord.deinit(); + coord.enable_hosted_transform = true; + var arena = base.SingleThreadArena.init(allocator); + defer arena.deinit(); + try coord.start(); + try coord.discoverAppFromPath(arena.allocator(), .{ .entry_path = app_path }); + try coord.coordinatorLoop(); + try std.testing.expect(!coord.hasUserErrors()); + const app = coord.appRootCheckedArtifact(); + const test_modules = [_]CheckedArtifact.CheckedModuleArtifactKey{app.key}; + coord.program_test_modules = &test_modules; + coord.runtime_lowering = .{ .target = .{ .inline_expects = .run } }; + try coord.finishCheckedProgram(.executable_artifacts); + try std.testing.expect(!coord.hasUserErrors()); + const prepared = &coord.program_session.?.runtime_prepared.?; + var runtime_count: usize = 0; + for (prepared.program.lifted.rootsView()) |root| { + try std.testing.expect(root.request.kind != .test_expect); + if (root.request.abi != .compile_time) runtime_count += 1; + } + try std.testing.expect(runtime_count > 0); +} + +test "semantic test discovery survives edits and never hides invalid literals or observations" { + if (is_freestanding) return error.SkipZigTest; + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeCacheKeyPurityFixture(&tmp, "discovery_cache"); + try tmp.dir.createDirPath(std.testing.io, "cache"); + const cache = try tmp.dir.realPathFileAlloc(std.testing.io, "cache", allocator); + defer allocator.free(cache); + const app_path = try tmp.dir.realPathFileAlloc(std.testing.io, "discovery_cache/app/main.roc", allocator); + defer allocator.free(app_path); + const source = + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\Word := { text : Str }.{ + \\ from_quote : Str -> Try(Word, [BadQuotedBytes(Str)]) + \\ from_quote = |text| if text == "bad" Err(BadQuotedBytes("rejected")) else Ok(Word.{ text }) + \\ show : Word -> Str + \\ show = |word| word.text + \\} + \\label : a -> a where [a.from_quote : Str -> Try(a, [BadQuotedBytes(Str)])] + \\label = |_| "good" + \\base = List.len([1.I64, 2, 3]) + \\add_base = |value| base + value + \\total = add_base(3) + \\main! = |_args| if total == 6 Ok({}) else crash "wrong total" + \\expect { + \\ word : Word + \\ word = label(Word.{ text: "good" }) + \\ List.contains(List.map([1.U64, 2, 3], add_base), 6) and word.show() == "good" + \\} + ; + const Run = struct { + const Result = struct { hits: u32, errors: bool, observed: bool }; + fn run(gpa: Allocator, directory: []const u8, path: []const u8) !Result { + var capture = StderrCapture{ .allocator = gpa }; + defer capture.bytes.deinit(gpa); + var context = CoreCtx.os(gpa, gpa, std.testing.io); + context.ctx = &capture; + context.vtable.writeStderr = StderrCapture.write; + var manager = CacheManager.init(gpa, .{ .enabled = true, .cache_dir = directory }, context); + var coord = try Coordinator.init(gpa, .single_threaded, 1, roc_target.RocTarget.detectNative(), try sharedBuiltinModules(), build_options.compiler_compatibility_id, &manager, context); + defer coord.deinit(); + coord.enable_hosted_transform = true; + var arena = base.SingleThreadArena.init(gpa); + defer arena.deinit(); + try coord.start(); + try coord.discoverAppFromPath(arena.allocator(), .{ .entry_path = path }); + try coord.coordinatorLoop(); + const app = coord.appRootCheckedArtifact(); + const test_modules = [_]CheckedArtifact.CheckedModuleArtifactKey{app.key}; + coord.program_test_modules = &test_modules; + coord.runtime_lowering = .{ .target = .{ .inline_expects = .run } }; + try coord.finishCheckedProgram(.executable_artifacts); + return .{ .hits = coord.ctfe_reuse_stats.settled_discovery_roots, .errors = coord.hasUserErrors(), .observed = std.mem.find(u8, capture.bytes.items, "discovery observation") != null }; + } + }; + const relative = "discovery_cache/app/main.roc"; + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = relative, .data = source }); + const cold = try Run.run(allocator, cache, app_path); + try std.testing.expect(!cold.errors); + try std.testing.expectEqual(@as(u32, 0), cold.hits); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = relative, .data = source ++ "\n# unrelated edit\n" }); + const edited = try Run.run(allocator, cache, app_path); + try std.testing.expect(!edited.errors); + try std.testing.expectEqual(@as(u32, 1), edited.hits); + + // Changing the conversion invalidates the certificate even though the + // expect itself did not change, and repeated erroneous builds never settle. + const invalid = try std.mem.replaceOwned(u8, allocator, source, "text == \"bad\"", "text == \"good\""); + defer allocator.free(invalid); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = relative, .data = invalid }); + for (0..2) |_| { + const result = try Run.run(allocator, cache, app_path); + try std.testing.expect(result.errors); + try std.testing.expectEqual(@as(u32, 0), result.hits); + } + const observing = try std.mem.replaceOwned(u8, allocator, source, "from_quote = |text| if text == \"bad\" Err(BadQuotedBytes(\"rejected\")) else Ok(Word.{ text })", "from_quote = |text| {\n dbg \"discovery observation\"\n Ok(Word.{ text })\n }"); + defer allocator.free(observing); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = relative, .data = observing }); + for (0..2) |_| { + const result = try Run.run(allocator, cache, app_path); + try std.testing.expect(!result.errors); + try std.testing.expectEqual(@as(u32, 0), result.hits); + try std.testing.expect(result.observed); + } +} + +test "initializer validation certificates defer values without losing errors observations or test positions" { + if (is_freestanding) return error.SkipZigTest; + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try writeCacheKeyPurityFixture(&tmp, "validation_cache"); + try tmp.dir.createDirPath(std.testing.io, "cache"); + const cache = try tmp.dir.realPathFileAlloc(std.testing.io, "cache", allocator); + defer allocator.free(cache); + const path = try tmp.dir.realPathFileAlloc(std.testing.io, "validation_cache/app/main.roc", allocator); + defer allocator.free(path); + const relative = "validation_cache/app/main.roc"; + const source = + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\Foo := { value : I64 }.{ + \\ get = |foo| foo.value + \\} + \\make = |n| if n == 0 crash "initializer validation failed" else Foo.{ value: n + 1 } + \\unused = make(41) + \\main! = |_args| Ok({}) + \\expect 1.I64 + 1 == 2 + \\expect 3.I64 + 1 == 4 + ; + const Run = struct { + const Result = struct { hits: u32, evaluated: u64, errors: bool, observed: bool }; + fn run(gpa: Allocator, directory: []const u8, app_path: []const u8) !Result { + var capture = StderrCapture{ .allocator = gpa }; + defer capture.bytes.deinit(gpa); + var context = CoreCtx.os(gpa, gpa, std.testing.io); + context.ctx = &capture; + context.vtable.writeStderr = StderrCapture.write; + var manager = CacheManager.init(gpa, .{ .enabled = true, .cache_dir = directory }, context); + var coord = try Coordinator.init(gpa, .single_threaded, 1, roc_target.RocTarget.detectNative(), try sharedBuiltinModules(), build_options.compiler_compatibility_id, &manager, context); + defer coord.deinit(); + coord.enable_hosted_transform = true; + var arena = base.SingleThreadArena.init(gpa); + defer arena.deinit(); + try coord.start(); + try coord.discoverAppFromPath(arena.allocator(), .{ .entry_path = app_path }); + try coord.coordinatorLoop(); + try std.testing.expect(!coord.hasUserErrors()); + const app = coord.appRootCheckedArtifact(); + var roots = std.ArrayList(CheckedArtifact.RootRequest).empty; + defer roots.deinit(gpa); + var metadata = std.ArrayList(lir.CheckedPipeline.RootTestPlanMetadata).empty; + defer metadata.deinit(gpa); + for (app.root_requests.requests) |request| { + if (request.kind != .test_expect) continue; + const index: u32 = @intCast(roots.items.len); + try roots.append(gpa, request); + try metadata.append(gpa, .{ .request_index = index, .root_order = index + 7, .result_index = index + 11, .module_index = 0, .root_index = index }); + } + coord.runtime_lowering = .{ .root_module = app, .target = .{ .inline_expects = .run }, .explicit_roots = .{ .requests = roots.items, .test_plan_metadata = metadata.items } }; + try coord.finishCheckedProgram(.executable_artifacts); + if (!coord.hasUserErrors()) if (coord.program_session.?.runtime_prepared) |prepared| { + for (prepared.test_plan_metadata, 0..) |item, index| { + try std.testing.expectEqual(@as(u32, @intCast(index)), item.request_index); + try std.testing.expectEqual(@as(u32, @intCast(index + 7)), item.root_order); + try std.testing.expectEqual(@as(u32, @intCast(index + 11)), item.result_index); + } + }; + return .{ .hits = coord.ctfe_reuse_stats.validated_comptime_roots, .evaluated = coord.ctfe_timing.snapshot().roots_evaluated, .errors = coord.hasUserErrors(), .observed = std.mem.find(u8, capture.bytes.items, "initializer observation") != null }; + } + }; + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = relative, .data = source }); + const cold = try Run.run(allocator, cache, path); + try std.testing.expect(!cold.errors); + try std.testing.expect(cold.evaluated > 0); + for ([_][]const u8{ "\n# first edit\n", "\n# second edit\n" }) |edit| { + const edited = try std.mem.concat(allocator, u8, &.{ source, edit }); + defer allocator.free(edited); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = relative, .data = edited }); + const result = try Run.run(allocator, cache, path); + try std.testing.expect(!result.errors); + try std.testing.expect(result.hits > 0); + try std.testing.expectEqual(@as(u64, 0), result.evaluated); + } + // A new reader needs the nominal value, even though its validation hit. + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = relative, .data = source ++ "\nexpect unused.get() == 42\n" }); + const reader = try Run.run(allocator, cache, path); + try std.testing.expect(!reader.errors); + try std.testing.expect(reader.evaluated > 0); + const invalid = try std.mem.replaceOwned(u8, allocator, source, "make(41)", "make(0)"); + defer allocator.free(invalid); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = relative, .data = invalid }); + for (0..2) |_| try std.testing.expect((try Run.run(allocator, cache, path)).errors); + const observing = try std.mem.replaceOwned(u8, allocator, source, "unused = make(41)", "unused = { dbg \"initializer observation\"\n make(41) }"); + defer allocator.free(observing); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = relative, .data = observing }); + for (0..2) |_| { + const result = try Run.run(allocator, cache, path); + try std.testing.expect(!result.errors); + try std.testing.expect(result.observed); + } +} diff --git a/src/compile/discovery_roots.zig b/src/compile/discovery_roots.zig new file mode 100644 index 00000000000..a744d769cf9 --- /dev/null +++ b/src/compile/discovery_roots.zig @@ -0,0 +1,117 @@ +//! Successful, silent literal discovery of closed test roots, keyed by the +//! checked producer's semantic dependency identity. This is independent of +//! test execution: an application build can settle a test's discovery too. +const std = @import("std"); +const base = @import("base"); +const check = @import("check"); +const roc_target = @import("roc_target"); +const CacheManager = @import("cache_manager.zig").CacheManager; +const Allocator = std.mem.Allocator; +const checked = check.CheckedArtifact; +const Key = [32]u8; +const magic = "ROC_DISCOVERY_ROOTS_1"; +const scope = "semantic-test-root-discovery-v1"; + +pub fn rootKey(artifact: *const checked.CheckedModuleArtifact, request: checked.RootRequest) Key { + const root = request.compile_time_root orelse base.invariant("semantic validation root lacks checked identity", .{}); + var hash = base.Sha256.init(.{}); + hash.update(scope); + hash.update(@tagName(roc_target.RocTarget.detectNative())); + hash.update(&artifact.compile_time_root_identities.test_rows[@backingInt(root)].value); + if (artifact.checking_context_identity.platform_app_relation) |relation| hash.update(&relation.bytes); + return hash.finalResult(); +} + +/// One small manifest per source placement. Its entries are independent +/// semantic facts, so retaining another test's fact never depends on the +/// source version or the order/number of tests in this compilation. +pub const Cache = struct { + allocator: Allocator, + manager: ?*CacheManager, + namespace: []const u8 = scope, + groups: std.AutoHashMapUnmanaged(Key, Group) = .empty, + hits: u32 = 0, + const Group = struct { + previous: std.AutoHashMapUnmanaged(Key, void) = .empty, + current: std.AutoHashMapUnmanaged(Key, void) = .empty, + fn deinit(self: *Group, allocator: Allocator) void { + self.previous.deinit(allocator); + self.current.deinit(allocator); + } + }; + + pub fn deinit(self: *Cache) void { + var groups = self.groups.valueIterator(); + while (groups.next()) |group| group.deinit(self.allocator); + self.groups.deinit(self.allocator); + } + + /// Register a root this compilation will either discover or restore. + /// The caller may omit only discovery, never runtime execution, on a hit. + pub fn lookup(self: *Cache, placement: Key, key: Key) Allocator.Error!bool { + const manager = self.manager orelse return false; + if (!manager.config.enabled) return false; + const entry = try self.groups.getOrPut(self.allocator, placement); + if (!entry.found_existing) { + entry.value_ptr.* = .{}; + if (manager.loadFamilyBytes(.local_discovery, placement, self.namespace)) |bytes| { + defer manager.allocator.free(bytes); + if (decode(bytes, placement)) |keys| { + var offset: usize = 0; + while (offset < keys.len) : (offset += 32) try entry.value_ptr.previous.put(self.allocator, keys[offset..][0..32].*, {}); + } + } + } + return entry.value_ptr.previous.contains(key); + } + + pub fn retain(self: *Cache, placement: Key, key: Key) Allocator.Error!void { + _ = try self.lookup(placement, key); + if (self.groups.getPtr(placement)) |group| try group.current.put(self.allocator, key, {}); + } + + pub fn register(self: *Cache, placement: Key, key: Key) Allocator.Error!bool { + const hit = try self.lookup(placement, key); + try self.retain(placement, key); + return hit; + } + + /// Called only after all registered roots completed literal discovery, + /// checking reported no error, and compile-time evaluation was silent. + pub fn publish(self: *Cache) Allocator.Error!void { + const manager = self.manager orelse return; + var groups = self.groups.iterator(); + while (groups.next()) |entry| { + var keys = std.ArrayList(Key).empty; + defer keys.deinit(self.allocator); + var current = entry.value_ptr.current.keyIterator(); + while (current.next()) |key| try keys.append(self.allocator, key.*); + std.mem.sort(Key, keys.items, {}, struct { + fn less(_: void, a: Key, b: Key) bool { + return std.mem.order(u8, &a, &b) == .lt; + } + }.less); + var bytes = std.ArrayList(u8).empty; + defer bytes.deinit(self.allocator); + try bytes.appendSlice(self.allocator, magic); + try bytes.appendSlice(self.allocator, entry.key_ptr); + try bytes.appendSlice(self.allocator, std.mem.sliceAsBytes(keys.items)); + var hash = base.Sha256.init(.{}); + hash.update(bytes.items); + try bytes.appendSlice(self.allocator, &hash.finalResult()); + manager.storeFamilyBytes(.local_discovery, entry.key_ptr.*, bytes.items, self.namespace, "root validation"); + } + } +}; + +fn decode(bytes: []const u8, placement: Key) ?[]const u8 { + const header = magic.len + 32; + if (bytes.len < header + 32 or (bytes.len - header - 32) % 32 != 0) return null; + if (!std.mem.eql(u8, bytes[0..magic.len], magic)) return null; + if (!std.mem.eql(u8, bytes[magic.len..][0..32], &placement)) return null; + const end = bytes.len - 32; + var hash = base.Sha256.init(.{}); + hash.update(bytes[0..end]); + if (!std.mem.eql(u8, &hash.finalResult(), bytes[end..])) return null; + return bytes[header..end]; +} diff --git a/src/compile/test/demand_driven_ctfe_test.zig b/src/compile/test/demand_driven_ctfe_test.zig index f587ed4a2ec..2284df5b74e 100644 --- a/src/compile/test/demand_driven_ctfe_test.zig +++ b/src/compile/test/demand_driven_ctfe_test.zig @@ -307,6 +307,56 @@ test "a build continues the evaluation's program with every runtime body" { try std.testing.expectEqual(session.runtime_roots.requests.len, runtime.lir_result.root_procs.items.len); } +test "a build never reserves the initializer of an unread compile-time root" { + if (is_freestanding) return error.SkipZigTest; + var checked = try checkApp(std.testing.allocator, + \\app [main!] { pf: platform "./.roc_echo_platform/main.roc" } + \\import pf.Echo + \\import Unused + \\main! = |args| { + \\ Echo.line!(Str.inspect(Unused.count(args))) + \\ Ok({}) + \\} + , &.{.{ .path = "Unused.roc", .source = + \\Unused := [].{ + \\ table : List(U64) + \\ table = { + \\ expect False + \\ List.repeat(9, 4) + \\ } + \\ count : List(Str) -> U64 + \\ count = |args| List.len(args) + \\} + }}, .{}); + defer checked.deinit(); + try checked.expectErrors(false); + const lifted = &checked.coord.program_session.?.runtime_prepared.?.program.lifted; + var checked_roots: usize = 0; + var packages = checked.coord.packages.iterator(); + while (packages.next()) |package| { + for (package.value_ptr.*.modules.items) |*mod| { + if (!std.mem.eql(u8, mod.name, "Unused")) continue; + const artifact = mod.checkedArtifact() orelse return error.TestUnexpectedResult; + for (artifact.root_requests.compile_time_requests) |request| { + const template = request.procedure_template orelse return error.TestUnexpectedResult; + checked_roots += 1; + try std.testing.expect(artifact.compile_time_roots.root(request.compile_time_root.?).payload == .unevaluated); + for (0..lifted.fnCount()) |index| { + const function = lifted.getFn(@fromBackingInt(@as(u32, @intCast(index)))); + const source = function.source orelse continue; + switch (source.fn_def) { + .local_template, .imported_template, .checked_generated => |actual| try std.testing.expect(!std.meta.eql(template, actual)), + else => {}, + } + } + } + } + } + // This checks absence of the signature itself, not only absence of its + // evaluation. Previously an unused initializer still reserved a function. + try std.testing.expectEqual(@as(usize, 1), checked_roots); +} + /// A compile-time root whose helper converts literals both directly and /// through a generic function, and captures a callable constant. fn nestedHelperApp(comptime literal: []const u8) []const u8 { diff --git a/src/eval/compile_time_finalization.zig b/src/eval/compile_time_finalization.zig index 7892ba20fb0..2b2046bf601 100644 --- a/src/eval/compile_time_finalization.zig +++ b/src/eval/compile_time_finalization.zig @@ -109,6 +109,9 @@ pub const Options = struct { /// Runtime outcomes already supplied by the caller. Only their literal /// conversion obligations may specialize; these roots never execute. discovery_roots: lir.CheckedPipeline.RootRequestSet = .{}, + /// Prior successful, silent validation; not a stored value. Every root + /// absent from this set is still demanded for validation in test/check. + validated_comptime_roots: ?*const std.AutoHashMapUnmanaged(lir.CheckedPipeline.ComptimeDemandRoot, void) = null, max_threads: usize = 0, /// Borrow the coordinator's workers after all frontend tasks have finished. /// Standalone finalizers without a coordinator lower on their calling thread. @@ -412,6 +415,9 @@ const DebugEvents = struct { }; allocator: Allocator, events: std.ArrayList(Event) = .empty, + /// Literal-root messages are emitted directly, without a checked root + /// to attach a stored event to. They still prevent silent discovery reuse. + emitted: bool = false, fn deinit(self: *DebugEvents) void { for (self.events.items) |event| self.allocator.free(event.message); @@ -601,6 +607,9 @@ pub const ProgramSession = struct { /// The position of each runtime request in the specialized program's root /// plan. runtime_positions: []u32, + /// All compile-time observations, including replayed observations, were + /// empty. Together with the caller's error check this certifies discovery. + discovery_silent: bool = false, /// Where a runtime consumer reads completed values from. pub const RuntimeStages = union(enum) { @@ -1242,7 +1251,7 @@ pub fn finalizeProgram( finalizationInvariant("program roots omitted their checked owners"); const lss_runtime = if (runtime_target) |target| target.specialization_strategy == .lss else false; - const demand_driven = demandDrivenEvaluation(modules, program_roots, lss_runtime); + const demand_driven = options.validated_comptime_roots != null or demandDrivenEvaluation(modules, program_roots, lss_runtime); const owned_imports = try allocator.dupe(checked.ImportedModuleView, lowering_modules.imports); errdefer allocator.free(owned_imports); const owned_relations = try allocator.dupe(checked.ImportedModuleView, lowering_modules.root.relation_modules); @@ -1292,6 +1301,10 @@ pub fn finalizeProgram( quiet_options.event_callback = null; quiet_options.completion_sink = null; quiet_options.keep_artifacts = false; + // The first specialization settled discovery-only test roots and + // reported their observations. They contribute no runtime entrypoint; + // only the runtime program's own literal roots belong in this pass. + quiet_options.discovery_roots = .{}; quiet_options.unfinalized_reports = if (options.unfinalized_reports != null) quiet_unfinalized.reports() else null; // The evaluation's run verified the program's restored literal // values, and the compile-time pack holds the evaluation's code, so @@ -1313,7 +1326,7 @@ pub fn finalizeProgram( null, false, lss_runtime, - demandDrivenEvaluation(quiet_modules, program_roots, lss_runtime), + options.validated_comptime_roots != null or demandDrivenEvaluation(quiet_modules, program_roots, lss_runtime), ); if (respecialized.compile_time_root_count != 0) finalizationInvariant("a program specialized again after its evaluation completed demanded a compile-time root again"); @@ -1330,7 +1343,7 @@ pub fn finalizeProgram( // Observations replay in sorted order once every root has evaluated: // those stored for the roots this program demands, and this // compilation's own. - const replay_filter: ?*const StoredRootSet = if (demand_driven) &stored_demand else null; + const replay_filter: ?*const StoredRootSet = if (demand_driven and options.validated_comptime_roots == null) &stored_demand else null; try debug_events.appendCached(options.cached_debug_modules, replay_filter); for (modules) |entry| try debug_events.appendStored(entry.module, replay_filter); try debug_events.persist(modules); @@ -1350,6 +1363,7 @@ pub fn finalizeProgram( .runtime_stages = runtime_stages, .runtime_prepared = runtime_prepared, .runtime_positions = runtime_positions, + .discovery_silent = debug_events.events.items.len == 0 and !debug_events.emitted, }; } @@ -1414,6 +1428,7 @@ fn specializeAndEvaluate( var source_modules = std.ArrayList(checked.ModuleId).empty; defer source_modules.deinit(allocator); for (modules) |entry| { + const module_start = requests.items.len; for (entry.module.root_requests.compile_time_requests) |request| { // A reopened artifact's roots that an earlier compilation // evaluated are read from its store like any other module's. @@ -1421,6 +1436,7 @@ fn specializeAndEvaluate( try requests.append(allocator, request); try source_modules.append(allocator, entry.module.key.digest()); } + if (base.dev_diagnostics.isSet(.ctfe_reuse_trace)) std.debug.print("ctfe-reuse pending {s}: {d} roots\n", .{ entry.module.moduleEnvConst().module_name, requests.items.len - module_start }); } const requested_root_count = requests.items.len; @@ -1499,8 +1515,8 @@ fn specializeAndEvaluate( var union_roots = program_roots; union_roots.requests = requests.items; union_roots.source_modules = source_modules.items; - // Test roots are addressed by their position in the request list, so - // a program that runs tests is never demand-driven. + // The Monotype producer translates these positions through its selected + // compile-time prefix before a runtime consumer reads them. const union_test_metadata = try allocator.dupe(@typeInfo(@TypeOf(program_roots.test_plan_metadata)).pointer.child, if (lss_runtime) program_roots.test_plan_metadata else &.{}); defer allocator.free(union_test_metadata); for (union_test_metadata) |*metadata| metadata.request_index += @intCast(requested_root_count); @@ -1521,6 +1537,7 @@ fn specializeAndEvaluate( const solved_policy = if (lss_runtime) lir.CheckedPipeline.SolvedPolicy.fromTarget(runtime_target.?) else check_solved_policy; var host_target = compileTimeTarget(options, solved_policy); host_target.demand_driven_comptime_roots = demand_driven; + host_target.validated_comptime_roots = options.validated_comptime_roots; if (options.literal_values) |values| { host_target.literal_values = values.lookup(); host_target.verify_literal_values = options.verify_literal_values; @@ -1586,7 +1603,7 @@ fn specializeAndEvaluate( const fresh_message = for (debug_events.events.items) |event| { if (event.fresh) break true; } else false; - if (options.object_cache != null and !fresh_message) { + if (options.object_cache != null and !fresh_message and !debug_events.emitted) { result.completed.?.discovery_summaries = try lir.CheckedPipeline.DiscoverySummary.collect(allocator, &prepared.program.lifted); } if (!shares_solved) { @@ -5657,6 +5674,7 @@ fn reportDebugMessage(allocator: Allocator, options: Options, module: checked.Mo } fn emitDebugMessage(allocator: Allocator, options: Options, is_repl: bool, message: []const u8) Allocator.Error!void { + if (options.debug_events) |events| events.emitted = true; if (options.completion_sink) |sink| try sink.debug(sink.context, message); if (options.debug_observer) |observer| try observer.observe(observer.context, message); if (is_repl) { diff --git a/src/eval/compile_time_reuse.zig b/src/eval/compile_time_reuse.zig index 5bcae028faa..282bb221048 100644 --- a/src/eval/compile_time_reuse.zig +++ b/src/eval/compile_time_reuse.zig @@ -30,6 +30,8 @@ pub inline fn traceEnabled() bool { /// borrowed from its relocated artifact body. pub const Previous = struct { key: checked.CheckedModuleArtifactKey, + checked_types: checked.CheckedTypeStore, + own_identity: [32]u8, names: canonical.CanonicalNameStore, roots: checked.CompileTimeRootTable, identities: checked.CompileTimeRootIdentityTable, @@ -45,6 +47,8 @@ pub const Previous = struct { const base_addr = @intFromPtr(serialized); return .{ .key = serialized.key, + .own_identity = serialized.module_identity.stable_hash, + .checked_types = deserializeField(serialized, "checked_types", base_addr, gpa), .names = deserializeField(serialized, "canonical_names", base_addr, gpa), .roots = deserializeField(serialized, "compile_time_roots", base_addr, gpa), .identities = deserializeField(serialized, "compile_time_root_identities", base_addr, gpa), @@ -57,6 +61,7 @@ pub const Previous = struct { } pub fn deinit(self: *Previous) void { + self.checked_types.deinit(self.const_store.allocator); self.names.deinit(); self.const_store.deinit(); } @@ -93,8 +98,10 @@ pub const Transplant = struct { reads: std.ArrayList(checked.CompileTimeRootReadTable.Ref) = .empty, /// How many roots were waiting for evaluation. pending_roots: u32 = 0, + type_occurrences: check.ConstStore.TypeOccurrenceMap, pub fn deinit(self: *Transplant) void { + self.type_occurrences.deinit(); self.matches.deinit(self.allocator); self.reads.deinit(self.allocator); } @@ -131,9 +138,35 @@ fn reusable(previous: *const Previous, root: checked.CompileTimeRoot) bool { }; } +/// A value reusable under semantic dependency identities must hold no module +/// version references, including references in its recorded demand. This is +/// stricter than transplantation across edits of its own module: imported +/// artifacts may have changed too. Version-bearing values retain the ordinary +/// artifact-key contract until their producer supplies explicit rebinding data. +fn reusableAcrossImports(previous: *const Previous, root: checked.CompileTimeRoot) Allocator.Error!bool { + if (previous.reads.readsOf(root.id).len != 0) return false; + switch (root.payload) { + .const_node => |node| { + if (!try previous.const_store.valuePortable(node, null)) return false; + return switch (root.kind) { + .constant, .hoisted_constant => previous.const_store.type_store.typePortable( + &previous.names, + storedRootType(previous, root.id), + null, + null, + ), + .hoisted_validation, .callable_binding, .numeral_conversion, .quote_conversion => true, + .expect, .repl_expr => false, + }; + }, + .discarded => return true, + .pending, .unevaluated, .fn_value, .expect, .runtime => return false, + } +} + /// Find the previous root each pending root of `module` copies. pub fn plan(allocator: Allocator, module: *const checked.CheckedModuleArtifact, previous: *const Previous) Allocator.Error!Transplant { - var transplant = Transplant{ .allocator = allocator }; + var transplant = Transplant{ .allocator = allocator, .type_occurrences = .init(allocator) }; errdefer transplant.deinit(); const identities = module.compile_time_root_identities.rows; const previous_identities = previous.identities.rows; @@ -143,8 +176,41 @@ pub fn plan(allocator: Allocator, module: *const checked.CheckedModuleArtifact, defer by_identity_and_site.deinit(allocator); var by_identity = std.AutoHashMapUnmanaged([32]u8, ComptimeRootId).empty; defer by_identity.deinit(allocator); + var by_semantics = std.AutoHashMapUnmanaged([32]u8, ComptimeRootId).empty; + defer by_semantics.deinit(allocator); + const semantic_rows = module.compile_time_root_identities.test_rows; + const previous_semantics = previous.identities.test_rows; + const has_semantics = semantic_rows.len == identities.len and previous_semantics.len == previous_identities.len; + var occurrences_prepared = false; for (previous.roots.roots) |root| { - if (!reusable(previous, root)) continue; + if (!reusable(previous, root)) { + if (root.payload != .const_node) continue; + if (!occurrences_prepared) { + for (previous.checked_types.view().rootRows()) |old| { + if (module.checked_types.view().rootForKey(old.key)) |current| { + try transplant.type_occurrences.put(old.id, current); + } + } + occurrences_prepared = true; + } + const own: canonical.CheckedModuleDigest = .{ .bytes = previous.key.bytes }; + if (!try previous.const_store.valuePortableWithOccurrences(root.payload.const_node, own, &transplant.type_occurrences)) continue; + switch (root.kind) { + .constant, .hoisted_constant => if (!try previous.const_store.type_store.typePortableWithOccurrences( + &previous.names, + storedRootType(previous, root.id), + own, + &previous.own_identity, + &transplant.type_occurrences, + )) continue, + .numeral_conversion, .quote_conversion, .hoisted_validation => {}, + .callable_binding, .expect, .repl_expr => continue, + } + } + if (has_semantics and try reusableAcrossImports(previous, root)) { + const entry = try by_semantics.getOrPut(allocator, previous_semantics[@backingInt(root.id)].value); + if (!entry.found_existing) entry.value_ptr.* = root.id; + } const identity = previous_identities[@backingInt(root.id)]; const exact = try by_identity_and_site.getOrPut(allocator, identityKey(identity)); if (!exact.found_existing) exact.value_ptr.* = root.id; @@ -165,7 +231,8 @@ pub fn plan(allocator: Allocator, module: *const checked.CheckedModuleArtifact, if (root.payload != .pending or root.kind == .repl_expr) continue; transplant.pending_roots += 1; const identity = identities[@backingInt(root.id)]; - const source = by_identity_and_site.get(identityKey(identity)) orelse by_identity.get(identity.value) orelse continue; + const semantic_source = if (has_semantics) by_semantics.get(semantic_rows[@backingInt(root.id)].value) else null; + const source = by_identity_and_site.get(identityKey(identity)) orelse by_identity.get(identity.value) orelse semantic_source orelse continue; const reads_start = transplant.reads.items.len; for (previous.reads.readsOf(source)) |read| { if (!std.meta.eql(read.module, previous.key.bytes)) { @@ -206,10 +273,12 @@ const Copy = struct { root_type: ?ConstTypeId, }; -fn copyRoot(module: *checked.CheckedModuleArtifact, previous: *const Previous, match: Match) Allocator.Error!Copy { +fn copyRoot(module: *checked.CheckedModuleArtifact, previous: *const Previous, match: Match, occurrences: *const check.ConstStore.TypeOccurrenceMap) Allocator.Error!Copy { const root = module.compile_time_roots.root(match.root); const source = previous.roots.root(match.previous); - return switch (source.payload) { + const values_start = module.const_store.values.items.len; + const types_start = module.const_store.type_store.named_pool.items.len; + const copy: Copy = switch (source.payload) { .const_node => |node| .{ .payload = .{ .const_node = try module.const_store.cloneValueFrom(&previous.const_store, node) }, .root_type = switch (root.kind) { @@ -225,6 +294,19 @@ fn copyRoot(module: *checked.CheckedModuleArtifact, previous: *const Previous, m .discarded => .{ .payload = .discarded, .root_type = null }, .pending, .unevaluated, .fn_value, .expect, .runtime => base.invariant("a compile-time root without reusable stored data was transplanted", .{}), }; + for (module.const_store.values.items[values_start..]) |*value| { + if (value.* == .nominal) rebindOccurrence(&value.nominal.named_type, previous.key, module.key, occurrences); + } + for (module.const_store.type_store.named_pool.items[types_start..]) |*named| { + rebindOccurrence(&named.named_type, previous.key, module.key, occurrences); + } + return copy; +} + +fn rebindOccurrence(named: *check.ConstStore.NamedType, previous: checked.CheckedModuleArtifactKey, current: checked.CheckedModuleArtifactKey, occurrences: *const check.ConstStore.TypeOccurrenceMap) void { + if (!std.meta.eql(named.ty_module.bytes, previous.bytes)) return; + named.ty = occurrences.get(named.ty) orelse base.invariant("stored value admission established every local type occurrence", .{}); + named.ty_module = current.digest(); } /// Install every match's copy before evaluation: payload, stored templates, @@ -232,7 +314,7 @@ fn copyRoot(module: *checked.CheckedModuleArtifact, previous: *const Previous, m /// roots from the store like a reopened entry's evaluated roots. pub fn install(module: *checked.CheckedModuleArtifact, previous: *const Previous, transplant: *const Transplant) Allocator.Error!void { for (transplant.matches.items) |match| { - const copy = try copyRoot(module, previous, match); + const copy = try copyRoot(module, previous, match, &transplant.type_occurrences); fillRoot(module, match.root, copy); } try installEvaluationRows(module, previous, transplant, transplant.matches.items); @@ -299,7 +381,7 @@ pub fn verifyAndInstall(module: *checked.CheckedModuleArtifact, previous: *const for (transplant.matches.items) |match| { const root = module.compile_time_roots.root(match.root); const fresh = root.payload; - const copy = try copyRoot(module, previous, match); + const copy = try copyRoot(module, previous, match, &transplant.type_occurrences); try verifyNamedTypeOccurrences(module, previous, modules, copy); switch (fresh) { .unevaluated => { @@ -323,8 +405,9 @@ pub fn verifyAndInstall(module: *checked.CheckedModuleArtifact, previous: *const } /// Assert that every named type of a copied value graph and stored type -/// re-enters a checked type store of another module at the nominal type it -/// names: a copy carries no checked occurrence of the version that stored it. +/// re-enters a current checked type store at the nominal type it names. A +/// copy may name this edited module only after explicit occurrence rebinding; +/// it never carries an occurrence of the previous version. fn verifyNamedTypeOccurrences(module: *const checked.CheckedModuleArtifact, previous: *const Previous, modules: CheckedModules, copy: Copy) Allocator.Error!void { const allocator = module.canonical_names.allocator; const store = &module.const_store; @@ -401,10 +484,10 @@ fn verifyNamedTypeOccurrence( named_type: check.ConstStore.NamedType, kind: ?check.ConstStore.TypeNamedKind, ) void { - if (std.meta.eql(named_type.ty_module.bytes, previous.key.bytes) or std.meta.eql(named_type.ty_module.bytes, module.key.bytes)) { + if (std.meta.eql(named_type.ty_module.bytes, previous.key.bytes)) { base.invariant("a transplanted named type's checked occurrence was in the version of its module that stored it", .{}); } - const owner = modules.find(modules.context, named_type.ty_module.bytes) orelse + const owner = if (std.meta.eql(named_type.ty_module.bytes, module.key.bytes)) module else modules.find(modules.context, named_type.ty_module.bytes) orelse base.invariant("a transplanted named type's checked occurrence named a module outside the compilation", .{}); const types = owner.checked_types.view(); if (@backingInt(named_type.ty) >= types.payloadCount()) { diff --git a/src/lir/arc.zig b/src/lir/arc.zig index 1e545bd580d..3b8a2d4a743 100644 --- a/src/lir/arc.zig +++ b/src/lir/arc.zig @@ -18197,3 +18197,28 @@ test "ARC ownership iteration skips absent resources and preserves release order try testing.expectEqual(null, empty.next()); for (keys) |key| try testing.expect(owned.contains(locals[key])); } + +test "ARC outcome capability excludes conventions that return no ownership units" { + for ([_]bool{ false, true }) |stable| { + var f = try ArcTest.init(testing.allocator); + defer f.deinit(); + const outcome_layout = try f.layouts.putTagUnion(&.{ f.list_i64, f.list_i64 }); + const param = try f.local(f.list_i64); + const choose = try f.local(.i64); + const result = try f.local(outcome_layout); + const ret = try f.ret(result); + const left = try f.assignTag(result, 0, param, ret); + const right = try f.assignTag(result, 1, param, ret); + const body = try f.switchStmt(choose, left, right, null); + const proc = try f.addProc(&.{ param, choose }, body, outcome_layout); + f.store.getProcSpecPtr(proc).stable_signature = stable; + const rc = [_]bool{ true, false, true }; + { + var solution = try arc_solve.solve(testing.allocator, &f.store, &f.layouts, &rc, .{}, &.{}, true); + defer solution.deinit(); + try testing.expect(solution.availableOutcomeSpanOf(proc).isEmpty()); + } + try insert(&f.store, &f.layouts, .{ .specialize = false }); + try testing.expect(!f.store.getProcSpec(proc).rc_variant_demandable); + } +} diff --git a/src/lir/arc_solve.zig b/src/lir/arc_solve.zig index e8761344d63..dfbd14e2d6b 100644 --- a/src/lir/arc_solve.zig +++ b/src/lir/arc_solve.zig @@ -1949,6 +1949,14 @@ fn computeOutcomeRestitution( continue; } + // A complete table can still return no ownership unit on any path. + // Such a convention is identical to the base and cannot be demanded; + // do not publish it as a variant capability or duplicate its liveness. + var restituted_params: arc_sig.ParamMask = 0; + var capabilities = accum.valueIterator(); + while (capabilities.next()) |outcome| restituted_params |= outcome.remaining_on_all_paths; + if (restituted_params == 0) continue; + const start = all_outcomes.items.len; var iter = accum.iterator(); while (iter.next()) |entry| { diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index 8b26a87090e..e95af587ae1 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -164,6 +164,9 @@ pub const TargetConfig = struct { /// compile-time roots it demands (design.md "Demand-Driven Compile-Time /// Evaluation"). demand_driven_comptime_roots: bool = false, + /// A test/check command still validates every initializer. A prior silent + /// successful validation permits deferring its value until a reader needs it. + validated_comptime_roots: ?*const std.AutoHashMapUnmanaged(ComptimeDemandRoot, void) = null, /// This program's specializations may be written to the object cache, so /// Monotype records what each one reads of compile-time evaluation /// (`SpecComptimeReads`). @@ -178,6 +181,9 @@ pub const TargetConfig = struct { /// a literal root that evaluation runs to verify the stored value. verify_literal_values: bool = false, inline_mode: InlineMode = .none, + /// Optional ARC mode specialization belongs to the consumer's optimization + /// policy, independently of whether source wrappers are inlined. + specialize_arc_modes: bool = false, /// Direct-call inlining scope for SpecConstr's value-aware clones. /// Optimized builds use `.all_calls`; dev builds use `.iterator_fusion` /// so post-check time and emitted program size stay bounded. Consulted @@ -1125,6 +1131,7 @@ pub const SolvedPolicy = struct { /// The settings one consumer's LIR generation, procedure passes and ARC run /// under. A consumer continuing a shared Solved program brings its own. pub const LirPolicy = struct { + specialize_arc_modes: bool, consume_dead_boxes: bool, zero_dict_seed: bool, list_in_place_map: bool, @@ -1538,6 +1545,7 @@ pub fn prepareCheckedModulesMonotype( .static_data_literals = target.checked_module_state == .checking_finalization or roots.include_internal_static_data, .comptime_value_reads = target.comptime_value_reads, .demand_driven_comptime_roots = target.demand_driven_comptime_roots, + .validated_comptime_roots = target.validated_comptime_roots, .comptime_demand_out = if (target.comptime_value_reads) &comptime_demand else null, .spec_comptime_reads_out = if (target.spec_comptime_reads) &spec_comptime_reads else null, .spec_key_aliases_out = &spec_key_aliases, @@ -1563,10 +1571,21 @@ pub fn prepareCheckedModulesMonotype( if (!target.demand_driven_comptime_roots and root_count != roots.requests.len) { checkedPipelineInvariant("Monotype lowering published a root count different from its requests"); } + if (target.demand_driven_comptime_roots) { + // Only compile-time requests are removed, all from the prefix. Test + // reporting retains its original source/result identity while its + // execution position follows the producer's selected root prefix. + const omitted: u32 = @intCast(roots.requests.len - root_count); + for (test_plan_metadata) |*metadata| metadata.request_index -= omitted; + } + // The validation set is borrowed only by Monotype production, never by + // its retained consumers after the coordinator's finalization returns. + var retained_target = target; + retained_target.validated_comptime_roots = null; return .{ .allocator = allocator, .program = mono, - .target = target, + .target = retained_target, .root_count = root_count, .test_plan_metadata = test_plan_metadata, .comptime_demand = if (target.comptime_value_reads) comptime_demand else null, @@ -2051,7 +2070,7 @@ fn finishLoweredOutput( } try Arc.insert(&lowered.lir_result.store, &lowered.lir_result.layouts, .{ .roots = arc_roots.items, - .specialize = target.inline_mode != .none, + .specialize = target.specialize_arc_modes, .consume_dead_boxes = target.consume_dead_boxes, .thread_confined_rc = target.thread_confined_rc, .post_check_executor = if (target.post_check_executor) |*executor| executor else null, @@ -2414,7 +2433,11 @@ const LirDump = if (!base.dev_diagnostics.available) struct { DebugPrint.writeProc(store.allocator, store, layouts, proc_id, &buffer.writer) catch |err| switch (err) { error.OutOfMemory, error.WriteFailed => return error.OutOfMemory, }; - std.debug.print("=== LIR {s} (p{d}) ===\n{s}\n", .{ name orelse "", index, buffer.written() }); + const proc = store.getProcSpecImmediate(proc_id); + const fingerprint = DebugPrint.procFingerprint(store.allocator, store, layouts, proc_id) catch |err| switch (err) { + error.OutOfMemory, error.WriteFailed => return error.OutOfMemory, + }; + std.debug.print("=== LIR {s} (p{d}, identity={x}, fingerprint={x:0>16}, borrowed={x}, ret_borrowed={}, unique={}) ===\n{s}\n", .{ name orelse "", index, proc.identity.bytes, fingerprint, proc.rc_borrowed_params, proc.rc_ret_borrowed, proc.rc_ret_unique, buffer.written() }); } } }; diff --git a/src/lir/loop_append_promote.zig b/src/lir/loop_append_promote.zig index 2900bb0edbb..b8cd2ab5b00 100644 --- a/src/lir/loop_append_promote.zig +++ b/src/lir/loop_append_promote.zig @@ -1292,6 +1292,18 @@ const Pass = struct { try self.scanProc(self.store.getProcSpecImmediate(proc_id).body.?, scan); max_join_id = @max(max_join_id, scan.max_join_id); if (scan.edges.items.len == 0) break; + const has_candidate = candidate: { + for (scan.joins.items) |info| { + if (!info.has_back_edge) continue; + const params = self.store.getLocalSpan(self.store.getCFStmtImmediate(info.stmt).join.params); + for (0..GuardedList.borrowLen(params)) |index| { + const param = GuardedList.at(params, index); + if (self.isListLocal(param) and !attempted.contains(param)) break :candidate true; + } + } + break :candidate false; + }; + if (!has_candidate) break; try self.classifyMetadataTransfers(scan, proc_id); try edge_index.rebuild(allocator, scan.edges.items); outer: for (scan.joins.items) |info| { @@ -5027,7 +5039,9 @@ test "promote ignores a join without a back edge" { .ret_layout = f.list, }, .none); + const before = @import("use_order.zig").topology_builds; try run(store, &f.layouts); + try testing.expectEqual(before, @import("use_order.zig").topology_builds); try testing.expect(store.getCFStmtImmediate(append_call).* == .assign_call); const unchanged = store.getCFStmtImmediate(loop).join; diff --git a/src/lir/pack_program.zig b/src/lir/pack_program.zig index ef99af96340..912d8743f48 100644 --- a/src/lir/pack_program.zig +++ b/src/lir/pack_program.zig @@ -512,6 +512,10 @@ pub const OfferFacts = struct { literal_conversion: []bool, site_code: []bool, reaches_linked: []bool, + reaches_observation: []bool, + /// Direct reads of evaluation-owned slots. Native publication propagates + /// this restriction over the actual excerpt graph, including lifted calls. + reads_evaluation_value: []bool, statements_visited: usize = 0, call_edges: usize = 0, propagation_edges: usize = 0, @@ -521,6 +525,14 @@ pub const OfferFacts = struct { } pub fn init(allocator: Allocator, store: *const LirStore) Allocator.Error!OfferFacts { + return initWithSlots(allocator, store, &.{}); + } + + pub fn initProgram(allocator: Allocator, program: *const core.Program.Result) Allocator.Error!OfferFacts { + return initWithSlots(allocator, &program.store, program.static_data_values.items); + } + + fn initWithSlots(allocator: Allocator, store: *const LirStore, slots: []const core.Program.StaticDataValue) Allocator.Error!OfferFacts { var arena = collections.SingleThreadArena.init(allocator); errdefer arena.deinit(); const a = arena.allocator(); @@ -528,6 +540,9 @@ pub const OfferFacts = struct { const converts = try a.alloc(bool, n); const carries = try a.alloc(bool, n); const linked = try a.alloc(bool, n); + const observed = try a.alloc(bool, n); + const evaluation_reads = try a.alloc(bool, n); + @memset(evaluation_reads, false); @memset(converts, false); @memset(carries, false); const starts = try a.alloc(u32, n + 1); @@ -540,6 +555,7 @@ pub const OfferFacts = struct { for (store.getProcSpecs(), 0..) |proc, index| { starts[index] = std.math.cast(u32, targets.items.len) orelse return error.OutOfMemory; linked[index] = proc.c_abi_entry; + observed[index] = proc.body == null; for (visited.items) |id| seen[@backingInt(id)] = false; visited.clearRetainingCapacity(); if (proc.body) |body| try work.append(a, body); @@ -549,6 +565,11 @@ pub const OfferFacts = struct { try visited.append(a, id); statements += 1; const stmt = store.getCFStmtImmediate(id); + if (stmt.* == .expect or stmt.* == .expect_err) observed[index] = true; + if (stmt.* == .assign_literal and stmt.assign_literal.value == .static_data and slots.len != 0) { + const slot = slots[@backingInt(stmt.assign_literal.value.static_data.id)]; + if (slot.compile_time_root != null) evaluation_reads[index] = true; + } if (stmt.* == .crash and store.stmtRowOrDefault(stmt.crash.extra).literal_rejection != null) converts[index] = true; if (proc.code_key == null) { var scope = store.stmtInlineScope(id); @@ -566,7 +587,7 @@ pub const OfferFacts = struct { const reverse = try collections.csr.transpose(a, starts, targets.items); var propagation_edges: usize = 0; var pending = std.ArrayList(u32).empty; - for ([_][]bool{ linked, carries }, 0..) |marks, property| { + for ([_][]bool{ linked, carries, observed }, 0..) |marks, property| { for (marks, 0..) |marked, index| if (marked) try pending.append(a, @intCast(index)); while (pending.pop()) |callee| { for (reverse.targets[reverse.starts[callee]..reverse.starts[callee + 1]]) |caller| { @@ -578,7 +599,7 @@ pub const OfferFacts = struct { } } } - return .{ .arena = arena, .literal_conversion = converts, .site_code = carries, .reaches_linked = linked, .statements_visited = statements, .call_edges = targets.items.len, .propagation_edges = propagation_edges }; + return .{ .arena = arena, .literal_conversion = converts, .site_code = carries, .reaches_linked = linked, .reaches_observation = observed, .reads_evaluation_value = evaluation_reads, .statements_visited = statements, .call_edges = targets.items.len, .propagation_edges = propagation_edges }; } }; @@ -877,3 +898,74 @@ test "offer facts visit shared cyclic call graphs once and retain all admission try std.testing.expectEqual(linked, facts.reaches_linked[i]); } } + +test "native offers record evaluation-owned slots even without Monotype read summaries" { + const a = std.testing.allocator; + var program = try core.Program.Result.init(a, .u64); + defer program.deinit(); + const store = &program.store; + try program.static_data_values.append(a, .{ + .initializer = null, + .layout_idx = .u64, + .compile_time_root = .{ + .module = .{}, + .root = .{ .literal = @fromBackingInt(0) }, + .const_locator = null, + .role = .{ .failure_message = .{ .failed_field = 0, .message_field = 0, .failed_offset = 0, .message_offset = 0 } }, + }, + }); + try program.static_data_values.append(a, .{ .initializer = null, .layout_idx = .u64 }); + const value = try store.addLocal(.{ .layout_idx = .u64 }); + const ret = try store.addCFStmt(.{ .ret = .{ .value = value } }, .test_fixture); + for (0..2) |i| { + const read = try store.addCFStmt(.{ .assign_literal = .{ + .target = value, + .value = .{ .static_data = .{ .id = @fromBackingInt(@intCast(i)) } }, + .next = ret, + } }, .test_fixture); + _ = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .identity = LIR.ProcIdentity.forTest(@intCast(i)), + .args = .empty(), + .body = read, + .ret_layout = .u64, + }, .none); + } + var facts = try OfferFacts.initProgram(a, &program); + defer facts.deinit(); + try std.testing.expectEqualSlices(bool, &.{ true, false }, facts.reads_evaluation_value); + try std.testing.expectEqualSlices(bool, &.{ false, false }, facts.reaches_observation); +} + +test "native observation admission propagates through shared cycles once" { + const a = std.testing.allocator; + var store = LirStore.init(a); + defer store.deinit(); + const value = try store.addLocal(.{ .layout_idx = .u64 }); + const ret = try store.addCFStmt(.{ .ret = .{ .value = value } }, .test_fixture); + const observed = try store.addCFStmt(.{ .expect = .{ .condition = value, .next = ret, .site = null } }, .test_fixture); + const n = 128; + for (0..n + 1) |i| { + _ = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .identity = LIR.ProcIdentity.forTest(@intCast(i)), + .args = .empty(), + .body = ret, + .ret_layout = .u64, + }, .none); + } + for (0..n) |i| { + const call = try store.addCFStmt(.{ .assign_call = .{ + .target = value, + .proc = @fromBackingInt(@intCast((i + 1) % n)), + .args = .empty(), + .next = if (i == 0) observed else ret, + } }, .test_fixture); + store.getProcSpecPtr(@fromBackingInt(@intCast(i))).body = call; + } + var facts = try OfferFacts.init(a, &store); + defer facts.deinit(); + for (facts.reaches_observation[0..n]) |marked| try std.testing.expect(marked); + try std.testing.expect(!facts.reaches_observation[n]); + try std.testing.expectEqual(@as(usize, n), facts.propagation_edges); +} diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index a321b81a474..d92c37391f5 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -79,6 +79,36 @@ const UnifyPairContext = struct { } }; +/// The expansion query reads only which Monotypes this scope binds. A new +/// binding invalidates its memo; updating a binding's destination does not. +/// No solved-graph mutation can change the immutable Monotype reachability. +const ArgumentScope = struct { + bindings: collections.DenseMap(MonoType.TypeId, Type.TypeVarId), + reads: collections.DenseMap(MonoType.TypeId, bool), + + fn init(allocator: Allocator) ArgumentScope { + return .{ .bindings = .init(allocator), .reads = .init(allocator) }; + } + fn deinit(self: *ArgumentScope) void { + self.bindings.deinit(); + self.reads.deinit(); + } + fn put(self: *ArgumentScope, ty: MonoType.TypeId, node: Type.TypeVarId) Allocator.Error!void { + const entry = try self.bindings.getOrPut(ty); + if (!entry.found_existing) self.reads.clearRetainingCapacity(); + entry.value_ptr.* = node; + } + fn get(self: *const ArgumentScope, ty: MonoType.TypeId) ?Type.TypeVarId { + return self.bindings.get(ty); + } + fn contains(self: *const ArgumentScope, ty: MonoType.TypeId) bool { + return self.bindings.contains(ty); + } +}; + +const ExactClonePair = struct { mono: MonoType.TypeId, solved: Type.TypeVarId }; +const ExactCloneProofs = @import("../proofs.zig").Cache(ExactClonePair, Type.TypeVarId); + const CompatibilityProofs = @import("../proofs.zig").Cache(UnifyPair, Type.TypeVarId); const UnifyPairSet = std.HashMap(UnifyPair, void, UnifyPairContext, std.hash_map.default_max_load_percentage); @@ -193,6 +223,11 @@ pub const WorkCounters = struct { clones: u64 = 0, unify_pairs: u64 = 0, active_cycles: u64 = 0, + exact_clone_queries: u64 = 0, + exact_clone_hits: u64 = 0, + arg_scope_queries: u64 = 0, + arg_scope_hits: u64 = 0, + arg_scope_walk_nodes: u64 = 0, compatibility_queries: u64 = 0, compatibility_pairs: u64 = 0, /// Revisited pairs within a query, from cycles or shared subgraphs. @@ -247,6 +282,11 @@ const Solver = struct { counters: ?*WorkCounters = null, /// Scratch and completed proofs owned by the function being solved. /// Disjoint queries share certificates until this producer scope ends. + exact_clone_proofs: ExactCloneProofs = .{}, + exact_clone_work: std.ArrayList(ExactClonePair) = .empty, + exact_clone_pairs: std.AutoHashMapUnmanaged(ExactClonePair, void) = .empty, + exact_clone_nodes: collections.DenseMapUnmanaged(MonoType.TypeId, Type.TypeVarId) = .empty, + exact_clone_dependencies: std.ArrayList(ExactCloneProofs.Dependency) = .empty, compatibility_work: std.ArrayList(UnifyPair) = .empty, compatibility_visited: UnifyPairSet.Unmanaged = .empty, compatibility_proofs: CompatibilityProofs = .{}, @@ -310,7 +350,7 @@ const Solver = struct { /// instantiated at, so a backing position of an argument's Monotype is /// that argument: a row joined through the backing is the argument's row /// too (design.md "Row Joins"). - arg_scopes: std.ArrayList(collections.DenseMap(MonoType.TypeId, Type.TypeVarId)), + arg_scopes: std.ArrayList(ArgumentScope), /// The argument scope a leaf created under a scoped expansion reads its /// positional components from. leaf_arg_scopes: collections.DenseMap(Type.TypeVarId, u32), @@ -525,6 +565,11 @@ const Solver = struct { } fn deinit(self: *Solver) void { + self.exact_clone_proofs.deinit(self.allocator); + self.exact_clone_work.deinit(self.allocator); + self.exact_clone_pairs.deinit(self.allocator); + self.exact_clone_nodes.deinit(self.allocator); + self.exact_clone_dependencies.deinit(self.allocator); self.compatibility_work.deinit(self.allocator); self.compatibility_visited.deinit(self.allocator); self.compatibility_proofs.deinit(self.allocator); @@ -2030,7 +2075,7 @@ const Solver = struct { fn newArgScope(self: *Solver) Allocator.Error!u32 { const index: u32 = @intCast(self.arg_scopes.items.len); - try self.arg_scopes.append(self.allocator, collections.DenseMap(MonoType.TypeId, Type.TypeVarId).init(self.allocator)); + try self.arg_scopes.append(self.allocator, ArgumentScope.init(self.allocator)); return index; } @@ -2789,6 +2834,13 @@ const Solver = struct { const leaf = leaf_content.mono; const id = leaf.id; if (!self.leafOriginIs(other, id) and !self.leafOriginIs(other_root, id)) return false; + if (self.reaches_cycle.isSet(@backingInt(id)) and + !self.contains_forced_dynamic.isSet(@backingInt(id)) and + leaf.ctx == Type.no_leaf_context and !self.leaf_arg_scopes.contains(leaf_root) and + !try self.monoProvenUninhabited(id)) + { + if (try self.exactMonoClone(id, other_root)) return true; + } if (self.isCallableFree(id)) { if (self.leafSharing(id) != .positional) return false; if (self.leaf_arg_scopes.get(leaf_root)) |scope| { @@ -2804,10 +2856,146 @@ const Solver = struct { return true; } + /// Prove that a materialized instance has exactly its source shape and + /// already includes every recursive/argument tie a fresh clone + /// would impose. Repeated mutable Monotypes must name one target class; + /// this is stronger than shape equality and prevents dropping cyclic ties. + /// No node is expanded or changed by this query. Producer writes invalidate + /// the certificate through the graph-owned reverse dependency index. + fn exactMonoClone(self: *Solver, mono: MonoType.TypeId, solved: Type.TypeVarId) Allocator.Error!bool { + self.countWork(.exact_clone_queries, 1); + const key: ExactClonePair = .{ .mono = mono, .solved = self.program.types.rootCompressed(solved) }; + self.exact_clone_proofs.refresh(self.allocator, self.program.types.analysis_changes.epoch, &self.program.types); + if (self.exact_clone_proofs.get(key)) |known| { + self.countWork(.exact_clone_hits, 1); + return known.value != 0; + } + self.exact_clone_work.clearRetainingCapacity(); + self.exact_clone_pairs.clearRetainingCapacity(); + self.exact_clone_nodes.clearRetainingCapacity(); + self.exact_clone_dependencies.clearRetainingCapacity(); + try self.exact_clone_work.append(self.allocator, key); + const matches = try self.walkExactMonoClone(); + try self.exact_clone_proofs.publish(self.allocator, &.{.{ .key = key, .value = @intFromBool(matches) }}, self.exact_clone_dependencies.items); + return matches; + } + + fn exactCloneSpans(self: *Solver, mono: MonoType.Span, solved: Type.Span) Allocator.Error!bool { + if (mono.len != solved.count()) return false; + for (self.lifted.types.span(mono), 0..) |child, i| { + try self.exact_clone_work.append(self.allocator, .{ .mono = child, .solved = self.program.types.spanItem(solved, i) }); + } + return true; + } + + fn walkExactMonoClone(self: *Solver) Allocator.Error!bool { + const mono_store = self.lifted.types; + const solved_store = &self.program.types; + while (self.exact_clone_work.pop()) |pair| { + const root = solved_store.rootCompressed(pair.solved); + if ((try self.exact_clone_pairs.getOrPut(self.allocator, .{ .mono = pair.mono, .solved = root })).found_existing) continue; + // Every representative we read is a dependency, including one + // which fails the sharing check below. + try self.exact_clone_dependencies.append(self.allocator, .{ .node = root }); + const entry = try self.exact_clone_nodes.getOrPut(self.allocator, pair.mono); + if (entry.found_existing) { + if (entry.value_ptr.* == root) continue; + if (!self.isCloneInvariant(pair.mono)) return false; + } else entry.value_ptr.* = root; + const actual = solved_store.get(root); + if (actual == .mono) { + if (actual.mono.id != pair.mono) return false; + // Even an acyclic unexpanded subtree can hide a mutable + // occurrence shared with a sibling (for example a function's + // return cell). Its source shape is not evidence that those + // target cells are already tied. Only immutable subtrees can + // establish this proof without exposing their contents. + if (!self.isCloneInvariant(pair.mono)) return false; + continue; + } + const expected = mono_store.get(pair.mono); + switch (expected) { + .primitive => |prim| if (actual != .primitive or prim != actual.primitive) return false, + .zst => if (actual != .zst) return false, + .hole => if (actual != .hole) return false, + .func => |function| { + if (actual != .func or !try self.exactCloneSpans(function.args, actual.func.args)) return false; + // The fresh clone's callable cell would be unbound. Joining + // it with this cell changes nothing, irrespective of the + // existing cell's lambda members. The repeated-node map + // still requires every repeated function occurrence to + // name one class, preserving the clone's callable ties. + try self.exact_clone_work.append(self.allocator, .{ .mono = function.ret, .solved = actual.func.ret }); + }, + .erased => return false, + .list => |child| { + if (actual != .list) return false; + try self.exact_clone_work.append(self.allocator, .{ .mono = child, .solved = actual.list }); + }, + .box => |child| { + if (actual != .box) return false; + try self.exact_clone_work.append(self.allocator, .{ .mono = child, .solved = actual.box }); + }, + .tuple => |items| { + if (actual != .tuple or !try self.exactCloneSpans(items, actual.tuple)) return false; + }, + .record => |fields| { + if (actual != .record or fields.len != actual.record.count()) return false; + for (mono_store.fieldSpan(fields), 0..) |field, i| { + const other = solved_store.fieldItem(actual.record, i); + if (field.name != other.name or !std.meta.eql(field.default, other.default)) return false; + if ((field.value_ty.get() == null) != (other.value_ty == null)) return false; + try self.exact_clone_work.append(self.allocator, .{ .mono = field.ty, .solved = other.ty }); + if (field.value_ty.get()) |ty| try self.exact_clone_work.append(self.allocator, .{ .mono = ty, .solved = other.value_ty.? }); + } + }, + .tag_union => |tags| { + if (actual != .tag_union or tags.len != actual.tag_union.count()) return false; + for (mono_store.tagSpan(tags), 0..) |tag, i| { + const other = solved_store.tagItem(actual.tag_union, i); + if (tag.name != other.name or tag.checked_name != other.checked_name or !try self.exactCloneSpans(tag.payloads, other.payloads)) return false; + } + }, + .named => |named| { + if (actual != .named) return false; + const other = actual.named; + if (!std.meta.eql(named.named_type, other.named_type) or !std.meta.eql(named.def, other.def) or named.kind != other.kind or named.builtin_owner != other.builtin_owner) return false; + if (!try self.exactCloneSpans(named.args, other.args) or (named.backing == null) != (other.backing == null) or named.declared_order.len != other.declared_order.count()) return false; + if (named.backing) |backing| { + if (backing.use != other.backing.?.use or backing.authority != other.backing.?.authority) return false; + try self.exact_clone_work.append(self.allocator, .{ .mono = backing.ty, .solved = other.backing.?.ty }); + } + for (mono_store.declaredFieldSpan(named.declared_order), 0..) |field, i| { + const other_field = solved_store.declaredFieldItem(other.declared_order, i); + switch (field) { + .named => |name| if (other_field != .named or name != other_field.named) return false, + .padding => |ty| { + if (other_field != .padding) return false; + try self.exact_clone_work.append(self.allocator, .{ .mono = ty, .solved = other_field.padding }); + }, + } + } + }, + } + } + return true; + } + /// A positional leaf can inherit a scope whose bindings are ancestors or /// unrelated arguments. Only bindings its expansion actually reads can /// make that expansion observable. Named children start their own scope. fn expansionReadsArgScope(self: *Solver, root: MonoType.TypeId, scope: u32) Allocator.Error!bool { + self.countWork(.arg_scope_queries, 1); + if (self.arg_scopes.items[scope].reads.get(root)) |answer| { + self.countWork(.arg_scope_hits, 1); + return answer; + } + const answer = try self.walkExpansionArgScope(root, scope); + try self.arg_scopes.items[scope].reads.put(root, answer); + return answer; + } + + fn walkExpansionArgScope(self: *Solver, root: MonoType.TypeId, scope: u32) Allocator.Error!bool { var seen = self.mono_set_pool.acquire(); defer self.mono_set_pool.release(&seen); var pending = std.ArrayList(MonoType.TypeId).empty; @@ -2817,6 +3005,7 @@ const Solver = struct { while (pending.pop()) |id| { if (seen.contains(id)) continue; try seen.put(id, {}); + self.countWork(.arg_scope_walk_nodes, 1); if (id != root and self.arg_scopes.items[scope].contains(id)) return true; if (self.leafSharing(id) != .positional) continue; switch (types.get(id)) { @@ -6415,3 +6604,179 @@ test "issue 12616 field presence changes retire compatibility proofs" { try types.set(b, .{ .record = required }); try std.testing.expect(try solver.typesCompatible(a, b)); } + +test "exact cyclic clone proofs avoid repeated expansion and reject widened or untied rows" { + const a = std.testing.allocator; + var lifted = emptyLiftedProgramForTest(a); + var lifted_owned = true; + errdefer if (lifted_owned) lifted.deinit(); + const end = try lifted.names.internTagLabel("End"); + const next = try lifted.names.internTagLabel("Next"); + const other = try lifted.names.internTagLabel("Other"); + const recursive = try lifted.types.beginRecursive(); + const list = try lifted.types.add(.{ .list = recursive.ty }); + lifted.types.finishRecursive(recursive, .{ .tag_union = try lifted.types.addTags(&.{ + .{ .name = end, .checked_name = end, .payloads = .empty() }, + .{ .name = next, .checked_name = next, .payloads = try lifted.types.addSpan(&.{list}) }, + }) }); + const pair_mono = try lifted.types.add(.{ .tuple = try lifted.types.addSpan(&.{ recursive.ty, recursive.ty }) }); + var program = Ast.Program.init(a, lifted); + lifted_owned = false; + defer program.deinit(); + var solver = try Solver.init(a, &program); + defer solver.deinit(); + var counts: WorkCounters = .{}; + solver.counters = &counts; + const root = try solver.lowerTypeFresh(recursive.ty); + const tags = (try solver.resolvedContent(root)).tag_union; + const list_var = program.types.spanItem(program.types.tagItem(tags, 1).payloads, 0); + _ = try solver.resolvedContent(list_var); + try std.testing.expect(try solver.exactMonoClone(recursive.ty, root)); + const expanded = counts.expansions; + const before = program.types.varCount(); + for (0..128) |_| { + const fresh = try solver.lowerTypeFresh(recursive.ty); + try solver.unify(fresh, root); + try std.testing.expectEqual(program.types.root(root), program.types.root(fresh)); + } + try std.testing.expectEqual(expanded, counts.expansions); + try std.testing.expectEqual(before + 128, program.types.varCount()); + try std.testing.expect(counts.exact_clone_hits >= 128); + + // Bisimilar recursive subtrees are not sufficient: a fresh clone's + // repeated recursive source id would tie these two distinct rows together. + const second = try solver.lowerTypeFresh(recursive.ty); + const second_tags = (try solver.resolvedContent(second)).tag_union; + const second_list = program.types.spanItem(program.types.tagItem(second_tags, 1).payloads, 0); + _ = try solver.resolvedContent(second_list); + const untied = try program.types.add(.{ .tuple = try program.types.addSpan(&.{ root, second }) }); + try solver.leaf_origins.put(untied, pair_mono); + try std.testing.expect(!try solver.exactMonoClone(pair_mono, untied)); + const fresh_pair = try solver.lowerTypeFresh(pair_mono); + try solver.unify(fresh_pair, untied); + try std.testing.expectEqual(program.types.root(root), program.types.root(second)); + + // A semantic write invalidates an earlier successful certificate. Exact + // source shape no longer holds, and ordinary unification keeps the union. + const canonical = program.types.rootCompressed(root); + const current_tags = (try solver.resolvedContent(canonical)).tag_union; + const first_tag = program.types.tagItem(current_tags, 0); + const next_tag = program.types.tagItem(current_tags, 1); + try program.types.set(canonical, .{ .tag_union = try program.types.addTags(&.{ + first_tag, next_tag, .{ .name = other, .checked_name = other, .payloads = .empty() }, + }) }); + try std.testing.expect(!try solver.exactMonoClone(recursive.ty, canonical)); + const fresh = try solver.lowerTypeFresh(recursive.ty); + try solver.unify(fresh, canonical); + try std.testing.expectEqual(@as(usize, 3), (try solver.resolvedContent(fresh)).tag_union.count()); +} + +test "exact cyclic clone proofs preserve populated callable cells and reject untied functions" { + const a = std.testing.allocator; + var lifted = emptyLiftedProgramForTest(a); + var lifted_owned = true; + errdefer if (lifted_owned) lifted.deinit(); + const end = try lifted.names.internTagLabel("End"); + const next = try lifted.names.internTagLabel("Next"); + const recursive = try lifted.types.beginRecursive(); + const callback = try lifted.types.add(.{ .func = .{ .args = .empty(), .ret = recursive.ty } }); + lifted.types.finishRecursive(recursive, .{ .tag_union = try lifted.types.addTags(&.{ + .{ .name = end, .checked_name = end, .payloads = .empty() }, + .{ .name = next, .checked_name = next, .payloads = try lifted.types.addSpan(&.{callback}) }, + }) }); + const pair = try lifted.types.add(.{ .tuple = try lifted.types.addSpan(&.{ callback, callback }) }); + var program = Ast.Program.init(a, lifted); + lifted_owned = false; + defer program.deinit(); + var solver = try Solver.init(a, &program); + defer solver.deinit(); + var counts: WorkCounters = .{}; + solver.counters = &counts; + const root = try solver.lowerTypeFresh(recursive.ty); + const tags = (try solver.resolvedContent(root)).tag_union; + const callback_var = program.types.spanItem(program.types.tagItem(tags, 1).payloads, 0); + const function = (try solver.resolvedContent(callback_var)).func; + const members = try program.types.addMembers(&.{.{ .lambda = @fromBackingInt(1), .captures = .empty() }}); + try program.types.set(function.callable, .{ .lambda_set = members }); + try std.testing.expect(try solver.exactMonoClone(recursive.ty, root)); + const expansions = counts.expansions; + for (0..128) |_| { + const fresh = try solver.lowerTypeFresh(recursive.ty); + try solver.unify(fresh, root); + try std.testing.expectEqual(program.types.root(root), program.types.root(fresh)); + } + try std.testing.expectEqual(expansions, counts.expansions); + try std.testing.expectEqual(@as(usize, 1), (try solver.resolvedContent(function.callable)).lambda_set.count()); + const other_callable = try program.types.add(.unbound); + const other_callback = try program.types.add(.{ .func = .{ .args = function.args, .ret = function.ret, .callable = other_callable } }); + const untied = try program.types.add(.{ .tuple = try program.types.addSpan(&.{ callback_var, other_callback }) }); + try std.testing.expect(!try solver.exactMonoClone(pair, untied)); +} + +test "argument scope expansion queries reuse exact answers and invalidate on new bindings" { + const a = std.testing.allocator; + var lifted = emptyLiftedProgramForTest(a); + var lifted_owned = true; + errdefer if (lifted_owned) lifted.deinit(); + const tag = try lifted.names.internTagLabel("A"); + const row = try lifted.types.add(.{ .tag_union = try lifted.types.addTags(&.{.{ .name = tag, .checked_name = tag, .payloads = .empty() }}) }); + const shape = try lifted.types.add(.{ .tuple = try lifted.types.addSpan(&.{ row, row }) }); + var program = Ast.Program.init(a, lifted); + lifted_owned = false; + defer program.deinit(); + var solver = try Solver.init(a, &program); + defer solver.deinit(); + var counts: WorkCounters = .{}; + solver.counters = &counts; + const scope = try solver.newArgScope(); + try std.testing.expect(!try solver.expansionReadsArgScope(shape, scope)); + const walked = counts.arg_scope_walk_nodes; + for (0..128) |_| try std.testing.expect(!try solver.expansionReadsArgScope(shape, scope)); + try std.testing.expectEqual(walked, counts.arg_scope_walk_nodes); + try std.testing.expectEqual(@as(u64, 128), counts.arg_scope_hits); + const node = try solver.lowerTypeFresh(row); + try solver.arg_scopes.items[scope].put(row, node); + try std.testing.expect(try solver.expansionReadsArgScope(shape, scope)); + const changed_walked = counts.arg_scope_walk_nodes; + const replacement = try solver.lowerTypeFresh(row); + try solver.arg_scopes.items[scope].put(row, replacement); + try std.testing.expect(try solver.expansionReadsArgScope(shape, scope)); + try std.testing.expectEqual(changed_walked, counts.arg_scope_walk_nodes); + const independent = try solver.newArgScope(); + try std.testing.expect(!try solver.expansionReadsArgScope(shape, independent)); +} + +test "exact cyclic clone proof rejects unexpanded leaves that hide a mutable tie" { + const a = std.testing.allocator; + var lifted = emptyLiftedProgramForTest(a); + var lifted_owned = true; + errdefer if (lifted_owned) lifted.deinit(); + const end = try lifted.names.internTagLabel("End"); + const next = try lifted.names.internTagLabel("Next"); + const u8_type = try lifted.types.add(.{ .primitive = .u8 }); + const inner = try lifted.types.add(.{ .func = .{ .args = .empty(), .ret = u8_type } }); + const outer = try lifted.types.add(.{ .func = .{ .args = .empty(), .ret = inner } }); + const recursive = try lifted.types.beginRecursive(); + lifted.types.finishRecursive(recursive, .{ .tag_union = try lifted.types.addTags(&.{ + .{ .name = end, .checked_name = end, .payloads = .empty() }, + .{ .name = next, .checked_name = next, .payloads = try lifted.types.addSpan(&.{ recursive.ty, outer, inner }) }, + }) }); + var program = Ast.Program.init(a, lifted); + lifted_owned = false; + defer program.deinit(); + var solver = try Solver.init(a, &program); + defer solver.deinit(); + const target = try program.types.add(.unbound); + const outer_leaf = try solver.lowerTypeFresh(outer); + const inner_instance = try solver.lowerTypeFresh(inner); + _ = try solver.resolvedContent(inner_instance); + try program.types.set(target, .{ .tag_union = try program.types.addTags(&.{ + .{ .name = end, .checked_name = end, .payloads = .empty() }, + .{ .name = next, .checked_name = next, .payloads = try program.types.addSpan(&.{ target, outer_leaf, inner_instance }) }, + }) }); + // Expanding a fresh R ties its inner function occurrence to the return + // of outer. The unexpanded target outer has no such established tie. + const count = program.types.varCount(); + try std.testing.expect(!try solver.exactMonoClone(recursive.ty, target)); + try std.testing.expectEqual(count, program.types.varCount()); +} diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index 8b3da2078e7..3f1d7b82a2e 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -10,7 +10,16 @@ const Allocator = std.mem.Allocator; const Constraints = Solve.InterfaceConstraints; const GuardedList = @import("collections").GuardedList; const invariant = base.invariant; -const magic = "RIFSUM02"; +const magic = "RIFSUM04"; + +/// A checked occurrence either names its immutable artifact directly, or +/// names a closed checked type by canonical content. The latter has no +/// instantiation identity and can be bound to any current published root +/// with that exact key; its nominal owner remains content-addressed. +pub const NamedOccurrence = union(enum) { + exact: struct { owner: [32]u8, occurrence: [32]u8, ty: u32 }, + closed: struct { owner: [32]u8, key: [32]u8 }, +}; /// Work performed by the compact codec, independent of wall-clock timing. pub const Stats = struct { types: u64 = 0, composites: u64 = 0, references: u64 = 0, defaults: u64 = 0 }; @@ -22,6 +31,11 @@ pub const EncodeError = Allocator.Error || error{NotRepresentable}; /// Allocation failure or an invalid optional cache record. pub const DecodeError = Allocator.Error || error{InvalidSummary}; +fn invalidSummary(comptime line: u32) error{InvalidSummary} { + if (base.dev_diagnostics.isSet(.pack_trace)) std.debug.print("interface invalid summary line={d}\n", .{line}); + return error.InvalidSummary; +} + /// Encode only the immutable graph reachable from the settled relation. The /// context supplies exact checked artifact addresses for executable defaults. pub fn encode(allocator: Allocator, types: *Type.Store, ns: *const names.NameStore, leaves: []const Type.TypeId, summary: Summary, context: anytype) EncodeError![]u8 { @@ -75,12 +89,12 @@ pub fn decodeForRequest(arena: Allocator, types: *Type.Store, ns: *names.NameSto var scratch = std.heap.ArenaAllocator.init(types.allocator); defer scratch.deinit(); var reader: Reader(@TypeOf(context)) = .{ .arena = scratch.allocator(), .names = ns, .leaves = leaves, .bytes = bytes, .context = context }; - if (!std.mem.startsWith(u8, reader.bytes, magic)) return error.InvalidSummary; + if (!std.mem.startsWith(u8, reader.bytes, magic)) return invalidSummary(@src().line); reader.bytes = reader.bytes[magic.len..]; reader.counts = try reader.read([3]u32); reader.type_count = try reader.read(u32); - if (try reader.read(u32) != leaves.len) return error.InvalidSummary; - if (reader.type_count > reader.bytes.len or leaves.len > std.math.maxInt(u32) - reader.type_count) return error.InvalidSummary; + if (try reader.read(u32) != leaves.len) return invalidSummary(@src().line); + if (reader.type_count > reader.bytes.len or leaves.len > std.math.maxInt(u32) - reader.type_count) return invalidSummary(@src().line); const summary = try reader.read(Summary); const nodes = try scratch.allocator().alloc(WireType, reader.type_count); for (nodes) |*node| { @@ -91,17 +105,19 @@ pub fn decodeForRequest(arena: Allocator, types: *Type.Store, ns: *names.NameSto .primitive, .erased, .zst, .hole => {}, } } - if (reader.bytes.len != 0) return error.InvalidSummary; + if (reader.bytes.len != 0) return invalidSummary(@src().line); switch (summary) { - .unchanged => if (!allow_unchanged or !std.meta.eql(reader.counts, [3]u32{ 0, 0, 0 }) or nodes.len != 0) return error.InvalidSummary, + .unchanged => if (!allow_unchanged or !std.meta.eql(reader.counts, [3]u32{ 0, 0, 0 }) or nodes.len != 0) return invalidSummary(@src().line), .constraints => |c| { - if (root_count) |expected| if (c.roots.len != expected) return error.InvalidSummary; - if (c.nodes.len != reader.counts[0] or c.open_nodes.len != reader.counts[1] or c.kinds.len != reader.counts[2]) return error.InvalidSummary; - for (c.nodes) |node| if (node == .open and node.open >= c.open_nodes.len) return error.InvalidSummary; + if (root_count) |expected| if (c.roots.len != expected) return invalidSummary(@src().line); + if (c.nodes.len != reader.counts[0] or c.open_nodes.len != reader.counts[1] or c.kinds.len != reader.counts[2]) return invalidSummary(@src().line); + for (c.nodes) |node| if (node == .open and node.open >= c.open_nodes.len) return invalidSummary(@src().line); for (c.open_nodes) |node| { - if (node.content == .redirect or node.content == .leaf) return error.InvalidSummary; - if (node.content == .unresolved and node.content.unresolved.origin == .placeholder) return error.InvalidSummary; - if (node.related_group != .none and @backingInt(node.related_group) >= c.open_nodes.len) return error.InvalidSummary; + if (node.content == .redirect or node.content == .leaf) return invalidSummary(@src().line); + if (node.content == .unresolved and node.content.unresolved.origin == .placeholder) return invalidSummary(@src().line); + if (node.content == .unresolved and node.content.unresolved.origin == .interface_parameter and + (node.content.unresolved.numeric_default_phase != null or node.content.unresolved.row_default != null)) return invalidSummary(@src().line); + if (node.related_group != .none and @backingInt(node.related_group) >= c.open_nodes.len) return invalidSummary(@src().line); } }, } @@ -222,7 +238,7 @@ fn validateTypeGraph(allocator: Allocator, leaf_count: usize, summary: Summary, seen[index] = true; try queue.appendSlice(allocator, edges.edges.items[starts[index]..starts[index + 1]]); } - for (seen) |reachable| if (!reachable) return error.InvalidSummary; + for (seen) |reachable| if (!reachable) return invalidSummary(@src().line); // Checking rejects alias-only cycles. A recursive type must cross a real // constructor, so an unguarded alias cycle is corrupt external input. @@ -239,7 +255,7 @@ fn validateTypeGraph(allocator: Allocator, leaf_count: usize, summary: Summary, const ref = @backingInt(backing.ty); if (ref < leaf_count) break; index = ref - leaf_count; - if (colors[index] == .active) return error.InvalidSummary; + if (colors[index] == .active) return invalidSummary(@src().line); } index = start; while (colors[index] == .active) { @@ -311,8 +327,8 @@ const Relocation = struct { /// Checked callable-type ordinals are provenance; producer-issued callable keys /// carry their relation, as in `Ast.fnEvidenceDigest`. pub fn requestKey(request: Constraints.Identity, evidence: [32]u8, procedure: [8]u8) [32]u8 { - var hash = std.crypto.hash.sha2.Sha256.init(.{}); - hash.update("roc.interface-request.v2"); + var hash = base.Sha256.init(.{}); + hash.update("roc.interface-request.v3"); hash.update(&procedure); hash.update(request.bytes); hash.update(&evidence); @@ -431,6 +447,7 @@ fn Writer(comptime Context: type) type { } fn write(self: *Self, comptime T: type, value: T) EncodeError!void { + if (T == Type.NamedType) return self.write(NamedOccurrence, self.context.describeNamedType(value)); if (T == Type.TypeId) { self.stats.references += 1; return self.write(u32, try self.reference(value)); @@ -503,16 +520,21 @@ fn Reader(comptime Context: type) type { type_count: u32 = 0, fn read(self: *Self, comptime T: type) DecodeError!T { + if (T == Type.NamedType) { + const named = (try self.context.resolveNamedType(try self.read(NamedOccurrence))) orelse return invalidSummary(@src().line); + if (!self.context.validNamedType(named)) return invalidSummary(@src().line); + return named; + } if (T == Type.TypeId) { self.stats.references += 1; const index = try self.read(u32); - if (index >= self.leaves.len + self.type_count) return error.InvalidSummary; + if (index >= self.leaves.len + self.type_count) return invalidSummary(@src().line); return @fromBackingInt(index); } if (T == Type.MaybeTypeId) return .of(try self.read(?Type.TypeId)); if (T == names.ModuleIdentityId) { const digest = try self.read([32]u8); - if (!self.context.validModuleIdentity(digest)) return error.InvalidSummary; + if (!self.context.validModuleIdentity(digest)) return invalidSummary(@src().line); return self.names.internModuleIdentity(&digest); } if (T == names.TypeNameId) return self.names.internTypeName(try self.read([]const u8)); @@ -523,14 +545,14 @@ fn Reader(comptime Context: type) type { const identity = try self.read([32]u8); const artifact = try self.read([32]u8); const expr = try self.read(u32); - const current = self.context.defaultArtifact(identity, expr) orelse return error.InvalidSummary; - if (!std.mem.eql(u8, &artifact, ¤t)) return error.InvalidSummary; + const current = self.context.defaultArtifact(identity, expr) orelse return invalidSummary(@src().line); + if (!std.mem.eql(u8, &artifact, ¤t)) return invalidSummary(@src().line); return .{ .module = try self.names.internModuleIdentity(&identity), .expr_node = expr }; } if (T == Solve.NodeId or T == Solve.MaybeNodeId or T == Solve.FieldKindId) { const index = try self.read(u32); if (T == Solve.MaybeNodeId and index == std.math.maxInt(u32)) return @fromBackingInt(index); - if (index >= self.counts[if (T == Solve.FieldKindId) 2 else 0]) return error.InvalidSummary; + if (index >= self.counts[if (T == Solve.FieldKindId) 2 else 0]) return invalidSummary(@src().line); return @fromBackingInt(index); } const result: T = switch (@typeInfo(T)) { @@ -542,7 +564,7 @@ fn Reader(comptime Context: type) type { .@"union" => |info| blk: { const tag = try self.read(info.tag_type.?); inline for (info.field_names, info.field_types) |field, F| if (tag == @field(info.tag_type.?, field)) break :blk @unionInit(T, field, try self.read(F)); - return error.InvalidSummary; + return invalidSummary(@src().line); }, .optional => |info| if (try self.read(bool)) try self.read(info.child) else null, .pointer => |info| blk: { @@ -554,7 +576,7 @@ fn Reader(comptime Context: type) type { if (info.size != .slice) @compileError("unbounded summary pointer"); const len = try self.read(u32); // Every supported element consumes at least one byte. - if (len > self.bytes.len) return error.InvalidSummary; + if (len > self.bytes.len) return invalidSummary(@src().line); const values = try self.arena.alloc(info.child, len); for (values) |*value| value.* = try self.read(info.child); break :blk values; @@ -568,20 +590,20 @@ fn Reader(comptime Context: type) type { const value = try self.read(info.tag_type); if (info.mode == .exhaustive) { inline for (info.field_values) |v| if (value == v) break :blk @fromBackingInt(value); - return error.InvalidSummary; + return invalidSummary(@src().line); } break :blk @fromBackingInt(value); }, .bool => switch (try self.read(u8)) { 0 => false, 1 => true, - else => return error.InvalidSummary, + else => return invalidSummary(@src().line), }, .int => blk: { const U = @Int(.unsigned, @bitSizeOf(T)); const size = (@bitSizeOf(T) + 7) / 8; - if (size > self.bytes.len) return error.InvalidSummary; - if (@bitSizeOf(T) % 8 != 0 and self.bytes[size - 1] >= (@as(u16, 1) << (@bitSizeOf(T) % 8))) return error.InvalidSummary; + if (size > self.bytes.len) return invalidSummary(@src().line); + if (@bitSizeOf(T) % 8 != 0 and self.bytes[size - 1] >= (@as(u16, 1) << (@bitSizeOf(T) % 8))) return invalidSummary(@src().line); var value: U = 0; for (self.bytes[0..size], 0..) |b, i| value |= std.math.shl(U, @as(U, @truncate(b)), i * 8); self.bytes = self.bytes[size..]; @@ -590,19 +612,19 @@ fn Reader(comptime Context: type) type { .void => {}, .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), }; - if (T == Type.NamedType) if (!self.context.validNamedType(result)) return error.InvalidSummary; + if (T == Type.NamedType) if (!self.context.validNamedType(result)) return invalidSummary(@src().line); if (T == WireNamed or T == @typeInfo(@FieldType(Solve.InstNode, "named")).pointer.child or T == Solve.InstIteratorPublicSource) { - if (!self.context.validNamedDefinition(self.names, result.named_type, result.def, result.kind, result.builtin_owner)) return error.InvalidSummary; + if (!self.context.validNamedDefinition(self.names, result.named_type, result.def, result.kind, result.builtin_owner)) return invalidSummary(@src().line); } if (T == WireType) switch (result) { .record => |fields| { for (fields, 0..) |field, i| { - if (i > 0 and std.mem.order(u8, self.names.recordFieldLabelText(fields[i - 1].name), self.names.recordFieldLabelText(field.name)) != .lt) return error.InvalidSummary; - if (field.kind_state == .undetermined and (field.value_ty.get() == null or field.ty != field.value_ty.get().? or field.default != null)) return error.InvalidSummary; + if (i > 0 and std.mem.order(u8, self.names.recordFieldLabelText(fields[i - 1].name), self.names.recordFieldLabelText(field.name)) != .lt) return invalidSummary(@src().line); + if (field.kind_state == .undetermined and (field.value_ty.get() == null or field.ty != field.value_ty.get().? or field.default != null)) return invalidSummary(@src().line); } }, .tag_union => |tags| for (tags, 0..) |tag, i| { - if (i > 0 and std.mem.order(u8, self.names.tagLabelText(tags[i - 1].name), self.names.tagLabelText(tag.name)) != .lt) return error.InvalidSummary; + if (i > 0 and std.mem.order(u8, self.names.tagLabelText(tags[i - 1].name), self.names.tagLabelText(tag.name)) != .lt) return invalidSummary(@src().line); }, .primitive, .named, .tuple, .list, .box, .func, .erased, .zst, .hole => {}, }; @@ -614,6 +636,8 @@ fn Reader(comptime Context: type) type { const TestProvenance = struct { artifact: [32]u8 = @splat(7), available: bool = true, + closed_key: ?[32]u8 = null, + occurrence_version: u8 = 0, pub fn defaultArtifact(self: @This(), identity: [32]u8, expr: u32) ?[32]u8 { if (!self.available or identity[0] != 1 or expr > 2) return null; return self.artifact; @@ -621,6 +645,22 @@ const TestProvenance = struct { pub fn validModuleIdentity(self: @This(), identity: [32]u8) bool { return self.available and identity[0] == 1; } + pub fn describeNamedType(self: @This(), named: Type.NamedType) NamedOccurrence { + if (self.closed_key) |key| return .{ .closed = .{ .owner = named.module.bytes, .key = key } }; + return .{ .exact = .{ .owner = named.module.bytes, .occurrence = named.ty_module.bytes, .ty = @backingInt(named.ty) } }; + } + pub fn resolveNamedType(self: @This(), occurrence: NamedOccurrence) Allocator.Error!?Type.NamedType { + return switch (occurrence) { + .exact => |exact| .{ .module = .{ .bytes = exact.owner }, .ty_module = .{ .bytes = exact.occurrence }, .ty = @fromBackingInt(exact.ty) }, + .closed => |closed| blk: { + const key = self.closed_key orelse break :blk null; + if (!std.mem.eql(u8, &key, &closed.key)) break :blk null; + var module: [32]u8 = @splat(0); + module[1] = self.occurrence_version; + break :blk .{ .module = .{ .bytes = closed.owner }, .ty_module = .{ .bytes = module }, .ty = @fromBackingInt(1) }; + }, + }; + } pub fn validNamedType(self: @This(), named: Type.NamedType) bool { return self.available and named.module.bytes[0] == 0 and named.ty_module.bytes[0] == 0 and @backingInt(named.ty) == 1; } @@ -1075,3 +1115,87 @@ test "persistent interface equivalent input leaves use the first request positio const result = try decode(arena.allocator(), &dest, &ns, &.{ first_input, second_input }, bytes, TestProvenance{}); try std.testing.expectEqual(first_input, result.constraints.nodes[0].mono); } + +test "persistent parametric interfaces retain explicit holes and reject unfinished placeholders" { + const gpa = std.testing.allocator; + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var types = Type.Store.init(gpa); + defer types.deinit(); + const graph = try Solve.InstGraph.create(gpa, &types, &ns); + defer graph.destroy(); + const scalar = try graph.newNode(.{ .primitive = .u8 }); + const list = try graph.newNode(.{ .list = scalar }); + var holes: [1]?Solve.NodeId = undefined; + const captured = try Constraints.captureRequestWithHoles(graph, graph.arena(), &.{ list, scalar }, &.{scalar}, &holes); + const bytes = try encode(gpa, &types, &ns, &.{}, .{ .constraints = captured }, TestProvenance{}); + defer gpa.free(bytes); + const reopened = (try decode(graph.arena(), &types, &ns, &.{}, bytes, TestProvenance{})).constraints; + for ([_]Constraints{ captured, reopened }) |summary| { + const instance = try summary.instantiate(graph); + try std.testing.expectEqual(Solve.InstVariableOrigin.interface_parameter, (try graph.content(instance[1])).unresolved.origin); + try std.testing.expect(graph.sameClass((try graph.content(instance[0])).list, instance[1])); + const actual = try graph.newNode(.{ .primitive = .str }); + try graph.unify(instance[1], actual); + try std.testing.expectEqual(Type.Primitive.str, (try graph.content((try graph.content(instance[0])).list)).primitive); + } + const parameter: Summary = .{ .constraints = .{ + .roots = &.{@fromBackingInt(0)}, + .nodes = &.{.{ .open = 0 }}, + .open_nodes = &.{.{ .content = .{ .unresolved = Solve.InstVariable.interfaceParameter() } }}, + .kinds = &.{}, + } }; + var checked_variable = parameter; + checked_variable.constraints.open_nodes = &.{.{ .content = .{ .unresolved = Solve.InstVariable.checkedVariable(null, null) } }}; + const malformed = try encode(gpa, &types, &ns, &.{}, parameter, TestProvenance{}); + defer gpa.free(malformed); + const comparison = try encode(gpa, &types, &ns, &.{}, checked_variable, TestProvenance{}); + defer gpa.free(comparison); + try std.testing.expectEqual(malformed.len, comparison.len); + var differences: usize = 0; + for (malformed, comparison) |*byte, other| if (byte.* != other) { + try std.testing.expectEqual(@as(u8, @backingInt(Solve.InstVariableOrigin.interface_parameter)), byte.*); + byte.* = @backingInt(Solve.InstVariableOrigin.placeholder); + differences += 1; + }; + try std.testing.expectEqual(@as(usize, 1), differences); + try std.testing.expectError(error.InvalidSummary, decode(graph.arena(), &types, &ns, &.{}, malformed, TestProvenance{})); +} + +test "persistent interface closed occurrences rebind by canonical key across artifacts" { + const a = std.testing.allocator; + var ns = names.NameStore.init(a); + defer ns.deinit(); + var types = Type.Store.init(a); + defer types.deinit(); + const scalar = try types.internPrimitive(&ns, .u64); + var old_module: [32]u8 = @splat(0); + old_module[1] = 3; + const alias = try types.add(.{ .named = .{ + .named_type = .{ .module = .{}, .ty = @fromBackingInt(1), .ty_module = .{ .bytes = old_module } }, + .def = .{ .module = try ns.internModuleIdentity(&@as([32]u8, @splat(1))), .type_name = try ns.internTypeName("Alias") }, + .kind = .alias, + .args = .empty(), + .backing = .{ .ty = scalar, .use = .inspectable }, + } }); + var nodes: [1]Constraints.Node = undefined; + const summary = typeSummary(alias, &nodes); + const key: [32]u8 = @splat(42); + const bytes = try encode(a, &types, &ns, &.{}, summary, TestProvenance{ .closed_key = key }); + defer a.free(bytes); + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const decoded = (try decode(arena.allocator(), &types, &ns, &.{}, bytes, TestProvenance{ .closed_key = key, .occurrence_version = 9 })).constraints; + const named = types.get(decoded.nodes[0].mono).named; + try std.testing.expectEqual(@as(u8, 9), named.named_type.ty_module.bytes[1]); + try std.testing.expectEqualSlices(u8, &@as([32]u8, @splat(0)), &named.named_type.module.bytes); + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &types, &ns, &.{}, bytes, TestProvenance{})); + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &types, &ns, &.{}, bytes, TestProvenance{ .closed_key = @splat(43) })); + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &types, &ns, &.{}, bytes, TestProvenance{ .closed_key = key, .available = false })); + // Identity-bearing occurrences keep their exact artifact; they are never + // silently promoted to the canonical closed-root contract. + const exact = try encode(a, &types, &ns, &.{}, summary, TestProvenance{}); + defer a.free(exact); + const exact_decoded = (try decode(arena.allocator(), &types, &ns, &.{}, exact, TestProvenance{ .closed_key = key, .occurrence_version = 9 })).constraints; + try std.testing.expectEqual(@as(u8, 3), types.get(exact_decoded.nodes[0].mono).named.named_type.ty_module.bytes[1]); +} diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index ddb95604484..e8caf72c0ba 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -313,6 +313,7 @@ pub const Options = struct { /// the compile-time roots the program demands (design.md "Demand-Driven /// Compile-Time Evaluation"). demand_driven_comptime_roots: bool = false, + validated_comptime_roots: ?*const std.AutoHashMapUnmanaged(EntryRoot, void) = null, /// With `comptime_value_reads`, receives the compile-time roots the /// program demands and the checked roots each of its compile-time roots /// reads. @@ -414,9 +415,10 @@ pub const ComptimeDemand = struct { /// nothing reachable references, such as a callee body an immediate claim /// lowered for a caller that kept its own copy, demands nothing. /// -/// The state persists across the lowering's drains. Definitions only grow, -/// and a definition is summarized once its body is final: after the drain -/// that completed it. +/// The state persists across lowering drains. A discovery request may expose +/// a parked declaration before an evaluation request completes its body. +/// Explicit body-publication events invalidate that declaration's summary and +/// revisit it if it was already reachable. const ComptimeDemandState = struct { const Range = struct { start: u32, len: u32 }; /// `local` is set when the body evaluates a root in place: code that @@ -431,6 +433,9 @@ const ComptimeDemandState = struct { /// two disjoint ranges of one id space. const nested_bit: u32 = 1 << 31; + /// Previously reached declarations whose final bodies were published + /// after an earlier demand round inspected their parked declarations. + changed_reached: std.ArrayList(u32) = .empty, def_summaries: std.ArrayList(?Summary) = .empty, nested_summaries: std.ArrayList(?Summary) = .empty, summary_refs: std.ArrayList(u32) = .empty, @@ -487,6 +492,7 @@ const ComptimeDemandState = struct { stored_demanded: std.ArrayList(u32) = .empty, fn deinit(self: *ComptimeDemandState, allocator: Allocator) void { + self.changed_reached.deinit(allocator); self.def_summaries.deinit(allocator); self.nested_summaries.deinit(allocator); self.summary_refs.deinit(allocator); @@ -602,6 +608,20 @@ const ComptimeDemandState = struct { if (list.items.len < len) try list.appendNTimes(allocator, value, len - list.items.len); } + /// Body publication is the producer event invalidating an earlier + /// summary of a parked declaration. Unreached bodies need no traversal; + /// reached ones join the next round at the changed definition itself. + fn bodyPublished(self: *ComptimeDemandState, allocator: Allocator, fn_id: Ast.FnId) Allocator.Error!void { + const raw = @backingInt(fn_id); + if (raw >= self.fn_items.items.len) return; + const item = self.fn_items.items[raw] orelse return; + self.summarySlot(item).* = null; + const reached = self.itemReached(item); + if (!reached.*) return; + try self.changed_reached.append(allocator, item); + reached.* = false; + } + fn summarySlot(self: *ComptimeDemandState, item: u32) *?Summary { return if (item & nested_bit != 0) &self.nested_summaries.items[item & ~nested_bit] else &self.def_summaries.items[item]; } @@ -997,8 +1017,8 @@ pub const SpecKeyAliases = struct { const DeclaredRoot = struct { request: checked.RootRequest, source_module: checked.ModuleId, - def: Ast.DefId, - fn_id: Ast.FnId, + def: ?Ast.DefId = null, + fn_id: ?Ast.FnId = null, /// A validation root (`hoisted_validation`): it produces no value for a /// read to reach and runs for its effects, so the program demands it /// outright. @@ -4528,6 +4548,13 @@ fn committedTypeRelocation(domain: anytype, program: *Ast.Program) *Type.Store.T return &domain.workspace_to_program.?; } +// ConstTypeStore nodes are immutable producer data throughout one lowering. +// The destination owns this map for exactly the lifetime of its Type.Store. +const ConstTypeMemo = std.AutoHashMapUnmanaged(struct { + store: *const check.ConstStore.ConstTypeStore, + ty: check.ConstStore.ConstTypeId, +}, Type.TypeId); + const SpecJobWorkspace = struct { allocator: Allocator, types: Type.Store, @@ -4540,6 +4567,7 @@ const SpecJobWorkspace = struct { /// it; summaries the coordinator shares are read from its table. interface_summaries: InterfaceSummaryCache, checked_type_cache: CheckedTypeMemo(Type.TypeId) = .empty, + const_type_cache: ConstTypeMemo = .empty, captured_types: Type.Store.EpochBoundary, captured_names: names.NameStore.EpochBoundary, program: ?*Ast.Program = null, @@ -4653,6 +4681,7 @@ const SpecJobWorkspace = struct { if (self.program_to_workspace) |*relocation| relocation.deinit(); self.interface_summaries.deinit(); self.checked_type_cache.deinit(self.allocator); + self.const_type_cache.deinit(self.allocator); self.types.deinit(); self.name_store.deinit(); self.superseded.deinit(self.allocator); @@ -4913,7 +4942,7 @@ const SpecJobWorkerInputs = struct { comptime_value_reads: bool, literal_roots: bool, replay_interface_summaries: base.TestSwitch(true), - declared_comptime_root_functions: *const DeclaredComptimeRootFunctions, + declared_comptime_roots: *const DeclaredComptimeRoots, hosted_catalog: []const HostedCatalogEntry, current_loc: base.SourceLoc, current_region: base.Region, @@ -5245,7 +5274,9 @@ fn appendRuntimeSchemaRequestToProgram( try program.addRuntimeSchemaRequest(request); } -const DeclaredComptimeRootFunctions = std.AutoHashMap(EntryRoot, Ast.FnId); +// Immutable request ordinals, published before workers start. Function ids +// are assigned separately by the coordinator only when a declaration is used. +const DeclaredComptimeRoots = std.AutoHashMap(EntryRoot, u32); /// Checked module keys cross independent checking environments. Their local /// module indices do not; distinct checked modules routinely share an index. @@ -5258,6 +5289,8 @@ const SourceFileIds = std.AutoHashMap([32]u8, u32); /// lowering module id names a row of one program's own module table. const LoweringModuleIds = std.AutoHashMap([32]u8, Common.LoweringModuleId); +const CheckedOccurrence = struct { module: checked.ModuleId, ty: checked.CheckedTypeId }; + const Builder = struct { /// `appModuleIdentity` of this lowering's modules. app_module_identity: ?[32]u8, @@ -5267,6 +5300,7 @@ const Builder = struct { /// Checked module key -> position in the lowering input; built lazily on /// first lookup (see `buildModuleIndex`). module_index: std.AutoHashMap([32]u8, ModuleIndexSlot), + module_identity_index: std.AutoHashMapUnmanaged([32]u8, u32) = .empty, /// Every input module's view: the root, then imports, then relation /// modules. A worker's builder borrows its coordinator's views instead, /// so a view it hands back stays valid for the whole run. @@ -5355,6 +5389,10 @@ const Builder = struct { /// Top-level statements of each top module whose types a literal value /// identity named, by module key. literal_top_statements: std.AutoHashMapUnmanaged([32]u8, collections.DenseMap(can.CIR.Statement.Idx, void)) = .empty, + /// Current published representatives of canonical closed checked types. + /// Views are immutable for this lowering; a missing key stays missing. + persistent_closed_occurrences: std.AutoHashMapUnmanaged([32]u8, ?CheckedOccurrence) = .empty, + /// Compile-time roots by module and site, for the modules whose roots a /// stored literal value's reads were looked up in. literal_read_sites: std.AutoHashMapUnmanaged(LiteralReadSite, u32) = .empty, @@ -5364,9 +5402,10 @@ const Builder = struct { /// module. literal_store_view: ?*ModuleViewData = null, replay_interface_summaries: base.TestSwitch(true), - declared_comptime_root_functions: DeclaredComptimeRootFunctions, - borrowed_comptime_root_functions: ?*const DeclaredComptimeRootFunctions = null, + declared_comptime_roots: DeclaredComptimeRoots, + borrowed_comptime_roots: ?*const DeclaredComptimeRoots = null, demand_driven_comptime_roots: bool, + validated_comptime_roots: ?*const std.AutoHashMapUnmanaged(EntryRoot, void), comptime_demand_out: ?*ComptimeDemand, spec_comptime_reads_out: ?*SpecComptimeReads, spec_key_aliases_out: ?*SpecKeyAliases, @@ -5403,6 +5442,7 @@ const Builder = struct { symbols: SymbolDomains = .{}, next_instantiation_scope: u64 = 0, type_cache: CheckedTypeMemo(Type.TypeId) = .empty, + const_type_cache: ConstTypeMemo = .empty, /// Deterministic FIFO of reserved specializations awaiting body lowering. /// Wave drains execute entries in dispatch order; an executing body may /// append new entries, which the same drain then reaches. @@ -5671,8 +5711,9 @@ const Builder = struct { .literal_values = if (options.literal_roots) options.literal_values else null, .verify_literal_values = options.verify_literal_values, .replay_interface_summaries = options.replay_interface_summaries, - .declared_comptime_root_functions = DeclaredComptimeRootFunctions.init(allocator), + .declared_comptime_roots = DeclaredComptimeRoots.init(allocator), .demand_driven_comptime_roots = options.demand_driven_comptime_roots, + .validated_comptime_roots = options.validated_comptime_roots, .comptime_demand_out = options.comptime_demand_out, .spec_comptime_reads_out = options.spec_comptime_reads_out, .spec_key_aliases_out = options.spec_key_aliases_out, @@ -5758,6 +5799,7 @@ const Builder = struct { /// tables remain immutable until all workers have finished this lowering /// run, so a worker's rows name the same owners the coordinator's do. fn seedProgramModuleTables(self: *Builder) Allocator.Error!void { + try self.seedModuleIdentityIndex(); std.debug.assert(self.borrowed_source_file_ids == null); std.debug.assert(self.borrowed_lowering_module_ids == null); std.debug.assert(self.source_file_ids.count() == 0); @@ -5836,8 +5878,9 @@ const Builder = struct { errdefer builder.deinit(); builder.borrowed_source_file_ids = inputs.source_file_ids; builder.borrowed_module_views = inputs.module_views; + try builder.seedModuleIdentityIndex(); builder.borrowed_lowering_module_ids = inputs.lowering_module_ids; - builder.borrowed_comptime_root_functions = inputs.declared_comptime_root_functions; + builder.borrowed_comptime_roots = inputs.declared_comptime_roots; builder.hosted_catalog = try worker.allocator.dupe(HostedCatalogEntry, inputs.hosted_catalog); builder.current_loc = inputs.current_loc; builder.current_region = inputs.current_region; @@ -5922,7 +5965,7 @@ const Builder = struct { self.nominal_parameter_value_masks.deinit(self.allocator); self.source_file_ids.deinit(); self.lowering_module_ids.deinit(); - self.declared_comptime_root_functions.deinit(); + self.declared_comptime_roots.deinit(); self.declared_roots.deinit(self.allocator); self.parked_root_jobs.deinit(self.allocator); self.deferred_roots.deinit(self.allocator); @@ -5935,10 +5978,12 @@ const Builder = struct { var top_statements = self.literal_top_statements.valueIterator(); while (top_statements.next()) |statements| statements.deinit(); self.literal_top_statements.deinit(self.allocator); + self.persistent_closed_occurrences.deinit(self.allocator); self.literal_read_sites.deinit(self.allocator); self.literal_read_site_modules.deinit(self.allocator); if (self.literal_store_view) |view| self.allocator.destroy(view); self.module_index.deinit(); + self.module_identity_index.deinit(self.allocator); self.spec_job_task_buffers.deinit(self.allocator); self.unshared_summary_domains.deinit(self.allocator); if (self.spec_job_commit_domain) |*domain| domain.deinit(); @@ -5976,6 +6021,7 @@ const Builder = struct { self.discovery_callees.deinit(self.allocator); } self.type_cache.deinit(self.allocator); + self.const_type_cache.deinit(self.allocator); self.const_evidence_memo.deinit(); self.const_evidence_by_content.deinit(); self.site_evidence_memo.deinit(); @@ -6195,7 +6241,7 @@ const Builder = struct { const scope = self.moduleForKeyBytes(address.family.method_scope) orelse return null; const scope_context = scope.compile_time_root_identities.valueContext() orelse return null; const program_context = (try self.programValueContext(false)) orelse return null; - var hash = std.crypto.hash.sha2.Sha256.init(.{}); + var hash = base.Sha256.init(.{}); hash.update("roc.interface-provider.v2"); hash.update(&(callee.code_generation_key orelse callee.key).bytes); hash.update(&scope_context); @@ -6279,7 +6325,7 @@ const Builder = struct { }; std.mem.sort([32]u8, dependencies.items, {}, Order.dependency); std.mem.sort([64]u8, requirements.items, {}, Order.requirement); - var hash = std.crypto.hash.sha2.Sha256.init(.{}); + var hash = base.Sha256.init(.{}); hash.update("roc.interface-dependencies.v1"); var counts: [8]u8 = undefined; std.mem.writeInt(u32, counts[0..4], @intCast(dependencies.items.len), .little); @@ -6678,9 +6724,9 @@ const Builder = struct { /// What the lowered program demands of compile-time evaluation, and the /// program's roots in their final order: the compile-time roots it /// demands, in request order, and then every other root. A declared root - /// whose body nothing demanded keeps its reserved declaration, whose body - /// crashes; nothing calls it, it is not a root, and reachability drops - /// it from every consumer. + /// which was never read or validated has no function. A root whose signature + /// was prepared but whose body nothing demanded retains its parked crash + /// body; it is not a root, and reachability drops it from every consumer. fn orderComptimeRoots(self: *Builder) Allocator.Error!void { if (!self.comptime_value_reads) return; try self.settleLiteralRoots(); @@ -6697,7 +6743,7 @@ const Builder = struct { if (self.demand_driven_comptime_roots) { // Only demand lowers a declared root's body, so the roots // whose bodies lowered are exactly the demanded ones. - const parked = self.parked_root_jobs.contains(root.fn_id); + const parked = if (root.fn_id) |id| self.parked_root_jobs.contains(id) else true; if (!state.demanded.items[index]) { if (!parked) Common.invariant("compile-time root body was lowered without demand"); continue; @@ -6705,18 +6751,19 @@ const Builder = struct { if (parked) Common.invariant("demanded compile-time root body was never lowered"); } const start = reads.items.len; - for (try state.rootReads(self.allocator, view, ComptimeDemandState.defItem(root.def))) |read| try reads.append(self.allocator, state.roots.items[read]); + for (try state.rootReads(self.allocator, view, ComptimeDemandState.defItem(root.def.?))) |read| try reads.append(self.allocator, state.roots.items[read]); try root_reads.append(self.allocator, .{ .start = @intCast(start), .len = @intCast(reads.items.len - start) }); if (self.demand_driven_comptime_roots) { - try self.appendRuntimeSchemaRequestsForDef(root.def); - try self.program.addRoot(.{ .def = root.def, .request = root.request, .owner = self.loweringModuleId(root.source_module) }); + try self.appendRuntimeSchemaRequestsForDef(root.def.?); + try self.program.addRoot(.{ .def = root.def.?, .request = root.request, .owner = self.loweringModuleId(root.source_module) }); } } if (self.demand_driven_comptime_roots) { for (self.deferred_roots.items) |root| try self.program.addRoot(root); self.deferred_roots.clearRetainingCapacity(); for (self.declared_roots.items) |root| { - const parked = self.parked_root_jobs.fetchRemove(root.fn_id) orelse continue; + const id = root.fn_id orelse continue; + const parked = self.parked_root_jobs.fetchRemove(id) orelse continue; try self.finishUndemandedRoot(parked.value); } if (self.parked_root_jobs.count() != 0) Common.invariant("parked compile-time root body belonged to no declared root"); @@ -7333,6 +7380,7 @@ const Builder = struct { defer demanded_later_fn_tys.deinit(self.allocator); var requests = all_requests; var source_modules = all_source_modules; + var selected_discovery_start = discovery_start; // A template root's function type is a function of its checked type // alone. Compile-time roots reach `lowerTemplate` twice, so each // request lowers its type once, and on the lanes when there are any. @@ -7351,42 +7399,32 @@ const Builder = struct { Common.invariant("shared compile-time request lacked its checked root identity"); const source_module = self.rootSourceModule(all_source_modules, i); const source_view = self.moduleForId(source_module); - const template = request.procedure_template orelse - Common.invariant("shared compile-time root lacked its declared entry template"); - // A demand-driven root's queued body is parked until the - // program demands it (see `advanceComptimeDemand`). - self.parking_root_job = self.demand_driven_comptime_roots; - self.parked_root_job = null; - const def = try self.lowerTemplate(template, source_view, request.checked_type, request.root_evidence, &fn_tys[i]); - self.parking_root_job = false; const key = EntryRoot{ .module = source_module, .root = root_id }; - const entry = try self.declared_comptime_root_functions.getOrPut(key); - const fn_id = self.defFnId(def); - if (entry.found_existing and entry.value_ptr.* != fn_id) { - Common.invariant("checked compile-time root reserved different functions for one declared root"); - } - entry.value_ptr.* = fn_id; + const ordinal: u32 = @intCast(self.declared_roots.items.len); + try self.declared_comptime_roots.put(key, ordinal); try self.declared_roots.append(self.allocator, .{ .request = request, .source_module = source_module, - .def = def, - .fn_id = fn_id, - .validation = source_view.compile_time_roots.root(root_id).kind == .hoisted_validation, + .validation = if (self.validated_comptime_roots) |validated| + !validated.contains(key) + else + source_view.compile_time_roots.root(root_id).kind == .hoisted_validation, }); - if (self.parked_root_job) |job| { - try self.parked_root_jobs.put(self.allocator, fn_id, job); - self.parked_root_job = null; - } + if (!self.demand_driven_comptime_roots) _ = try self.prepareDeclaredRoot(ordinal, &fn_tys[i]); } if (self.demand_driven_comptime_roots) { // Declared compile-time roots become program roots once // demand is known (`orderComptimeRoots`); everything else - // lowers now. Only a program without an LSS runtime consumer - // has discovery roots, and only one with it is demand-driven, - // so the filtered positions need no discovery mapping. - if (discovery_start != null) Common.invariant("demand-driven compile-time roots received discovery roots"); + // lowers now. The discovery boundary is a position in the + // original request list, so translate it through the same + // filtering as the requests. An application can have both + // runtime entrypoints and discovery-only test roots. + if (discovery_start != null) selected_discovery_start = 0; for (all_requests, 0..) |request, i| { if (request.abi == .compile_time) continue; + if (discovery_start) |start| if (i < start) { + selected_discovery_start.? += 1; + }; try demanded_later_requests.append(self.allocator, request); try demanded_later_sources.append(self.allocator, self.rootSourceModule(all_source_modules, i)); try demanded_later_fn_tys.append(self.allocator, fn_tys[i]); @@ -7398,25 +7436,25 @@ const Builder = struct { } defer self.root_demand = .evaluation; const executor = self.post_check_executor orelse { - for (requests, 0..) |request, i| try self.lowerRootWithDemand(request, self.rootSourceModule(source_modules, i), rootDemand(discovery_start, i), &root_fn_tys[i]); + for (requests, 0..) |request, i| try self.lowerRootWithDemand(request, self.rootSourceModule(source_modules, i), rootDemand(selected_discovery_start, i), &root_fn_tys[i]); return; }; if (executor.worker_count <= 1) { - for (requests, 0..) |request, i| try self.lowerRootWithDemand(request, self.rootSourceModule(source_modules, i), rootDemand(discovery_start, i), &root_fn_tys[i]); + for (requests, 0..) |request, i| try self.lowerRootWithDemand(request, self.rootSourceModule(source_modules, i), rootDemand(selected_discovery_start, i), &root_fn_tys[i]); return; } var index: usize = 0; while (index < requests.len) { if (requests[index].procedure_use == null) { - try self.lowerRootWithDemand(requests[index], self.rootSourceModule(source_modules, index), rootDemand(discovery_start, index), &root_fn_tys[index]); + try self.lowerRootWithDemand(requests[index], self.rootSourceModule(source_modules, index), rootDemand(selected_discovery_start, index), &root_fn_tys[index]); index += 1; continue; } const start = index; - const demand = rootDemand(discovery_start, index); + const demand = rootDemand(selected_discovery_start, index); while (index < requests.len and requests[index].procedure_use != null and - rootDemand(discovery_start, index) == demand and + rootDemand(selected_discovery_start, index) == demand and index - start < executor.worker_count) : (index += 1) {} self.root_demand = demand; @@ -7440,7 +7478,7 @@ const Builder = struct { /// instead, and a binding root through its binding. fn rootLowersTemplate(self: *const Builder, request: checked.RootRequest) bool { if (request.procedure_template == null) return false; - if (self.comptime_value_reads and request.abi == .compile_time) return true; + if (self.comptime_value_reads and request.abi == .compile_time) return !self.demand_driven_comptime_roots; return request.procedure_binding == null and request.procedure_use == null; } @@ -7485,7 +7523,7 @@ const Builder = struct { .static_data_literals = self.static_data_literals, .comptime_value_reads = self.comptime_value_reads, .literal_roots = self.literal_roots, - .declared_comptime_root_functions = self.borrowed_comptime_root_functions orelse &self.declared_comptime_root_functions, + .declared_comptime_roots = self.borrowed_comptime_roots orelse &self.declared_comptime_roots, .hosted_catalog = self.hosted_catalog, .current_loc = self.current_loc, .current_region = self.current_region, @@ -7698,7 +7736,7 @@ const Builder = struct { .comptime_value_reads = self.comptime_value_reads, .literal_roots = self.literal_roots, .replay_interface_summaries = self.replay_interface_summaries, - .declared_comptime_root_functions = self.borrowed_comptime_root_functions orelse &self.declared_comptime_root_functions, + .declared_comptime_roots = self.borrowed_comptime_roots orelse &self.declared_comptime_roots, .hosted_catalog = self.hosted_catalog, .current_loc = self.current_loc, .current_region = self.current_region, @@ -7790,8 +7828,10 @@ const Builder = struct { }; if (root_id) |root| { if (self.comptimeValueReadDeclared(view, root)) { - const owners = self.borrowed_comptime_root_functions orelse &self.declared_comptime_root_functions; - const fn_id = owners.get(.{ .module = view.key, .root = root }).?; + const owners = self.borrowed_comptime_roots orelse &self.declared_comptime_roots; + const ordinal = owners.get(.{ .module = view.key, .root = root }).?; + var fn_ty: ?Type.TypeId = null; + const fn_id = try self.prepareDeclaredRoot(ordinal, &fn_ty); const initializer = try self.program.addExpr(.{ .ty = ret_ty, .data = .{ .call_proc = .{ .callee = Ast.localProcCallee(fn_id), .args = .empty() } }, @@ -9363,6 +9403,43 @@ const Builder = struct { self.next_spec_dispatch_index += 1; } + /// Prepare the signature and reserve a body only for an explicitly read + /// or validated root. Request ordinals stay immutable across worker epochs. + fn prepareDeclaredRoot(self: *Builder, ordinal: u32, fn_ty: *?Type.TypeId) Allocator.Error!Ast.FnId { + if (self.borrowed_comptime_roots != null) Common.invariant("worker attempted to reserve a declared root"); + const root = self.declared_roots.items[ordinal]; + if (root.fn_id) |id| return id; + const source_view = self.moduleForId(root.source_module); + const template = root.request.procedure_template orelse + Common.invariant("shared compile-time root lacked its checked entry template"); + const saved_parking = self.parking_root_job; + const saved_job = self.parked_root_job; + defer { + self.parking_root_job = saved_parking; + self.parked_root_job = saved_job; + } + self.parking_root_job = self.demand_driven_comptime_roots; + self.parked_root_job = null; + const def = try self.lowerTemplate(template, source_view, root.request.checked_type, root.request.root_evidence, fn_ty); + const fn_id = self.defFnId(def); + self.declared_roots.items[ordinal].def = def; + self.declared_roots.items[ordinal].fn_id = fn_id; + if (self.parked_root_job) |job| try self.parked_root_jobs.put(self.allocator, fn_id, job); + return fn_id; + } + + /// Drafts name checked declaration ordinals. Resolve those explicit intents + /// before final ids are assigned; no worker mutates the declaration table. + fn resolveDeclaredRootCalls(self: *Builder, draft: *BodyDraftStore) Allocator.Error!void { + for (0..draft.exprs.items.len) |index| { + const data = draft.exprs.items[index].data; + if (data != .call_proc or data.call_proc.callee != .declared_root) continue; + var fn_ty: ?Type.TypeId = null; + const id = try self.prepareDeclaredRoot(data.call_proc.callee.declared_root, &fn_ty); + draft.exprs.items[index].data.call_proc.callee = .{ .func = .{ .local = .{ .final = id } } }; + } + } + /// Move a parked compile-time root body into the specialization queue, /// under the next dispatch index. fn unparkRootJob(self: *Builder, fn_id: Ast.FnId) Allocator.Error!void { @@ -9380,10 +9457,12 @@ const Builder = struct { if (!self.demand_driven_comptime_roots) return false; try self.settleLiteralRoots(); const state = &self.comptime_demand; - const view = self.program.view(); + var view = self.program.view(); try state.index(self.allocator, self, view); var starts = std.ArrayList(u32).empty; defer starts.deinit(self.allocator); + try starts.appendSlice(self.allocator, state.changed_reached.items); + state.changed_reached.clearRetainingCapacity(); while (state.walked_deferred_roots < self.deferred_roots.items.len) : (state.walked_deferred_roots += 1) { try starts.append(self.allocator, ComptimeDemandState.defItem(self.deferred_roots.items[state.walked_deferred_roots].def)); } @@ -9395,7 +9474,7 @@ const Builder = struct { try starts.append(self.allocator, ComptimeDemandState.defItem(view.literal_roots[state.walked_literal_roots].def)); } // The drain that just finished lowered every body that was queued. - for (state.awaiting_bodies.items) |declared| try starts.append(self.allocator, ComptimeDemandState.defItem(self.declared_roots.items[declared].def)); + for (state.awaiting_bodies.items) |declared| try starts.append(self.allocator, ComptimeDemandState.defItem(self.declared_roots.items[declared].def.?)); state.awaiting_bodies.clearRetainingCapacity(); var newly_demanded = std.ArrayList(u32).empty; defer newly_demanded.deinit(self.allocator); @@ -9414,13 +9493,17 @@ const Builder = struct { } defer newly_demanded.clearRetainingCapacity(); for (newly_demanded.items) |declared| { + var fn_ty: ?Type.TypeId = null; + const fn_id = try self.prepareDeclaredRoot(declared, &fn_ty); + view = self.program.view(); + try state.index(self.allocator, self, view); const root = self.declared_roots.items[declared]; - if (self.parked_root_jobs.contains(root.fn_id)) { - try self.unparkRootJob(root.fn_id); + if (self.parked_root_jobs.contains(fn_id)) { + try self.unparkRootJob(fn_id); try state.awaiting_bodies.append(self.allocator, declared); } else { // Another root sharing its reserved body already lowered it. - try starts.append(self.allocator, ComptimeDemandState.defItem(root.def)); + try starts.append(self.allocator, ComptimeDemandState.defItem(root.def.?)); } } } @@ -9837,7 +9920,7 @@ const Builder = struct { .comptime_value_reads = self.comptime_value_reads, .literal_roots = self.literal_roots, .replay_interface_summaries = self.replay_interface_summaries, - .declared_comptime_root_functions = self.borrowed_comptime_root_functions orelse &self.declared_comptime_root_functions, + .declared_comptime_roots = self.borrowed_comptime_roots orelse &self.declared_comptime_roots, .hosted_catalog = self.hosted_catalog, .current_loc = self.current_loc, .current_region = self.current_region, @@ -11231,6 +11314,7 @@ const Builder = struct { const entry = self.lowered_templates.get(fn_id) orelse Common.invariant("lowered procedure template definition disappeared before completion"); try self.spec_store.markReady(entry.spec, fn_ty, self.specializationTypeDigest(fn_ty)); + try self.comptime_demand.bodyPublished(self.allocator, fn_id); } fn typedLocalsForArgs(self: *Builder, arg_tys: anytype) Allocator.Error!Ast.Span(Ast.TypedLocal) { @@ -11764,7 +11848,7 @@ const Builder = struct { const def = try typeDef(self.activeNameStore(), self.app_module_identity, view, nominal.origin_module, nominal.name, nominal.source_decl); self.noteBuiltinTryDef(nominal.builtin, self.activeNameStore(), def); const named: @FieldType(Type.Content, "named") = .{ - .named_type = .{ .module = self.declaredModuleForNominal(view, nominal), .ty = frame.checked_ty, .ty_module = moduleDigestFromId(view.key) }, + .named_type = try self.canonicalNamedOccurrence(view, frame.checked_ty, self.declaredModuleForNominal(view, nominal)), .def = def, .kind = if (nominal.is_opaque) .@"opaque" else .nominal, .builtin_owner = builtinOwner(nominal.builtin), @@ -12148,6 +12232,17 @@ const Builder = struct { return view.templates.templateHasSingleSourceCall(checked_template.template); } + /// Closed checked occurrences have no instantiation identity. Give both + /// freshly lowered and replayed named types the same exact current witness. + /// Open occurrences retain their source cells and are never coalesced here. + fn canonicalNamedOccurrence(self: *Builder, view: ModuleView, ty: checked.CheckedTypeId, owner: names.CheckedModuleDigest) Allocator.Error!Type.NamedType { + const root = view.types.rootRows()[@backingInt(ty)]; + if (root.contains_identity_variables) return .{ .module = owner, .ty = ty, .ty_module = moduleDigestFromId(view.key) }; + const provenance = BodyContext.PersistentProvenance{ .builder = self }; + return (try provenance.resolveNamedType(.{ .closed = .{ .owner = owner.bytes, .key = root.key.bytes } })) orelse + Common.invariant("a lowered closed named type has no current canonical occurrence"); + } + fn moduleForDigest(self: *Builder, module_digest: names.CheckedModuleDigest) ModuleView { return self.moduleForKeyBytes(module_digest.bytes) orelse Common.invariant("procedure template referenced a checked module that is not in the lowering input"); @@ -12223,17 +12318,17 @@ const Builder = struct { /// (used to resolve a defaulted field's declaring module—design.md /// "Defaulted Fields"). fn moduleForIdentityHash(self: *Builder, origin_hash: *const [32]u8) ?ModuleView { - const root = self.rootModuleView(); - if (moduleViewIdentityMatches(root, origin_hash)) return root; - for (0..self.modules.imports.len) |index| { - const view = self.importModuleView(index); - if (moduleViewIdentityMatches(view, origin_hash)) return view; - } - for (0..self.modules.root.relation_modules.len) |index| { - const view = self.relationModuleView(index); - if (moduleViewIdentityMatches(view, origin_hash)) return view; + const index = self.module_identity_index.get(origin_hash.*) orelse return null; + return &self.moduleViews()[index]; + } + + fn seedModuleIdentityIndex(self: *Builder) Allocator.Error!void { + const views = self.moduleViews(); + try self.module_identity_index.ensureTotalCapacity(self.allocator, @intCast(views.len)); + for (views, 0..) |view, index| { + const entry = self.module_identity_index.getOrPutAssumeCapacity(view.module_identity.stable_hash); + if (!entry.found_existing) entry.value_ptr.* = @intCast(index); } - return null; } fn templateSpecIdentity( @@ -12477,7 +12572,7 @@ const Builder = struct { /// on unbound platform requirements from that manifest. fn comptimeValueReadDeclared(self: *Builder, view: ModuleView, root_id: checked.ComptimeRootId) bool { if (!self.comptime_value_reads) return false; - const declarations = self.borrowed_comptime_root_functions orelse &self.declared_comptime_root_functions; + const declarations = self.borrowed_comptime_roots orelse &self.declared_comptime_roots; return declarations.contains(.{ .module = view.key, .root = root_id }); } @@ -15653,6 +15748,7 @@ const Builder = struct { } try body_draft.finishOwnerRuns(); try self.resolveDeferredTemplateSpecs(body_draft, &committed_types); + try self.resolveDeclaredRootCalls(body_draft); var commit_map = try self.buildDraftCommitMap( body_draft, &committed_types, @@ -15718,6 +15814,7 @@ const Builder = struct { const sealed_roots = try self.allocator.alloc(Type.TypeId, 0); errdefer self.allocator.free(sealed_roots); try self.resolveDeferredTemplateSpecs(body_draft, committed_types); + try self.resolveDeclaredRootCalls(body_draft); var commit_map = try self.buildDraftCommitMap( body_draft, committed_types, @@ -16923,6 +17020,7 @@ const DirectCallRequest = struct { }; const DraftProcCallee = union(enum(u8)) { + declared_root: u32, func: DraftFnSlot, lifted: Ast.LiftedFnId, }; @@ -21590,7 +21688,7 @@ const FinalIdOffsets = struct { .final => false, }, }, - .lifted => false, + .lifted, .declared_root => false, }; } @@ -21603,6 +21701,7 @@ const FinalIdOffsets = struct { }, } }, .lifted => |lifted| .{ .lifted = lifted }, + .declared_root => Common.invariant("unresolved declared root reached Monotype emission"), }; } @@ -27199,7 +27298,7 @@ const BodyContext = struct { const nominal = task.nominal; task.def = try typeDef(self.nameStoreMut(), self.builder.app_module_identity, self.view, nominal.origin_module, nominal.name, nominal.source_decl); self.builder.noteBuiltinTryDef(nominal.builtin, self.nameStore(), task.def); - task.named_type = .{ .module = self.builder.declaredModuleForNominal(self.view, nominal), .ty = task.checked_ty, .ty_module = moduleDigestFromId(self.view.key) }; + task.named_type = try self.builder.canonicalNamedOccurrence(self.view, task.checked_ty, self.builder.declaredModuleForNominal(self.view, nominal)); frame.cursor = InstNominalCursor.declared_order; return .{ .call = .{ .declared_order = try self.boxInstTask(InstDeclaredOrderTask, .{ .nominal = nominal, .args = task.args }) } }; } @@ -27986,9 +28085,51 @@ const BodyContext = struct { pub fn validModuleIdentity(self: PersistentProvenance, identity: [32]u8) bool { return self.builder.moduleForIdentityHash(&identity) != null; } + pub fn describeNamedType(self: PersistentProvenance, named: Type.NamedType) PersistentInterface.NamedOccurrence { + const view = self.builder.moduleForKeyBytes(named.ty_module.bytes) orelse + Common.invariant("persistent named occurrence has no checked artifact"); + const root = view.types.rootRows()[@backingInt(named.ty)]; + if (!root.contains_identity_variables) return .{ .closed = .{ .owner = named.module.bytes, .key = root.key.bytes } }; + return .{ .exact = .{ .owner = named.module.bytes, .occurrence = named.ty_module.bytes, .ty = @backingInt(named.ty) } }; + } + pub fn resolveNamedType(self: PersistentProvenance, stored: PersistentInterface.NamedOccurrence) Allocator.Error!?Type.NamedType { + return switch (stored) { + .exact => |exact| .{ .module = .{ .bytes = exact.owner }, .ty_module = .{ .bytes = exact.occurrence }, .ty = @fromBackingInt(exact.ty) }, + .closed => |closed| blk: { + const entry = try self.builder.persistent_closed_occurrences.getOrPut(self.builder.allocator, closed.key); + if (!entry.found_existing) { + var representative: ?CheckedOccurrence = null; + for (self.builder.moduleViews()) |view| { + const root = view.types.rootForKey(.{ .bytes = closed.key }) orelse continue; + if (view.types.rootRows()[@backingInt(root)].contains_identity_variables) continue; + if (representative) |previous| { + if (std.mem.order(u8, &previous.module.bytes, &view.key.bytes) != .gt) continue; + } + representative = .{ .module = view.key, .ty = root }; + } + entry.value_ptr.* = representative; + } + const occurrence = entry.value_ptr.* orelse break :blk null; + const view = self.builder.moduleForKeyBytes(occurrence.module.bytes).?; + const owner = switch (view.types.payload(occurrence.ty)) { + .nominal => |nominal| nominal.owner_module, + .alias => |alias| alias.owner_module, + else => break :blk null, + }; + if (!std.mem.eql(u8, &owner.bytes, &closed.owner)) break :blk null; + break :blk .{ .module = .{ .bytes = closed.owner }, .ty_module = occurrence.module, .ty = occurrence.ty }; + }, + }; + } pub fn validNamedType(self: PersistentProvenance, named: Type.NamedType) bool { - if (self.builder.moduleForKeyBytes(named.module.bytes) == null) return false; - const occurrence = self.builder.moduleForKeyBytes(named.ty_module.bytes) orelse return false; + if (self.builder.moduleForKeyBytes(named.module.bytes) == null) { + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("interface invalid named owner={x}\n", .{named.module.bytes}); + return false; + } + const occurrence = self.builder.moduleForKeyBytes(named.ty_module.bytes) orelse { + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("interface invalid occurrence={x} type={d}\n", .{ named.ty_module.bytes, @backingInt(named.ty) }); + return false; + }; return @backingInt(named.ty) < occurrence.types.payloadCount(); } }; @@ -28942,6 +29083,15 @@ const BodyContext = struct { tag: struct { checked_name: names.TagNameId, produced_name: names.TagNameId, checked_len: usize, produced_len: usize }, }; + /// Completed acyclic witnesses are reusable during one relation traversal + /// only while the graph has not changed. Active-path assumptions are never + /// published as completed witnesses. + const ProducedValueMemo = struct { + const Entry = struct { witness: NodeId, epoch: u64 }; + completed: std.AutoHashMapUnmanaged(ProducedValuePair, Entry) = .empty, + cycles: u64 = 0, + }; + /// A matching container whose children are still relating. const ProducedValueFrame = struct { checked_node: NodeId, @@ -28950,6 +29100,8 @@ const BodyContext = struct { /// related. produced_content: InstNode, pair: ProducedValuePair, + epoch: u64, + cycles: u64, ops_start: usize, next: usize, results_start: usize, @@ -28971,6 +29123,8 @@ const BodyContext = struct { root_produced: NodeId, visiting: *std.AutoHashMap(ProducedValuePair, void), ) Allocator.Error!NodeId { + var memo: ProducedValueMemo = .{}; + defer memo.completed.deinit(self.allocator); var frames: std.ArrayListUnmanaged(ProducedValueFrame) = .empty; defer frames.deinit(self.allocator); var ops: std.ArrayListUnmanaged(ProducedValueOp) = .empty; @@ -28981,7 +29135,7 @@ const BodyContext = struct { _ = visiting.remove(frame.pair); }; - var delivered: ?NodeId = try self.enterProducedValue(root_checked, root_produced, visiting, &frames, &ops, &results); + var delivered: ?NodeId = try self.enterProducedValue(root_checked, root_produced, visiting, &memo, &frames, &ops, &results); while (true) { if (delivered) |node| { if (frames.items.len == 0) return node; @@ -28995,7 +29149,7 @@ const BodyContext = struct { frames.items[frames.items.len - 1].next += 1; switch (op) { .child => |child| { - delivered = try self.enterProducedValue(child.checked, child.produced, visiting, &frames, &ops, &results); + delivered = try self.enterProducedValue(child.checked, child.produced, visiting, &memo, &frames, &ops, &results); continue; }, .field_name => |field| failed = field.checked != field.produced, @@ -29019,6 +29173,9 @@ const BodyContext = struct { const produced_root = self.graph.rootNode(frame.produced_node); delivered = witness orelse try self.relateUnmatchedProducedValue(frame.checked_node, frame.produced_node, checked_root, produced_root); _ = visiting.remove(frame.pair); + if (frame.epoch == self.graph.analysis_changes.epoch and frame.cycles == memo.cycles) { + try memo.completed.put(self.allocator, frame.pair, .{ .witness = delivered.?, .epoch = frame.epoch }); + } } } @@ -29029,6 +29186,7 @@ const BodyContext = struct { checked_node: NodeId, produced_node: NodeId, visiting: *std.AutoHashMap(ProducedValuePair, void), + memo: *ProducedValueMemo, frames: *std.ArrayListUnmanaged(ProducedValueFrame), ops: *std.ArrayListUnmanaged(ProducedValueOp), results: *std.ArrayListUnmanaged(NodeId), @@ -29039,8 +29197,16 @@ const BodyContext = struct { if (checked_root == produced_root) return checked_node; const pair = ProducedValuePair{ .request = checked_root, .produced = produced_root }; + if (memo.completed.get(pair)) |known| { + if (known.epoch == self.graph.analysis_changes.epoch) return known.witness; + } const entry = try visiting.getOrPut(pair); - if (entry.found_existing) return produced_node; + if (entry.found_existing) { + memo.cycles += 1; + return produced_node; + } + const epoch = self.graph.analysis_changes.epoch; + const cycles = memo.cycles; var pushed = false; defer if (!pushed) { _ = visiting.remove(pair); @@ -29163,6 +29329,8 @@ const BodyContext = struct { .produced_node = produced_node, .produced_content = (try self.graph.content(produced_root)), .pair = pair, + .epoch = epoch, + .cycles = cycles, .ops_start = ops_start, .next = ops_start, .results_start = results.items.len, @@ -37865,11 +38033,11 @@ const BodyContext = struct { cell: DraftTypeCell, const_locator: ?checked.ConstLocator, ) Allocator.Error!DraftExprId { - const owners = self.builder.borrowed_comptime_root_functions orelse &self.builder.declared_comptime_root_functions; - const fn_id = owners.get(.{ .module = view.key, .root = root_id }) orelse - Common.invariant("shared root read lacked a declared root function"); + const owners = self.builder.borrowed_comptime_roots orelse &self.builder.declared_comptime_roots; + const ordinal = owners.get(.{ .module = view.key, .root = root_id }) orelse + Common.invariant("shared root read lacked its checked declaration"); const initializer = try self.addExprWithTypeCell(cell, .{ .call_proc = .{ - .callee = .{ .func = .{ .local = .{ .final = fn_id } } }, + .callee = .{ .declared_root = ordinal }, .args = .empty(), } }); return try self.addExprWithTypeCell(cell, .{ .comptime_value = .{ @@ -47585,25 +47753,39 @@ const BodyContext = struct { return materialized; } + fn constCaptureTypeCache(self: *BodyContext) *ConstTypeMemo { + if (self.typeStore() == &self.builder.program.types) return &self.builder.const_type_cache; + const workspace = self.draft.spec_job_workspace orelse + Common.invariant("stored-type restoration has no owning destination workspace"); + std.debug.assert(self.typeStore() == &workspace.types); + return &workspace.const_type_cache; + } + fn lowerConstCaptureType( self: *BodyContext, store_view: ModuleView, ty: check.ConstStore.ConstTypeId, ) Allocator.Error!Type.TypeId { + const cache = self.constCaptureTypeCache(); + const source = &store_view.const_store.type_store; + if (cache.get(.{ .store = source, .ty = ty })) |existing| return existing; var map = collections.DenseMap(check.ConstStore.ConstTypeId, Type.TypeId).init(self.allocator); defer map.deinit(); const transaction = self.typeStore().beginTransaction(); errdefer transaction.abort(self.typeStore()); const speculative = try self.lowerConstCaptureTypeInner(store_view, ty, &map); + try cache.ensureUnusedCapacity(self.typeStore().allocator, @intCast(map.count())); var result = try self.typeStore().commitTransaction( self.nameStore(), transaction, speculative, ); defer result.deinit(); - var lowered_types = map.valueIterator(); - while (lowered_types.next()) |lowered| { - lowered.* = result.remapType(lowered.*); + // Only committed immutable destination ids enter the durable map. + // A failed transaction publishes no entries and cannot poison a retry. + var entries = map.iterator(); + while (entries.next()) |entry| { + cache.putAssumeCapacity(.{ .store = source, .ty = entry.key_ptr.* }, result.remapType(entry.value_ptr.*)); } return result.root; } @@ -47707,6 +47889,7 @@ const BodyContext = struct { if (map.get(ty)) |existing| return existing; const gpa = self.allocator; const type_store = &store_view.const_store.type_store; + if (self.constCaptureTypeCache().get(.{ .store = type_store, .ty = ty })) |existing| return existing; const stored = type_store.get(ty); const ops_start = ops.items.len; // Stored aliases retain the checked acyclic backing chain. Memoize @@ -54630,7 +54813,7 @@ const BodyContext = struct { if (variable.row_default) |row_default| break :blk row_default == .empty_tag_union; break :blk switch (variable.origin) { .checked_variable => true, - .row_extension, .placeholder => false, + .row_extension, .placeholder, .interface_parameter => false, }; }, .redirect, .leaf, .primitive, .list, .box, .tuple, .func, .tag_union, .record, .empty_tag_union, .empty_record, .named, .erased, .zst, .hole => false, @@ -72502,6 +72685,8 @@ test "issue 11453: stored aliases preserve sharing recursion and nominal backing var builder: Builder = undefined; builder.app_module_identity = null; builder.program = &program; + builder.const_type_cache = .empty; + defer builder.const_type_cache.deinit(gpa); var ctx: BodyContext = undefined; builder.task_storage = .{}; defer builder.task_storage.deinit(ctx.allocator); @@ -72527,6 +72712,21 @@ test "issue 11453: stored aliases preserve sharing recursion and nominal backing const recursive_items = program.types.span(program.types.get(restored_recursive).tuple); try std.testing.expectEqual(restored_recursive, GuardedList.at(recursive_items, 0)); try std.testing.expectEqual(@as(usize, 4), program.types.typeCount()); + // Repeated restoration of the complete graph and one of its children + // performs no traversal allocations, including through a transparent alias. + var failing = std.testing.FailingAllocator.init(gpa, .{ .fail_index = 0 }); + ctx.allocator = failing.allocator(); + try std.testing.expectEqual(result, try ctx.lowerConstCaptureType(view, root)); + try std.testing.expectEqual(restored_nominal, try ctx.lowerConstCaptureType(view, alias)); + ctx.allocator = gpa; + + // The same source id in another producer's store is a distinct key. + var other = check.ConstStore.ConstStore.init(gpa); + defer other.deinit(); + const other_ty = try other.type_store.append(.{ .primitive = .i64 }); + view.const_store = &other; + const other_result = try ctx.lowerConstCaptureType(view, other_ty); + try std.testing.expectEqual(Type.Primitive.i64, program.types.get(other_result).primitive); } test "issue 11737: independent call selects its own nested contract without copying" { @@ -72665,3 +72865,90 @@ test "issue 12616 completion watches canonical function arguments through old al try graph.unify(left, right); try fixture.expectRelation(.unchanged, request, produced); } + +test "produced witness relation visits shared container pairs once" { + var fixture: CompletionAnalysisFixture = undefined; + try fixture.init(std.testing.allocator); + defer fixture.deinit(); + const graph = fixture.graph; + const shared = try graph.newNode(.{ .unresolved = InstVariable.placeholder() }); + var request = shared; + var produced = shared; + for (0..12) |_| { + request = try graph.newNode(.{ .tuple = try graph.arena().dupe(NodeId, &.{ request, request }) }); + produced = try graph.newNode(.{ .tuple = try graph.arena().dupe(NodeId, &.{ produced, produced }) }); + } + const witness = try fixture.ctx.relateCheckedNodeToProducedValue(request, produced); + try std.testing.expectEqual(produced, witness); + try std.testing.expect(fixture.diagnostics.completion_queries <= 24); +} + +test "module identity index retains first current view and rejects absent owners" { + const allocator = std.testing.allocator; + // Only the explicit owner identity participates in this immutable index. + var views: [4]ModuleViewData = undefined; + for (&views, [_]u8{ 1, 2, 1, 3 }) |*view, byte| view.module_identity.stable_hash = @splat(byte); + var builder: Builder = undefined; + builder.allocator = allocator; + builder.borrowed_module_views = &views; + builder.module_identity_index = .empty; + defer builder.module_identity_index.deinit(allocator); + try builder.seedModuleIdentityIndex(); + try std.testing.expect(builder.moduleForIdentityHash(&@as([32]u8, @splat(1))).? == &views[0]); + try std.testing.expect(builder.moduleForIdentityHash(&@as([32]u8, @splat(2))).? == &views[1]); + try std.testing.expect(builder.moduleForIdentityHash(&@as([32]u8, @splat(3))).? == &views[3]); + try std.testing.expect(builder.moduleForIdentityHash(&@as([32]u8, @splat(4))) == null); +} + +test "fresh closed named occurrences agree with persistent replay and preserve open identities" { + const a = std.testing.allocator; + var first = checked.CheckedTypeStore{}; + defer first.deinit(a); + var second = checked.CheckedTypeStore{}; + defer second.deinit(a); + const owner: names.CheckedModuleDigest = .{ .bytes = @splat(42) }; + const key: [32]u8 = @splat(73); + const Fixture = struct { + fn add(store: *checked.CheckedTypeStore, identity: [32]u8, open: bool) !checked.CheckedTypeId { + const backing = try store.reserveSyntheticTypeRoot(std.testing.allocator, .{ .bytes = @splat(3) }, false); + try store.fillSyntheticTypeRoot(std.testing.allocator, backing, .empty_record); + const id = try store.reserveSyntheticTypeRoot(std.testing.allocator, .{ .bytes = identity }, open); + try store.fillSyntheticTypeRoot(std.testing.allocator, id, .{ .alias = .{ + .name = @fromBackingInt(@as(u32, 0)), + .origin_module = @fromBackingInt(@as(u32, 0)), + .owner_module = .{ .bytes = @splat(42) }, + .backing = backing, + } }); + return id; + } + }; + const first_ty = try Fixture.add(&first, key, false); + _ = try Fixture.add(&second, @splat(74), false); + const second_ty = try Fixture.add(&second, key, false); + const open_ty = try Fixture.add(&first, @splat(75), true); + var views: [2]ModuleViewData = undefined; + views[0].key = .{ .bytes = @splat(20) }; + views[0].types = first.view(); + views[1].key = .{ .bytes = @splat(10) }; + views[1].types = second.view(); + var builder: Builder = undefined; + builder.allocator = a; + builder.borrowed_module_views = &views; + builder.persistent_closed_occurrences = .empty; + defer builder.persistent_closed_occurrences.deinit(a); + builder.module_index = @TypeOf(builder.module_index).init(a); + defer builder.module_index.deinit(); + try builder.module_index.put(views[0].key.bytes, .root); + try builder.module_index.put(views[1].key.bytes, .{ .import = 0 }); + const left = try builder.canonicalNamedOccurrence(&views[0], first_ty, owner); + const right = try builder.canonicalNamedOccurrence(&views[1], second_ty, owner); + const provenance = BodyContext.PersistentProvenance{ .builder = &builder }; + const restored = (try provenance.resolveNamedType(.{ .closed = .{ .owner = owner.bytes, .key = key } })).?; + try std.testing.expectEqualDeep(left, right); + try std.testing.expectEqualDeep(left, restored); + try std.testing.expectEqual(second_ty, left.ty); + try std.testing.expectEqualSlices(u8, &views[1].key.bytes, &left.ty_module.bytes); + const open = try builder.canonicalNamedOccurrence(&views[0], open_ty, owner); + try std.testing.expectEqual(open_ty, open.ty); + try std.testing.expectEqualSlices(u8, &views[0].key.bytes, &open.ty_module.bytes); +} diff --git a/src/postcheck/monotype/solve.zig b/src/postcheck/monotype/solve.zig index 4dc7af238dd..8f1c900b18c 100644 --- a/src/postcheck/monotype/solve.zig +++ b/src/postcheck/monotype/solve.zig @@ -133,6 +133,9 @@ pub const InstVariableOrigin = enum(u8) { checked_variable, row_extension, placeholder, + /// An explicit parameter of a detached interface relation, filled when + /// that relation is joined to its requesting graph. Never defaulted. + interface_parameter, }; /// Defaulting evidence carried by an unresolved instantiation-graph node until @@ -160,6 +163,10 @@ pub const InstVariable = struct { }; } + pub fn interfaceParameter() InstVariable { + return .{ .origin = .interface_parameter }; + } + pub fn placeholder() InstVariable { return .{ .origin = .placeholder }; } @@ -1172,6 +1179,12 @@ pub const InterfaceConstraints = struct { // digest, so requests that differ only in the checked occurrence // through which a named type was reached share one interface. if (T == Type.NamedType) return self.write(names.CheckedModuleDigest, value.module); + if (T == @FieldType(InstNode, "tag_union")) { + // The actual ordered tags and their extension are constraints; + // the producer's memo that this order is sorted is not. + try self.write(@TypeOf(value.tags), value.tags); + return self.write(NodeId, value.ext); + } if (T == Type.TypeId) { try self.leaves.append(self.graph.allocator, value); const digest = self.graph.types.representationDigestCached(ns, value, null); @@ -1397,7 +1410,7 @@ pub const InterfaceConstraints = struct { self.hole_classes[hole] = root; const open_index: u32 = @intCast(self.open_nodes.items.len); self.nodes.items[@backingInt(id)] = .{ .open = open_index }; - try self.open_nodes.append(self.graph.allocator, .{ .content = .{ .unresolved = InstVariable.placeholder() } }); + try self.open_nodes.append(self.graph.allocator, .{ .content = .{ .unresolved = InstVariable.interfaceParameter() } }); return null; } } @@ -3724,7 +3737,7 @@ pub const InstGraph = struct { .redirect, .leaf => unreachable, // callers pass a find root, and rootContent of a find root expands a leaf, so it returns structure .unresolved => |variable| switch (variable.origin) { .checked_variable, .row_extension => .{ .fixed = 0 }, - .placeholder => Common.invariant("placeholder reached generated iterator representation finalization"), + .placeholder, .interface_parameter => Common.invariant("placeholder reached generated iterator representation finalization"), }, .primitive, .empty_tag_union, .empty_record, .erased, .zst, .hole, .func => .{ .fixed = 0 }, .list, .box => .{ .children = .{ .count = 1, .increment = 0 } }, @@ -3886,7 +3899,7 @@ pub const InstGraph = struct { return switch (variable.origin) { .checked_variable => true, .row_extension => Common.invariant("row extension reached final demand validation without row default"), - .placeholder => Common.invariant("instantiation placeholder reached final demand validation"), + .placeholder, .interface_parameter => Common.invariant("instantiation placeholder reached final demand validation"), }; }, .named => |named| { @@ -7282,6 +7295,7 @@ pub const InstGraph = struct { fn mergeVariableOrigin(a: InstVariableOrigin, b: InstVariableOrigin) InstVariableOrigin { if (a == .checked_variable or b == .checked_variable) return .checked_variable; if (a == .row_extension or b == .row_extension) return .row_extension; + if (a == .interface_parameter or b == .interface_parameter) return .interface_parameter; return .placeholder; } @@ -8881,7 +8895,7 @@ const GraphUninhabitedScan = struct { break :blk switch (variable.origin) { .checked_variable => .{ .value = true }, .row_extension => Common.invariant("row extension reached final inhabitance validation without row default"), - .placeholder => Common.invariant("instantiation placeholder reached final inhabitance validation"), + .placeholder, .interface_parameter => Common.invariant("instantiation placeholder reached final inhabitance validation"), }; }, }, @@ -10230,7 +10244,7 @@ fn materializeUnresolved(variable: InstVariable) Type.Content { return switch (variable.origin) { .checked_variable => .{ .tag_union = Type.Span.empty() }, .row_extension => Common.invariant("row extension reached Monotype materialization without row default"), - .placeholder => Common.invariant("instantiation placeholder reached Monotype materialization"), + .placeholder, .interface_parameter => Common.invariant("instantiation placeholder reached Monotype materialization"), }; } @@ -15312,3 +15326,27 @@ test "finished witness absence updates after registration and import and resets const reset_capture = try InterfaceConstraints.capture(graph, graph.arena(), &.{fresh}); try std.testing.expect(reset_capture.open_nodes[0].finished == .none); } + +test "interface request identity ignores the tag sorting memo" { + const allocator = std.testing.allocator; + var types = Type.Store.init(allocator); + defer types.deinit(); + var ns = names.NameStore.init(allocator); + defer ns.deinit(); + const graph = try InstGraph.create(allocator, &types, &ns); + defer graph.destroy(); + const label = try ns.internTagLabel("Value"); + var ids: [2]InterfaceConstraints.Identity = undefined; + for (&ids, [_]bool{ false, true }) |*identity, sorted| { + const value = try graph.newNode(.{ .unresolved = InstVariable.checkedVariable(null, null) }); + const payload = try graph.arena().dupe(NodeId, &.{value}); + const row = try graph.newNode(.{ .tag_union = .{ + .tags = try graph.arena().dupe(InstTag, &.{.{ .name = label, .checked_name = label, .payloads = payload }}), + .ext = try graph.newNode(.empty_tag_union), + .tags_sorted = sorted, + } }); + const captured = try InterfaceConstraints.captureRequest(graph, graph.arena(), &.{row}); + identity.* = try captured.identity(graph); + } + try std.testing.expect(try ids[0].eql(ids[1], &types, &ns)); +} diff --git a/src/postcheck/proc_identity.zig b/src/postcheck/proc_identity.zig index 2e51bff0fbb..91f5a925092 100644 --- a/src/postcheck/proc_identity.zig +++ b/src/postcheck/proc_identity.zig @@ -58,11 +58,19 @@ const Allocator = std.mem.Allocator; /// SHA-256 content identity of one procedure. pub const Identity = [TypeDigestHasher.digest_length]u8; -const domain = "roc.proc.identity.v3"; +const domain = "roc.proc.identity.v4"; /// Everything one solved program has rendered so far, shared by every /// identity rendered over it. pub const Memo = struct { + const Description = struct { label: Identity, children: []const Identity }; + allocator: Allocator, + /// Canonical one-step descriptions of completed types. A later recursive + /// component may be a partial copy of one already reduced, with edges + /// both into itself and into that earlier component. + descriptions: std.AutoHashMapUnmanaged(Identity, Description) = .empty, + descriptions_by_label: std.AutoHashMapUnmanaged(Identity, std.ArrayList(Identity)) = .empty, + /// Digest of every type rendered so far, keyed by its root variable. digests: collections.DenseMap(SolvedType.TypeVarId, Identity), /// The one-step unfolding of every cyclic group position rendered so far, @@ -75,6 +83,7 @@ pub const Memo = struct { pub fn init(allocator: Allocator) Memo { return .{ + .allocator = allocator, .digests = collections.DenseMap(SolvedType.TypeVarId, Identity).init(allocator), .unfoldings = std.AutoHashMap(Identity, Identity).init(allocator), .node_maps = collections.DenseMapPool(SolvedType.TypeVarId, u32).init(allocator), @@ -82,6 +91,12 @@ pub const Memo = struct { } pub fn deinit(self: *Memo) void { + var descriptions = self.descriptions.valueIterator(); + while (descriptions.next()) |description| self.allocator.free(description.children); + self.descriptions.deinit(self.allocator); + var labels = self.descriptions_by_label.valueIterator(); + while (labels.next()) |entries| entries.deinit(self.allocator); + self.descriptions_by_label.deinit(self.allocator); self.digests.deinit(); self.unfoldings.deinit(); self.node_maps.deinit(); @@ -211,6 +226,9 @@ const Engine = struct { sorted_labels: std.ArrayList(Identity) = .empty, rank_of_member: std.ArrayList(u32) = .empty, block_rep: std.ArrayList(u32) = .empty, + reuse_matches: std.AutoHashMapUnmanaged(u32, Identity) = .empty, + reuse_pending: std.ArrayList(ReusePair) = .empty, + const ReusePair = struct { node: u32, digest: Identity }; fn init(renderer: *const Renderer) Engine { return .{ @@ -233,6 +251,8 @@ const Engine = struct { self.sorted_labels.deinit(self.gpa); self.rank_of_member.deinit(self.gpa); self.block_rep.deinit(self.gpa); + self.reuse_matches.deinit(self.gpa); + self.reuse_pending.deinit(self.gpa); } fn run(self: *Engine, root: SolvedType.TypeVarId) Allocator.Error!Identity { @@ -505,8 +525,10 @@ const Engine = struct { const key = self.unfoldingKey(node); const digest = self.renderer.memo.unfoldings.get(key) orelse key; try self.finalize(node, digest); + try self.recordDescription(node); return; } + if (try self.reuseComponent(members)) return; try self.resolveCyclicComponent(members); } @@ -547,6 +569,86 @@ const Engine = struct { try self.renderer.memo.digests.put(entry.ty, digest); } + /// The exact non-edge encoding, with a marker at each ordered edge. + fn nodeLabel(self: *const Engine, node: u32) Identity { + var hasher = TypeDigestHasher.init(); + writeBytes(&hasher, "type-description"); + for (self.itemsOf(node)) |item| switch (item) { + .bytes => |range| { + writeBytes(&hasher, "bytes"); + writeBytes(&hasher, self.bytesOf(range.start, range.len)); + }, + .child => writeBytes(&hasher, "child"), + }; + return hasher.finalResult(); + } + + fn recordDescription(self: *Engine, node: u32) Allocator.Error!void { + const memo = self.renderer.memo; + const digest = self.nodes.items[node].digest.?; + if (memo.descriptions.contains(digest)) return; + const children = try self.gpa.alloc(Identity, self.childrenOf(node).len); + var owned = true; + errdefer if (owned) self.gpa.free(children); + for (self.childrenOf(node), children) |child, *out| out.* = self.childDigest(child); + const label = self.nodeLabel(node); + try memo.descriptions.put(self.gpa, digest, .{ .label = label, .children = children }); + owned = false; + const candidates = try memo.descriptions_by_label.getOrPut(self.gpa, label); + if (!candidates.found_existing) candidates.value_ptr.* = .empty; + try candidates.value_ptr.append(self.gpa, digest); + } + + /// An SCC boundary is a storage fact, not part of an infinite type. + /// Before assigning a new group identity, prove whether this component + /// unfolds to an already completed type. All matching head labels are + /// considered, and the proof follows every ordered edge to a fixed point. + /// This also covers a partial recursive copy which points back into the + /// original component, rather than only a one-step acyclic unfolding. + fn reuseComponent(self: *Engine, members: []const u32) Allocator.Error!bool { + const memo = self.renderer.memo; + const candidates = memo.descriptions_by_label.get(self.nodeLabel(members[0])) orelse return false; + for (candidates.items) |candidate| { + if (!try self.matchesDescription(members[0], candidate)) continue; + for (members) |node| { + const digest = self.reuse_matches.get(node) orelse + Common.invariant("a recursive type proof omitted a member of its component"); + try self.finalize(node, digest); + } + for (members) |node| try memo.unfoldings.put(self.unfoldingKey(node), self.nodes.items[node].digest.?); + return true; + } + return false; + } + + fn matchesDescription(self: *Engine, root: u32, digest: Identity) Allocator.Error!bool { + self.reuse_matches.clearRetainingCapacity(); + self.reuse_pending.clearRetainingCapacity(); + try self.reuse_pending.append(self.gpa, .{ .node = root, .digest = digest }); + while (self.reuse_pending.pop()) |pair| { + if (self.nodes.items[pair.node].digest) |known| { + if (!std.mem.eql(u8, &known, &pair.digest)) return false; + continue; + } + const match = try self.reuse_matches.getOrPut(self.gpa, pair.node); + if (match.found_existing) { + if (!std.mem.eql(u8, match.value_ptr, &pair.digest)) return false; + continue; + } + match.value_ptr.* = pair.digest; + const description = self.renderer.memo.descriptions.get(pair.digest) orelse + Common.invariant("a completed procedure type lacks its canonical description"); + if (!std.mem.eql(u8, &description.label, &self.nodeLabel(pair.node))) return false; + const children = self.childrenOf(pair.node); + if (children.len != description.children.len) return false; + for (children, description.children) |child, expected| switch (child) { + .digest => |known| if (!std.mem.eql(u8, &known, &expected)) return false, + .node => |node| try self.reuse_pending.append(self.gpa, .{ .node = node, .digest = expected }), + }; + } + return true; + } + /// Reduce one cyclic component by bisimulation refinement over content /// labels, order the reduced positions by their final labels, and digest /// every member as its position in that one group rendering. @@ -688,6 +790,7 @@ const Engine = struct { writeU32(&hasher, rank_of_member[pos]); try self.finalize(node, hasher.finalResult()); } + for (members) |node| try self.recordDescription(node); // A rolled-out copy of any position unfolds to the same one-step // encoding, so it folds to that position's digest. for (members) |node| { @@ -720,6 +823,44 @@ fn writeU32(hasher: *TypeDigestHasher, value: u32) void { hasher.update(&buffer); } +test "procedure identity folds partial recursive copies across component boundaries" { + const a = std.testing.allocator; + var types = SolvedType.Store.init(a); + defer types.deinit(); + var name_store = names.NameStore.init(a); + defer name_store.deinit(); + var fn_by_symbol = std.AutoHashMap(Common.Symbol, Lifted.FnId).init(a); + defer fn_by_symbol.deinit(); + // A = (A, B), B = List(A). A partial copy C = (C, B) denotes + // exactly A, but C is a separate strongly connected component. + const original = try types.add(.unbound); + const list = try types.add(.{ .list = original }); + try types.set(original, .{ .tuple = try types.addSpan(&.{ original, list }) }); + const copy = try types.add(.unbound); + try types.set(copy, .{ .tuple = try types.addSpan(&.{ copy, list }) }); + const scalar = try types.add(.{ .primitive = .u8 }); + const different_list = try types.add(.{ .list = scalar }); + const different = try types.add(.unbound); + try types.set(different, .{ .tuple = try types.addSpan(&.{ different, different_list }) }); + for ([_]bool{ false, true }) |copy_first| { + var memo = Memo.init(a); + defer memo.deinit(); + const renderer = Renderer{ + .allocator = a, + .types = types.view(), + .names = &name_store, + .fn_tys = &.{}, + .source_digests = &.{}, + .fn_by_symbol = &fn_by_symbol, + .memo = &memo, + }; + const first = try renderer.typeDigest(if (copy_first) copy else original); + const second = try renderer.typeDigest(if (copy_first) original else copy); + try std.testing.expectEqual(first, second); + try std.testing.expect(!std.mem.eql(u8, &first, &try renderer.typeDigest(different))); + } +} + test "procedure identity excludes outer callable sets but retains nested callable sets" { const allocator = std.testing.allocator; var types = SolvedType.Store.init(allocator); diff --git a/src/postcheck/proofs.zig b/src/postcheck/proofs.zig index fd0e82f48c7..f70cc894771 100644 --- a/src/postcheck/proofs.zig +++ b/src/postcheck/proofs.zig @@ -10,6 +10,7 @@ pub fn Changes(comptime Node: type) type { const Self = @This(); /// Intrusive subscription owned by a published proof's dependency array. pub const Watch = struct { + subscribed: bool = false, previous: ?*Watch = null, next: ?*Watch = null, context: *anyopaque, @@ -43,6 +44,8 @@ pub fn Changes(comptime Node: type) type { } /// Register already allocated dependency storage. No producer write allocates. pub fn subscribe(self: *Self, node: Node, watch: *Watch) void { + std.debug.assert(!watch.subscribed); + watch.subscribed = true; const head = &self.records.items[@backingInt(node)]; watch.previous = null; watch.next = head.*; @@ -51,16 +54,19 @@ pub fn Changes(comptime Node: type) type { } /// Detach in constant time, including after the node column has grown. pub fn unsubscribe(self: *Self, node: Node, watch: *Watch) void { + if (!watch.subscribed) return; + watch.subscribed = false; if (watch.previous) |previous| previous.next = watch.next else self.records.items[@backingInt(node)] = watch.next; if (watch.next) |next| next.previous = watch.previous; } /// Queue invalidation only; active queries may still borrow certificates. pub fn mark(self: *Self, node: Node) void { self.epoch += 1; - var cursor = self.records.items[@backingInt(node)]; - while (cursor) |watch| { + while (self.records.items[@backingInt(node)]) |watch| { + // Detach before notifying: invalidating a proof also detaches + // its other node dependencies, possibly on this same node. + self.unsubscribe(node, watch); watch.notify(watch.context, self.epoch); - cursor = watch.next; } } }; @@ -88,6 +94,7 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { valid: bool = true, pending_next: ?*Proof = null, notifications: *Notifications, + changes: *MutationIndex, slot: usize, dependencies: []Link, keys: []Key, @@ -95,6 +102,13 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { fn invalidate(self: *Proof) void { if (!self.valid) return; self.valid = false; + // The certificate remains owned until refresh, but no further + // producer writes can make it more invalid. Unsubscribe now so + // long mutation batches never repeatedly visit a dirty proof. + for (self.dependencies) |*link| switch (link.dependency) { + .node => |node| self.changes.unsubscribe(node, &link.watch), + .proof => {}, + }; self.pending_next = self.notifications.head; self.notifications.head = self; self.notifications.invalidations += 1; @@ -217,7 +231,7 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { errdefer gpa.free(links); const keys = try gpa.alloc(Key, entries.len); errdefer gpa.free(keys); - proof.* = .{ .notifications = self.notifications.?, .slot = self.groups.items.len, .dependencies = links, .keys = keys }; + proof.* = .{ .notifications = self.notifications.?, .changes = self.changes.?, .slot = self.groups.items.len, .dependencies = links, .keys = keys }; var linked: usize = 0; errdefer for (links[0..linked]) |*link| self.unlink(gpa, link); for (dependencies, links) |dependency, *link| { @@ -354,3 +368,35 @@ fn allocationFailureFixture(gpa: Allocator) !void { test "proof publication allocation failures detach every graph subscription" { try std.testing.checkAllAllocationFailures(std.testing.allocator, allocationFailureFixture, .{}); } + +test "a dirty proof unsubscribes before later writes in the same batch" { + const a = std.testing.allocator; + const Node = enum(u32) { a, b, c }; + const Graph = struct { + changes: Changes(Node) = .{}, + fn proofChanges(self: *@This()) *Changes(Node) { + return &self.changes; + } + }; + var graph = Graph{}; + defer graph.changes.deinit(a); + try graph.changes.ensureTotalCapacityPrecise(a, 3); + for (0..3) |_| graph.changes.appendAssumeCapacity(); + var cache = Cache(u32, Node){}; + defer cache.deinit(a); + cache.refresh(a, 0, &graph); + try cache.publish(a, &.{.{ .key = 1, .value = 1 }}, &.{ .{ .node = .a }, .{ .node = .b } }); + try cache.publish(a, &.{.{ .key = 2, .value = 2 }}, &.{ .{ .node = .a }, .{ .node = .a } }); + try cache.publish(a, &.{.{ .key = 3, .value = 3 }}, &.{.{ .node = .c }}); + graph.changes.mark(.a); + for (0..4096) |_| { + graph.changes.mark(.a); + graph.changes.mark(.b); + } + cache.refresh(a, graph.changes.epoch, &graph); + try std.testing.expectEqual(@as(usize, 2), cache.invalidation_edges); + try std.testing.expectEqual(@as(usize, 2), cache.invalidations); + try std.testing.expect(cache.get(1) == null); + try std.testing.expect(cache.get(2) == null); + try std.testing.expect(cache.get(3) != null); +} diff --git a/src/postcheck/solved_lir_lower.zig b/src/postcheck/solved_lir_lower.zig index 0c4584eb9b8..6d7268f897c 100644 --- a/src/postcheck/solved_lir_lower.zig +++ b/src/postcheck/solved_lir_lower.zig @@ -4497,7 +4497,7 @@ const Lowerer = struct { const keyed = try self.allocator.alloc(Keyed, tags.len); defer self.allocator.free(keyed); for (tags, keyed) |tag, *entry| { - var hasher = std.crypto.hash.sha2.Sha256.init(.{}); + var hasher = base.Sha256.init(.{}); const payloads = self.types.span(tag.payloads); for (0..payloads.len) |payload_index| { const digest = try self.types.typeDigest(&self.solved.lifted.names, GuardedList.at(payloads, payload_index)); From 55df0a5060d60492b9282f93c9f0a8d48b885e22 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 07:42:49 -0400 Subject: [PATCH 38/97] Reuse catalog sessions and skip unreachable loop-promotion analysis Retain catalog descriptors and bounded decoded pages between short locked operations. Validate the published sequence after reacquiring ownership and invalidate pages on external commits or local writes. Keep exact batched retention observations protected by generation leases. Before constructing loop metadata topology, require an exact scan-edge path from an unattempted list loop parameter to a promotable operation. Full metadata and rewrite checks still apply to every candidate. Validation: 22 focused catalog/maintenance checks, 25 native-cache checks, an external-commit/interrupted-redo cache regression, and 24 loop-promotion checks pass in ReleaseFast. Catalog microbenchmark: 2000 acquisitions improve from 151-154ms to 41ms; full-project measurements pending. --- design.md | 19 +++++++ src/cli/pack_store.zig | 2 +- src/compile/artifact_store.zig | 49 ++++++++++++++++-- src/compile/cache_index.zig | 91 +++++++++++++++++++++++++++++++++ src/lir/loop_append_promote.zig | 63 ++++++++++++++++++++--- 5 files changed, 212 insertions(+), 12 deletions(-) diff --git a/design.md b/design.md index 733fa10f64e..129a584417b 100644 --- a/design.md +++ b/design.md @@ -27219,3 +27219,22 @@ generation is discarded rather than applied to its replacement. Process death releases the leases and may lose unpublished retention observations, but cannot invalidate already accepted immutable bytes. Failed optional retention writes do not fail compilation. No age approximation or whole-catalog scan is introduced. + + +Loop append promotion builds metadata lifetime topology only when an unattempted +list loop parameter reaches an operation the pass can rewrite. Its producer-owned +scan edges and exact forward closure establish this necessary condition; aliases, +parameter writes, and cyclic paths remain in that closure. Unrelated appends do +not require analyzing a read-only loop. A candidate that passes this condition +still receives the complete existing metadata, ownership, and rewrite checks. + + +Native acquisition batches retain open catalog and coordination handles, but +release the kernel shard lock between operations. Every reacquisition recovers +pending redo and validates the current catalog header sequence. A changed +sequence invalidates all retained decoded pages; local writes invalidate the +changed pages. Group enumeration and exact acquisition share this invocation-owned +session. The page cache is bounded, performs no historical scan, and is never +read outside shard ownership. Retention leases remain independent of catalog +ownership. The session owns its descriptors and path until its acquisition owner +releases the final retained records. diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 80ded54f01d..f66ecf27109 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -1383,7 +1383,7 @@ pub const LoadedPacks = struct { for (self.index_roots.items) |root| { self.index_stats.bucket_opens += 1; const group = root.store.indexedIdentity(root.origin, key, @splat(0), .offer); - const keys = root.store.native.groupKeys(allocator, group) catch |err| switch (err) { + const keys = (try self.retentionBatch(root.store)).groupKeys(allocator, group) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => { cacheable = false; diff --git a/src/compile/artifact_store.zig b/src/compile/artifact_store.zig index 5e08b7de23e..424a84152e9 100644 --- a/src/compile/artifact_store.zig +++ b/src/compile/artifact_store.zig @@ -300,6 +300,10 @@ pub const Store = struct { else => return err, }; defer shard.close(); + return self.acquireFromShard(key, timestamp, deferred, &shard); + } + + fn acquireFromShard(self: *const Store, key: catalog.Key, timestamp: i64, deferred: ?*?RetentionBatch.Observation, shard: *Shard) Error!?Artifact { var tx = shard.index.transaction(); const found = (try tx.find(.artifacts, key)) orelse return null; var metadata = try currentMetadata(&tx, key, found.value); @@ -376,6 +380,12 @@ pub const Store = struct { else => return err, }; defer shard.close(); + return groupKeysInShard(allocator, group_key, &shard); + } + + fn groupKeysInShard(allocator: Allocator, group_key: catalog.Key, shard: *Shard) Error![]catalog.Key { + var keys: std.ArrayList(catalog.Key) = .empty; + errdefer keys.deinit(allocator); var tx = shard.index.transaction(); var cursor: catalog.Key = @splat(0); @memcpy(cursor[0..32], group_key[0..32]); @@ -405,6 +415,9 @@ pub const RetentionBatch = struct { store: Store, pending: [shard_count][catalog.max_changes]Observation = undefined, counts: [shard_count]u8 = @splat(0), + shards: [shard_count]?Shard = @splat(null), + caches: [shard_count]catalog.ReadCache = @splat(.{}), + counters: catalog.Counters = .{}, pub fn init(store: *const Store) Allocator.Error!RetentionBatch { return .{ .store = .{ .allocator = store.allocator, .io = store.io, .root = try store.allocator.dupe(u8, store.root) } }; @@ -415,6 +428,7 @@ pub const RetentionBatch = struct { // turns validated code into a failed compilation; leases remain held // until this flush attempt has finished. self.flush() catch {}; + for (&self.shards) |*entry| if (entry.*) |*shard| shard.close(); self.store.deinit(); } @@ -422,7 +436,12 @@ pub const RetentionBatch = struct { const number = Store.shardFor(key); if (self.counts[number] == catalog.max_changes) try self.flushShard(number); var observation: ?Observation = null; - const artifact = try self.store.acquireKeyObserved(key, timestamp, &observation); + const shard = self.acquireShard(number) catch |err| switch (err) { + error.Missing => return null, + else => return err, + }; + defer shard.lock.unlock(self.store.io); + const artifact = try self.store.acquireFromShard(key, timestamp, &observation, shard); if (observation) |value| { self.pending[number][self.counts[number]] = value; self.counts[number] += 1; @@ -430,6 +449,29 @@ pub const RetentionBatch = struct { return artifact; } + fn acquireShard(self: *RetentionBatch, number: u4) Error!*Shard { + if (self.shards[number]) |*shard| { + shard.lock.lock(self.store.io, .exclusive) catch return error.IoFailure; + errdefer shard.lock.unlock(self.store.io); + try shard.index.refresh(); + return shard; + } + var shard = try Shard.openRequired(&self.store, number, false, &self.counters); + shard.index.read_cache = &self.caches[number]; + self.shards[number] = shard; + return &self.shards[number].?; + } + + pub fn groupKeys(self: *RetentionBatch, allocator: Allocator, identity: Identity) Error![]catalog.Key { + const group_key = identity.indexedKey(); + const shard = self.acquireShard(Store.shardFor(group_key)) catch |err| switch (err) { + error.Missing => return allocator.alloc(catalog.Key, 0), + else => return err, + }; + defer shard.lock.unlock(self.store.io); + return Store.groupKeysInShard(allocator, group_key, shard); + } + pub fn flush(self: *RetentionBatch) Error!void { for (0..shard_count) |number| try self.flushShard(@intCast(number)); } @@ -437,9 +479,8 @@ pub const RetentionBatch = struct { fn flushShard(self: *RetentionBatch, number: u4) Error!void { const count = self.counts[number]; if (count == 0) return; - var counters = catalog.Counters{}; - var shard = try Shard.openRequired(&self.store, number, false, &counters); - defer shard.close(); + const shard = try self.acquireShard(number); + defer shard.lock.unlock(self.store.io); var tx = shard.index.transaction(); for (self.pending[number][0..count]) |observation| { // A rejected payload may have released its lease. Never apply that diff --git a/src/compile/cache_index.zig b/src/compile/cache_index.zig index a57cb9aaaba..3b4e1b823fa 100644 --- a/src/compile/cache_index.zig +++ b/src/compile/cache_index.zig @@ -113,6 +113,20 @@ pub const Node = struct { } }; +/// Decoded pages retained by one catalog reader. Reopening metadata ownership +/// validates the published sequence before these pages can be read again. +pub const ReadCache = struct { + ids: [64]RecordId = @splat(0), + nodes: [64]Node = undefined, + fn clear(self: *ReadCache) void { + @memset(&self.ids, 0); + } + fn invalidate(self: *ReadCache, id: RecordId) void { + const slot = id % self.ids.len; + if (self.ids[slot] == id) self.ids[slot] = 0; + } +}; + /// Open catalog with bounded recovery and positional record access. pub const Index = struct { dir: Dir, @@ -120,6 +134,7 @@ pub const Index = struct { file: Io.File, header: Header, counters: *Counters, + read_cache: ?*ReadCache = null, /// `dir` and the shard lock are borrowed. The lock file is separate from /// both files that this protocol replaces or truncates. @@ -137,6 +152,17 @@ pub const Index = struct { return result; } + /// Reacquire the current published catalog under the kernel shard lock. + /// Any external transaction invalidates the invocation's decoded pages. + pub fn refresh(self: *Index) Error!void { + errdefer if (self.read_cache) |cache| cache.clear(); + try self.recover(); + const current = try Header.decode(&try self.readRecord(0)); + if ((self.file.length(self.io) catch return error.IoFailure) < current.next * record_size) return error.CorruptCatalog; + if (current.sequence != self.header.sequence) if (self.read_cache) |cache| cache.clear(); + self.header = current; + } + pub fn close(self: *Index) void { self.file.close(self.io); } @@ -152,6 +178,7 @@ pub const Index = struct { } fn writeRecord(self: *Index, id: RecordId, bytes: Record) Error!void { + if (self.read_cache) |cache| cache.invalidate(id); if (id > std.math.maxInt(u64) / record_size) return error.CorruptCatalog; self.counters.writes += 1; self.counters.written_bytes += record_size; @@ -160,6 +187,14 @@ pub const Index = struct { pub fn node(self: *Index, id: RecordId) Error!Node { if (id == 0 or id >= self.header.next) return error.CorruptCatalog; + if (self.read_cache) |cache| { + const slot = id % cache.ids.len; + if (cache.ids[slot] == id) return cache.nodes[slot]; + const value = try Node.decode(&try self.readRecord(id), self.header.next); + cache.ids[slot] = id; + cache.nodes[slot] = value; + return value; + } return Node.decode(&try self.readRecord(id), self.header.next); } @@ -583,3 +618,59 @@ test "catalog acquisition retains paths and sees pending writes" { var fresh = index.transaction(); try std.testing.expectEqual(@as(Value, @splat(3)), (try fresh.find(.artifacts, key)).?.value); } + +test "catalog sessions reuse decoded pages and invalidate external commits and interrupted redo" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var counters = Counters{}; + var index = try Index.open(tmp.dir, io, &counters); + defer index.close(); + var cache: ReadCache = .{}; + index.read_cache = &cache; + const key: Key = @splat(3); + { + var tx = index.transaction(); + _ = try tx.putValue(.artifacts, key, @splat(1)); + try tx.commit(); + } + { + var tx = index.transaction(); + _ = (try tx.find(.artifacts, key)).?; + } + const before = counters.reads; + for (0..10) |_| { + var tx = index.transaction(); + try std.testing.expectEqual(@as(Value, @splat(1)), (try tx.find(.artifacts, key)).?.value); + } + try std.testing.expectEqual(before, counters.reads); + { + var other = try Index.open(tmp.dir, io, &counters); + defer other.close(); + var tx = other.transaction(); + _ = try tx.putValue(.artifacts, key, @splat(2)); + try tx.commit(); + } + try index.refresh(); + { + var tx = index.transaction(); + try std.testing.expectEqual(@as(Value, @splat(2)), (try tx.find(.artifacts, key)).?.value); + } + { + var other = try Index.open(tmp.dir, io, &counters); + defer other.close(); + var tx = other.transaction(); + _ = try tx.putValue(.artifacts, key, @splat(3)); + try tx.prepare(); + // A writer died after publishing redo, before publishing the header. + } + try index.refresh(); + { + var tx = index.transaction(); + try std.testing.expectEqual(@as(Value, @splat(3)), (try tx.find(.artifacts, key)).?.value); + _ = try tx.putValue(.artifacts, key, @splat(4)); + try tx.commit(); + } + var last = index.transaction(); + try std.testing.expectEqual(@as(Value, @splat(4)), (try last.find(.artifacts, key)).?.value); +} diff --git a/src/lir/loop_append_promote.zig b/src/lir/loop_append_promote.zig index b8cd2ab5b00..7a1f8aa7a18 100644 --- a/src/lir/loop_append_promote.zig +++ b/src/lir/loop_append_promote.zig @@ -389,6 +389,7 @@ const Pass = struct { edge_index: EdgeIndex, proc_args: collections.DenseMap(LocalId, void), attempted: collections.DenseMap(LocalId, void), + candidate_carriers: collections.DenseMap(LocalId, void), new_locals: std.ArrayList(LocalId) = .empty, /// `scanProc`'s work stack, visited statements, open join bodies, and /// the stack depth at which each open body ends. @@ -438,6 +439,7 @@ const Pass = struct { .edge_index = .{ .heads = collections.DenseMap(LocalId, usize).init(allocator), .next = &.{} }, .proc_args = collections.DenseMap(LocalId, void).init(allocator), .attempted = collections.DenseMap(LocalId, void).init(allocator), + .candidate_carriers = collections.DenseMap(LocalId, void).init(allocator), .scan_visited = collections.DenseMap(CFStmtId, void).init(allocator), .scan_active_bodies = collections.DenseMap(LIR.JoinPointId, ?usize).init(allocator), }; @@ -455,6 +457,7 @@ const Pass = struct { fn release(self: *Pass) void { self.resetProcState(); self.classifier.append_kind.clearRetainingCapacity(); + self.candidate_carriers.clearRetainingCapacity(); self.family_parents.clearRetainingCapacity(); self.family_members.clearRetainingCapacity(); self.family_split.clearRetainingCapacity(); @@ -475,6 +478,7 @@ const Pass = struct { self.edge_index.deinit(self.allocator); self.proc_args.deinit(); self.attempted.deinit(); + self.candidate_carriers.deinit(); self.new_locals.deinit(self.allocator); self.scan_stack.deinit(self.allocator); self.scan_visited.deinit(); @@ -1292,20 +1296,32 @@ const Pass = struct { try self.scanProc(self.store.getProcSpecImmediate(proc_id).body.?, scan); max_join_id = @max(max_join_id, scan.max_join_id); if (scan.edges.items.len == 0) break; + try edge_index.rebuild(allocator, scan.edges.items); + // Metadata lifetime analysis cannot enable a rewrite when no + // remaining list parameter reaches any operation we can promote. + // This is an exact necessary condition over the scan's edges. + self.candidate_carriers.clearRetainingCapacity(); + for (scan.joins.items) |info| { + if (!info.has_back_edge) continue; + const params = self.store.getLocalSpan(self.store.getCFStmtImmediate(info.stmt).join.params); + for (0..GuardedList.borrowLen(params)) |index| { + const param = GuardedList.at(params, index); + if (!self.isListLocal(param) or attempted.contains(param) or self.candidate_carriers.contains(param)) continue; + _ = try edge_index.closure(allocator, scan.edges.items, param, &self.candidate_carriers); + } + } const has_candidate = candidate: { - for (scan.joins.items) |info| { - if (!info.has_back_edge) continue; - const params = self.store.getLocalSpan(self.store.getCFStmtImmediate(info.stmt).join.params); - for (0..GuardedList.borrowLen(params)) |index| { - const param = GuardedList.at(params, index); - if (self.isListLocal(param) and !attempted.contains(param)) break :candidate true; + for (scan.edges.items) |edge| { + if (!self.candidate_carriers.contains(edge.source)) continue; + switch (edge.kind) { + .append_call, .range_append, .le_bytes_append, .reserve_op, .set_op => break :candidate true, + .param_write, .alias, .refresh_op, .unsafe_append => {}, } } break :candidate false; }; if (!has_candidate) break; try self.classifyMetadataTransfers(scan, proc_id); - try edge_index.rebuild(allocator, scan.edges.items); outer: for (scan.joins.items) |info| { if (!info.has_back_edge) continue; const join = self.store.getCFStmtImmediate(info.stmt).join; @@ -5434,3 +5450,36 @@ test "promote initializes merged metadata on every incoming definition" { try testing.expectEqual(@as(u32, 1), hot.jumps_to_head); try testing.expectEqual(@as(u32, 0), hot.jumps_to_copy); } + +test "promote skips metadata topology for a read-only loop beside an unrelated append" { + var f = try PromoteTest.init(testing.allocator); + defer f.deinit(); + const store = &f.store; + const append_proc = try f.addAppendHelper(); + const out = try store.addLocal(.{ .layout_idx = f.list }); + const initial = try store.addLocal(.{ .layout_idx = f.list }); + const other = try store.addLocal(.{ .layout_idx = f.list }); + const item = try store.addLocal(.{ .layout_idx = .u8 }); + const appended = try store.addLocal(.{ .layout_idx = f.list }); + const alias = try store.addLocal(.{ .layout_idx = f.list }); + const join = f.freshJoinPointId(); + const back = try store.addCFStmt(.{ .jump = .{ .target = join } }, .test_fixture); + const set_back = try store.addCFStmt(.{ .set_local = .{ .target = out, .value = alias, .mode = .initialize_join_param, .next = back } }, .test_fixture); + const body = try store.addCFStmt(.{ .assign_ref = .{ .target = alias, .op = .local(out), .next = set_back } }, .test_fixture); + const entry = try store.addCFStmt(.{ .jump = .{ .target = join } }, .test_fixture); + const initialize = try store.addCFStmt(.{ .set_local = .{ .target = out, .value = initial, .mode = .initialize_join_param, .next = entry } }, .test_fixture); + const append = try store.addCFStmt(.{ .assign_call = .{ .target = appended, .proc = append_proc, .args = try store.addLocalSpan(&.{ other, item }), .next = initialize } }, .test_fixture); + const loop = try store.addCFStmt(.{ .join = .{ .id = join, .params = try store.addLocalSpan(&.{out}), .body = body, .remainder = append } }, .test_fixture); + _ = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .identity = LIR.ProcIdentity.forTest(171), + .args = try store.addLocalSpan(&.{ initial, other, item }), + .body = loop, + .ret_layout = f.list, + }, .none); + const before = @import("use_order.zig").topology_builds; + try run(store, &f.layouts); + try testing.expectEqual(before, @import("use_order.zig").topology_builds); + try testing.expect(store.getCFStmtImmediate(append).* == .assign_call); + try testing.expectEqual(@as(usize, 1), store.getLocalSpan(store.getCFStmtImmediate(loop).join.params).len); +} From 92fdade785fba4ffd8b6770dbf2b4f68b6f004b5 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 07:57:11 -0400 Subject: [PATCH 39/97] Memoize completed erased-callable traversals within a graph epoch --- design.md | 9 ++++ src/postcheck/lambda_solved/solve.zig | 63 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/design.md b/design.md index 129a584417b..4b4470745e5 100644 --- a/design.md +++ b/design.md @@ -27238,3 +27238,12 @@ session. The page cache is bounded, performs no historical scan, and is never read outside shard ownership. Retention leases remain independent of catalog ownership. The session owns its descriptors and path until its acquisition owner releases the final retained records. + + +Lambda Solved retains completed erased-callable reachability markings only for +the exact graph mutation epoch in which a walk made no changes. A later query +can skip a certified root or subgraph at that epoch. Unification, replacement, +or lazy expansion invalidates reuse through the producer's mutation epoch; +a walk that changed the graph publishes no certificate. Each active traversal +still owns its independent cycle set, including during nested marking calls. +No choice of callable representation changes; this records completed work only. diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index d92c37391f5..a59a73e960c 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -223,6 +223,9 @@ pub const WorkCounters = struct { clones: u64 = 0, unify_pairs: u64 = 0, active_cycles: u64 = 0, + erasure_queries: u64 = 0, + erasure_cache_hits: u64 = 0, + erasure_nodes: u64 = 0, exact_clone_queries: u64 = 0, exact_clone_hits: u64 = 0, arg_scope_queries: u64 = 0, @@ -280,6 +283,8 @@ pub fn run( const Solver = struct { counters: ?*WorkCounters = null, + erased_reachability: collections.DenseMapUnmanaged(Type.TypeVarId, void) = .empty, + erased_reachability_epoch: u64 = 0, /// Scratch and completed proofs owned by the function being solved. /// Disjoint queries share certificates until this producer scope ends. exact_clone_proofs: ExactCloneProofs = .{}, @@ -565,6 +570,7 @@ const Solver = struct { } fn deinit(self: *Solver) void { + self.erased_reachability.deinit(self.allocator); self.exact_clone_proofs.deinit(self.allocator); self.exact_clone_work.deinit(self.allocator); self.exact_clone_pairs.deinit(self.allocator); @@ -1863,9 +1869,26 @@ const Solver = struct { } fn markErasedCallablesReachedByType(self: *Solver, ty: Type.TypeVarId) Allocator.Error!void { + self.countWork(.erasure_queries, 1); + const epoch = self.program.types.analysis_changes.epoch; + if (self.erased_reachability_epoch != epoch) { + self.erased_reachability.clearRetainingCapacity(); + self.erased_reachability_epoch = epoch; + } + if (self.erased_reachability.contains(self.program.types.rootCompressed(ty))) { + self.countWork(.erasure_cache_hits, 1); + return; + } var visited = self.solved_set_pool.acquire(); defer self.solved_set_pool.release(&visited); try self.markErasedCallablesReachedByTypeInner(ty, &visited); + // A walk that performed unification or expanded a leaf keeps no + // certificate. Only a completed walk over an unchanged graph proves + // that every visited root already has the marking's entire effect. + if (self.program.types.analysis_changes.epoch == epoch) { + var roots = visited.keyIterator(); + while (roots.next()) |root| try self.erased_reachability.put(self.allocator, root.*, {}); + } } /// Marking a root erases every callable it reaches, and marking it again @@ -1883,6 +1906,11 @@ const Solver = struct { while (pending.pop()) |next| { const root = types.rootCompressed(next); if ((try visited.getOrPut(root)).found_existing) continue; + if (self.erased_reachability_epoch == types.analysis_changes.epoch and self.erased_reachability.contains(root)) { + self.countWork(.erasure_cache_hits, 1); + continue; + } + self.countWork(.erasure_nodes, 1); const content = types.get(root); const resolved = if (std.meta.activeTag(content) == .mono) @@ -6780,3 +6808,38 @@ test "exact cyclic clone proof rejects unexpanded leaves that hide a mutable tie try std.testing.expect(!try solver.exactMonoClone(recursive.ty, target)); try std.testing.expectEqual(count, program.types.varCount()); } + +test "erasure reachability skips completed subgraphs and revisits changed children" { + const a = std.testing.allocator; + var program = Ast.Program.init(a, emptyLiftedProgramForTest(a)); + defer program.deinit(); + var solver = try Solver.init(a, &program); + defer solver.deinit(); + var counts: WorkCounters = .{}; + solver.counters = &counts; + const types = &program.types; + const number = try types.add(.{ .primitive = .u64 }); + const callable = try types.add(.{ .lambda_set = .empty() }); + const function = try types.add(.{ .func = .{ .args = .empty(), .callable = callable, .ret = number } }); + const root = try types.add(.{ .list = function }); + try solver.markErasedCallablesReachedByType(root); + try std.testing.expect(types.get(types.root(callable)) == .erased); + try solver.markErasedCallablesReachedByType(root); + const before = counts.erasure_nodes; + for (0..100) |_| try solver.markErasedCallablesReachedByType(root); + try std.testing.expectEqual(before, counts.erasure_nodes); + const wrapper = try types.add(.{ .box = root }); + try solver.markErasedCallablesReachedByType(wrapper); + try std.testing.expectEqual(before + 1, counts.erasure_nodes); + const next_callable = try types.add(.{ .lambda_set = .empty() }); + const next_function = try types.add(.{ .func = .{ .args = .empty(), .callable = next_callable, .ret = number } }); + try types.set(root, .{ .list = next_function }); + try solver.markErasedCallablesReachedByType(wrapper); + try std.testing.expect(types.get(types.root(next_callable)) == .erased); + const cycle = try types.add(.unbound); + try types.set(cycle, .{ .tuple = try types.addSpan(&.{ cycle, wrapper }) }); + try solver.markErasedCallablesReachedByType(cycle); + const after_cycle = counts.erasure_nodes; + try solver.markErasedCallablesReachedByType(cycle); + try std.testing.expectEqual(after_cycle, counts.erasure_nodes); +} From 5fde02b290d36252805e85ad4559b9976eac0ad4 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 08:04:17 -0400 Subject: [PATCH 40/97] Reuse interned Monotypes before copying cross-store type rows --- design.md | 10 +++++++ src/postcheck/monotype/type.zig | 49 +++++++++++++++++++++++++++++---- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/design.md b/design.md index 4b4470745e5..ee85afcc3e7 100644 --- a/design.md +++ b/design.md @@ -27247,3 +27247,13 @@ or lazy expansion invalidates reuse through the producer's mutation epoch; a walk that changed the graph publishes no certificate. Each active traversal still owns its independent cycle set, including during nested marking calls. No choice of callable representation changes; this records completed work only. + + +Cross-store immutable Monotype import resolves each newly reached source node's +producer-cached full structural digest against the destination's committed +interner before allocating speculative rows. A matching committed representative +is reused directly; only absent content is copied and sealed. The full reached +closure still receives relocation entries, including recursive members and +children of reused roots. This shares immutable type content, never mutable +instantiation cells or ownership scopes. All fallible work precedes relocation +publication, and allocation failure preserves the original store and mapping. diff --git a/src/postcheck/monotype/type.zig b/src/postcheck/monotype/type.zig index 5464ab7da9d..a4a94d9b702 100644 --- a/src/postcheck/monotype/type.zig +++ b/src/postcheck/monotype/type.zig @@ -1773,11 +1773,25 @@ pub const Store = struct { errdefer transaction.abort(self); const reserved = try self.allocator.alloc(TypeId, closure.items.len); defer self.allocator.free(reserved); - for (reserved) |*slot| slot.* = try transaction.reserve(self); + var first_new: ?TypeId = null; + for (closure.items, reserved) |source_ty, *slot| { + // Both stores use the same full structural digest domain. Reuse + // an already committed destination type before allocating its + // rows, rather than materializing a copy for commit to discard. + if (source.cachedDigest(source_ty, .full)) |digest| { + if (self.full_digest_interned.get(DigestBucketKey.from(digest))) |existing| { + slot.* = existing; + continue; + } + } + slot.* = try transaction.reserve(self); + if (first_new == null) first_new = slot.*; + } var scratch: ImportScratch = .{}; defer scratch.deinit(self.allocator); for (closure.items, reserved) |source_ty, destination_ty| { + if (@backingInt(destination_ty) < transaction.mark_.types_len) continue; const imported = try self.importContent( source, source_names, @@ -1795,15 +1809,21 @@ pub const Store = struct { // digest its source already computed. Seeding the copies lets the // commit intern them by lookup instead of hashing each one again. for (closure.items, reserved) |source_ty, destination_ty| { + if (@backingInt(destination_ty) < transaction.mark_.types_len) continue; inline for (.{ NamedDigestMode.full, NamedDigestMode.identity_only, NamedDigestMode.equality, NamedDigestMode.representation }) |mode| { if (source.cachedDigest(source_ty, mode)) |digest| try self.setCachedDigest(destination_ty, mode, digest); } } - var committed = try self.commitTransaction(destination_names, transaction, reserved[0]); - defer committed.deinit(); - for (closure.items, reserved) |source_ty, speculative| { - relocation.map.getPtr(source_ty).?.* = committed.remapType(speculative); + if (first_new) |root| { + var committed = try self.commitTransaction(destination_names, transaction, root); + defer committed.deinit(); + for (closure.items, reserved) |source_ty, speculative| { + relocation.map.getPtr(source_ty).?.* = committed.remapType(speculative); + } + } else { + transaction.abort(self); + for (closure.items, reserved) |source_ty, existing| relocation.map.getPtr(source_ty).?.* = existing; } for (source_roots, imported_roots) |source_root, *destination_root| { destination_root.* = relocation.remapType(source, source_root); @@ -4863,6 +4883,8 @@ test "monotype cross-store import reuses every member of a preexisting recursive &destination_names, ); defer relocation.deinit(); + var digest_stats: Store.DigestStats = .{}; + destination.digest_stats = &digest_stats; var imported = try destination.importTypes( &destination_names, &source, @@ -4881,6 +4903,23 @@ test "monotype cross-store import reuses every member of a preexisting recursive try std.testing.expectEqual(destination_leaf, relocation.remapType(&source, source_leaf)); try std.testing.expectEqual(initial_types, destination.types.len()); try std.testing.expectEqual(initial_spans, destination.spans.len()); + try std.testing.expectEqual(@as(u64, 0), digest_stats.commit_root_requests); + + // A different source-store import can share the complete existing cycle + // and still add a new parent without copying that cycle into its suffix. + const parent = try source.internFunc(&source_names, &.{source_c_final}, source_a_final); + var fresh = Store.TypeRelocation.init(std.testing.allocator, &source, &source_names, &destination, &destination_names); + defer fresh.deinit(); + var with_parent = try destination.importTypes(&destination_names, &source, &source_names, &fresh, &.{parent}); + defer with_parent.deinit(); + try std.testing.expectEqual(initial_types + 1, destination.types.len()); + try std.testing.expectEqual(@as(u64, 1), digest_stats.commit_root_requests); + try std.testing.expectEqual(destination_a_final, fresh.remapType(&source, source_a_final)); + try std.testing.expectEqual(destination_b_final, fresh.remapType(&source, source_b_final)); + try std.testing.expectEqual(destination_c_final, fresh.remapType(&source, source_c_final)); + const function = destination.get(with_parent.roots[0]).func; + try std.testing.expectEqual(destination_a_final, function.ret); + try std.testing.expectEqual(destination_c_final, GuardedList.at(destination.span(function.args), 0)); } test "monotype cross-store import is atomic under allocation failure" { From 929cfc9b8d5cba566c72b08b825f1c43f8bd5daa Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 08:23:23 -0400 Subject: [PATCH 41/97] Preserve pending erased-call summaries across native cache hits --- design.md | 8 ++++ src/backend/dev/PackFile.zig | 12 +++++- src/cli/main.zig | 4 ++ src/cli/pack_store.zig | 4 +- src/lir/LIR.zig | 3 ++ src/lir/tail_drive.zig | 64 +++++++++++++++++++++++++++++- src/postcheck/common.zig | 2 + src/postcheck/solved_lir_lower.zig | 1 + 8 files changed, 95 insertions(+), 3 deletions(-) diff --git a/design.md b/design.md index ee85afcc3e7..be5a78ea229 100644 --- a/design.md +++ b/design.md @@ -21932,6 +21932,14 @@ callee can return with a call pending, and on each deferred call: by its function pointer and the procedure asks with its own, so a value no callee read cannot answer for a different procedure entered later. +The pass also publishes `LirProcSpec.may_leave_pending`, its exact procedure +summary, into native cache offers. Reusing a body-less native entry seeds the +consumer's propagation with that producer fact, including when no locally +compiled body contains a deferred call. Callers must drive or forward that +pending work before treating its result slot as a value. The versioned pack +format records the summary alongside the ownership ABI; an older entry without +the fact is incompatible, never assumed to return a completed value. + A call's result may be converted to another representation before it is returned, by statements after the call. The pass marks a direct call or a deferred call `returns_pending` when its value reaches the return through diff --git a/src/backend/dev/PackFile.zig b/src/backend/dev/PackFile.zig index 658d0690e04..240a9db4768 100644 --- a/src/backend/dev/PackFile.zig +++ b/src/backend/dev/PackFile.zig @@ -23,7 +23,7 @@ const Allocator = std.mem.Allocator; const magic = "RPCK"; /// Format version; bump whenever the encoding or artifact contents change. -pub const format_version: u32 = 17; +pub const format_version: u32 = 18; /// One specialization the pack can serve: its reservation-time key, the /// excerpt holding its procedure, and the ownership signature and @@ -34,6 +34,8 @@ pub const SpecEntry = struct { excerpt: u32, /// The producer proved this closure contains no expect instrumentation. test_compatible: bool = false, + /// Whether the native body can return before an erased call completes. + may_leave_pending: bool = false, /// The compile-time roots the procedure's code reads, which a linking /// program demands (`Common.SpecCacheHit.comptime_root_reads`). comptime_root_reads: []const lir.CheckedPipeline.ComptimeRootRead = &.{}, @@ -677,6 +679,7 @@ fn writeSpecs(writer: *Writer, specs: []const SpecEntry) Allocator.Error!void { try writer.raw(&spec.key); try writer.word(spec.excerpt); try writer.byte(@intFromBool(spec.test_compatible)); + try writer.byte(@intFromBool(spec.may_leave_pending)); try writer.word(@intCast(spec.comptime_root_reads.len)); for (spec.comptime_root_reads) |root_read| { try writer.raw(&root_read.module); @@ -706,6 +709,11 @@ fn readSpecs(reader: *Reader, arena_allocator: Allocator, count: u32, excerpt_co 1 => true, else => return error.MalformedPack, }, + .may_leave_pending = switch (try reader.byte()) { + 0 => false, + 1 => true, + else => return error.MalformedPack, + }, .comptime_root_reads = reads: { const reads = try arena_allocator.alloc(lir.CheckedPipeline.ComptimeRootRead, try reader.word()); for (reads) |*root_read| root_read.* = .{ .module = (try reader.raw(32))[0..32].*, .root = try reader.word() }; @@ -912,6 +920,7 @@ test "pack bytes round-trip every excerpt field and spec entry" { .rc_read_only_params = 0b100, .rc_ret_unique = true, .rc_ret_unique_fields = 0b10, + .may_leave_pending = true, .rc_ret_conditions = &.{ 0x0001_02ff, 0x0000_0403 }, .comptime_root_reads = &.{ .{ .module = @as([32]u8, @splat(0x11)), .root = 3 }, @@ -936,6 +945,7 @@ test "pack bytes round-trip every excerpt field and spec entry" { try testing.expectEqual(specs[0].rc_read_only_params, pack.specs[0].rc_read_only_params); try testing.expect(pack.specs[0].rc_ret_unique); try testing.expectEqual(specs[0].rc_ret_unique_fields, pack.specs[0].rc_ret_unique_fields); + try testing.expect(pack.specs[0].may_leave_pending); try testing.expectEqualSlices(u32, specs[0].rc_ret_conditions, pack.specs[0].rc_ret_conditions); try testing.expectEqualDeep(specs[0].comptime_root_reads, pack.specs[0].comptime_root_reads); const proc = pack.set.excerpts[0]; diff --git a/src/cli/main.zig b/src/cli/main.zig index 9551369e350..5aef76a9e6d 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -8865,6 +8865,7 @@ fn objectPackBytes( .rc_read_only_params = proc.rc_read_only_params, .rc_ret_unique = proc.rc_ret_unique, .rc_ret_unique_fields = proc.rc_ret_unique_fields, + .may_leave_pending = proc.may_leave_pending, .rc_ret_conditions = try GuardedList.dupe(arena, u32, store.getU32Span(proc.rc_ret_conditions)), }); } @@ -9163,6 +9164,7 @@ const PackOffers = struct { /// A rule read from the program's code withholds it. withheld: bool, test_compatible: bool, + may_leave_pending: bool, rc_borrowed_params: u64, rc_ret_borrowed: bool, rc_ret_lenders: u64, @@ -9283,6 +9285,7 @@ const PackOffers = struct { .rc_read_only_params = proc.rc_read_only_params, .rc_ret_unique = proc.rc_ret_unique, .rc_ret_unique_fields = proc.rc_ret_unique_fields, + .may_leave_pending = proc.may_leave_pending, .rc_ret_conditions = try GuardedList.dupe(arena, u32, store.getU32Span(proc.rc_ret_conditions)), .name = try arena.dupe(u8, store.procDebugName(id) orelse ""), .fingerprint = fingerprint, @@ -9334,6 +9337,7 @@ const PackOffers = struct { .rc_read_only_params = offer.rc_read_only_params, .rc_ret_unique = offer.rc_ret_unique, .rc_ret_unique_fields = offer.rc_ret_unique_fields, + .may_leave_pending = offer.may_leave_pending, .rc_ret_conditions = offer.rc_ret_conditions, }); } diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index f66ecf27109..4badc9b4864 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -1080,7 +1080,7 @@ pub const LoadedPacks = struct { const origin = old.set.excerpts[old.index].metadata_origin orelse return false; if (!std.mem.eql(u8, &origin.record.owner.domain, &store.domain) or !std.mem.eql(u8, &origin.record.digest, &component) or origin.index != ordinal) return false; const hit = self.specs.get(spec.key) orelse return false; - if (hit.rc_borrowed_params != spec.rc_borrowed_params or hit.rc_ret_borrowed != spec.rc_ret_borrowed or + if (hit.may_leave_pending != spec.may_leave_pending or hit.rc_borrowed_params != spec.rc_borrowed_params or hit.rc_ret_borrowed != spec.rc_ret_borrowed or hit.rc_ret_lenders != spec.rc_ret_lenders or hit.rc_read_only_params != spec.rc_read_only_params or hit.rc_ret_unique != spec.rc_ret_unique or hit.rc_ret_unique_fields != spec.rc_ret_unique_fields or !std.mem.eql(u32, hit.rc_ret_conditions, spec.rc_ret_conditions) or hit.comptime_root_reads.len != spec.comptime_root_reads.len) return false; @@ -1756,6 +1756,7 @@ pub const LoadedPacks = struct { .rc_read_only_params = spec.rc_read_only_params, .rc_ret_unique = spec.rc_ret_unique, .rc_ret_unique_fields = spec.rc_ret_unique_fields, + .may_leave_pending = spec.may_leave_pending, .rc_ret_conditions = spec.rc_ret_conditions, }); } @@ -1781,6 +1782,7 @@ pub const LoadedPacks = struct { .rc_read_only_params = spec.rc_read_only_params, .rc_ret_unique = spec.rc_ret_unique, .rc_ret_unique_fields = spec.rc_ret_unique_fields, + .may_leave_pending = spec.may_leave_pending, .rc_ret_conditions = spec.rc_ret_conditions, }, .pack = @intCast(pack_index), diff --git a/src/lir/LIR.zig b/src/lir/LIR.zig index 28852a0284f..56f7be0be7b 100644 --- a/src/lir/LIR.zig +++ b/src/lir/LIR.zig @@ -2116,6 +2116,9 @@ pub const LirProcSpec = struct { /// `unless_caller_drives` statement: its entry records whether the /// erased-call runtime made the call. reads_caller_drives: bool = false, + /// Producer-owned tail-drive summary. Cached bodies retain this fact so + /// callers make a pending erased call before reading its result. + may_leave_pending: bool = false, /// Tail-recursion rewrite applied by the TRMC pass, if any. tail_transform: TailTransform = .none, /// Set by ARC on every procedure that makes or receives a same-SCC tail diff --git a/src/lir/tail_drive.zig b/src/lir/tail_drive.zig index 2abe63c6c05..d15654b026e 100644 --- a/src/lir/tail_drive.zig +++ b/src/lir/tail_drive.zig @@ -133,12 +133,18 @@ pub fn run( try body_clone.appendSuccessors(store, &work, stmt_id, allocator); } } - if (deferred_calls.items.len == 0) return; // A procedure can return with a call pending when it defers one itself, // or hands up one a callee left. var may_pend = try std.bit_set.DynamicBitSetUnmanaged.initEmpty(allocator, proc_count); defer may_pend.deinit(allocator); + // A body supplied by the native cache has already been analyzed by its + // producer. Its absence here says nothing about whether it leaves work + // pending; replay that explicit summary before propagating to callers. + for (0..proc_count) |index| { + const proc = store.getProcSpecImmediate(@fromBackingInt(@as(u32, @intCast(index)))); + if (proc.external and proc.may_leave_pending) may_pend.set(index); + } for (deferred_calls.items) |deferred| { if (deferred.returns == .no) continue; if (handsUp(store, &outside, deferred.caller) != .never) may_pend.set(deferred.caller); @@ -155,6 +161,10 @@ pub fn run( } } + for (0..proc_count) |index| { + store.getProcSpecPtr(@fromBackingInt(@as(u32, @intCast(index)))).may_leave_pending = may_pend.isSet(index); + } + for (deferred_calls.items) |deferred| { const drive = driveAfter(store, &outside, deferred.caller, deferred.returns != .no); const updated = &store.getCFStmtPtr(deferred.stmt).assign_call_erased; @@ -319,3 +329,55 @@ fn returnOf( test "tail drive declarations are referenced" { std.testing.refAllDecls(@This()); } + +test "cached pending-call summaries propagate without a locally deferred call" { + const a = std.testing.allocator; + for ([_]bool{ false, true }) |pending| { + var store = LirStore.init(a); + defer store.deinit(); + const external = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .identity = LIR.ProcIdentity.forTest(1), + .args = .empty(), + .ret_layout = .u64, + .external = true, + .may_leave_pending = pending, + }, .none); + const value = try store.addLocal(.{ .layout_idx = .u64 }); + const ret = try store.addCFStmt(.{ .ret = .{ .value = value } }, .test_fixture); + const call = try store.addCFStmt(.{ .assign_call = .{ + .target = value, + .proc = external, + .args = .empty(), + .next = ret, + } }, .test_fixture); + const relay = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .identity = LIR.ProcIdentity.forTest(2), + .args = .empty(), + .body = call, + .ret_layout = .u64, + }, .none); + const result = try store.addLocal(.{ .layout_idx = .u64 }); + const root_ret = try store.addCFStmt(.{ .ret = .{ .value = result } }, .test_fixture); + const root_call = try store.addCFStmt(.{ .assign_call = .{ + .target = result, + .proc = relay, + .args = .empty(), + .next = root_ret, + } }, .test_fixture); + const root = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .identity = LIR.ProcIdentity.forTest(3), + .args = .empty(), + .body = root_call, + .ret_layout = .u64, + }, .none); + try run(a, &store, &.{&.{root}}); + try std.testing.expectEqual(pending, store.getProcSpecImmediate(external).may_leave_pending); + try std.testing.expectEqual(pending, store.getProcSpecImmediate(relay).may_leave_pending); + try std.testing.expect(!store.getProcSpecImmediate(root).may_leave_pending); + try std.testing.expectEqual(if (pending) LIR.PendingDrive.handed_up else .none, store.stmtRowOrDefault(store.getCFStmtImmediate(call).assign_call.extra).drive); + try std.testing.expectEqual(if (pending) LIR.PendingDrive.always else .none, store.stmtRowOrDefault(store.getCFStmtImmediate(root_call).assign_call.extra).drive); + } +} diff --git a/src/postcheck/common.zig b/src/postcheck/common.zig index d82666cb22c..00e1bb5210f 100644 --- a/src/postcheck/common.zig +++ b/src/postcheck/common.zig @@ -454,6 +454,8 @@ pub const LiteralRootReuse = union(enum) { /// entry adopts as fixed. pub const SpecCacheHit = struct { identity: [32]u8, + /// The producer's tail-drive summary for this procedure's native body. + may_leave_pending: bool = false, /// The entry is a linked object reached through its C-ABI entry rather /// than spliced code (`LIR.LirProcSpec.c_abi_entry`). c_abi_entry: bool = false, diff --git a/src/postcheck/solved_lir_lower.zig b/src/postcheck/solved_lir_lower.zig index 6d7268f897c..2f5d78f4b54 100644 --- a/src/postcheck/solved_lir_lower.zig +++ b/src/postcheck/solved_lir_lower.zig @@ -3333,6 +3333,7 @@ const Lowerer = struct { .abi = if (spec.abi == .erased) .erased_callable else .roc, .hosted = try self.hostedProcForSource(source_fn.source), .external = cached != null, + .may_leave_pending = if (cached) |hit| hit.may_leave_pending else false, .cache_key = cached_key, .code_key = code_key, .source_monotype_fn = if (source_fn.source) |template| if (template.frozen_fn) |mono_fn| @backingInt(mono_fn) else null else null, From dda9cd7bf1da05bbb5a48a6283ffbf72adc5bac2 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 08:28:01 -0400 Subject: [PATCH 42/97] Qualify loop promotion before constructing lifetime topology --- design.md | 6 ++++++ src/lir/loop_append_promote.zig | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/design.md b/design.md index be5a78ea229..9dd64b64406 100644 --- a/design.md +++ b/design.md @@ -27235,6 +27235,12 @@ scan edges and exact forward closure establish this necessary condition; aliases parameter writes, and cyclic paths remain in that closure. Unrelated appends do not require analyzing a read-only loop. A candidate that passes this condition still receives the complete existing metadata, ownership, and rewrite checks. +The pass tests its structural prerequisites (complete definitions, supported +parameter writes, and tracked carrier uses) before building ordered-use +metadata. These prerequisites depend on carrier membership, not on whether an +edge carries existing metadata or starts new metadata. A surviving candidate +then receives the full lifetime classification; that classification can only +remove candidate rewrite sites and is shared until a rewrite changes the body. Native acquisition batches retain open catalog and coordination handles, but diff --git a/src/lir/loop_append_promote.zig b/src/lir/loop_append_promote.zig index 7a1f8aa7a18..7ce418ac649 100644 --- a/src/lir/loop_append_promote.zig +++ b/src/lir/loop_append_promote.zig @@ -1321,7 +1321,7 @@ const Pass = struct { break :candidate false; }; if (!has_candidate) break; - try self.classifyMetadataTransfers(scan, proc_id); + var metadata_classified = false; outer: for (scan.joins.items) |info| { if (!info.has_back_edge) continue; const join = self.store.getCFStmtImmediate(info.stmt).join; @@ -1333,7 +1333,7 @@ const Pass = struct { if (!self.isListLocal(param)) continue; if (attempted.contains(param)) continue; try attempted.put(param, {}); - if (try self.promoteParam(scan, edge_index, proc_args, info.stmt, param, &max_join_id, new_locals)) { + if (try self.promoteParam(scan, edge_index, proc_args, proc_id, &metadata_classified, info.stmt, param, &max_join_id, new_locals)) { promoting = true; break :outer; } @@ -1361,6 +1361,8 @@ const Pass = struct { scan: *Scan, edge_index: *const EdgeIndex, proc_args: *collections.DenseMap(LocalId, void), + proc_id: LIR.LirProcSpecId, + metadata_classified: *bool, loop_stmt: CFStmtId, list_param: LocalId, max_join_id: *u32, @@ -1477,6 +1479,30 @@ const Pass = struct { if (total != tracked) return false; } + // All structural prerequisites hold. Only now build ordered-use + // topology to decide which transfers preserve metadata. Until this + // point carried and entry edges differ in no prerequisite above: + // both define the same carrier and require the same wiring. + if (!metadata_classified.*) { + try self.classifyMetadataTransfers(scan, proc_id); + metadata_classified.* = true; + } + rewrite_site_count = 0; + has_sets = false; + for (scan.edges.items) |*edge| { + edge.flow = if (!carriers.contains(edge.target)) .outside else if (carriers.contains(edge.source) and edge.preserves_metadata) .carried else .entry; + if (edge.flow != .carried) continue; + switch (edge.kind) { + .append_call, .range_append, .le_bytes_append, .reserve_op => rewrite_site_count += 1, + .set_op => { + rewrite_site_count += 1; + has_sets = true; + }, + .param_write, .alias, .refresh_op, .unsafe_append => {}, + } + } + if (rewrite_site_count == 0) return false; + // Qualified: thread the slack. try self.apply(scan, &chain_defs, &chain_params, has_sets, loop_stmt, list_param, max_join_id, new_locals); return true; @@ -4998,7 +5024,9 @@ test "promote leaves a tainted chain alone" { .ret_layout = f.list, }, .none); + const before = @import("use_order.zig").topology_builds; try run(store, &f.layouts); + try testing.expectEqual(before, @import("use_order.zig").topology_builds); // Nothing changed: the call is still a call and the loop still has one // parameter. From 8b9a740b6ae23bdf79fd0c2b00c454b0ac6ebb90 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 09:28:27 -0400 Subject: [PATCH 43/97] Keep dev procedures reusable under an explicit owned base ABI --- design.md | 39 ++++++++++++++++++-------- src/cli/main.zig | 4 ++- src/eval/compile_time_finalization.zig | 1 + src/lir/checked_pipeline.zig | 24 ++++++++++++---- src/postcheck/solved_inline.zig | 7 +++-- src/postcheck/solved_lir_lower.zig | 14 ++++++++- 6 files changed, 67 insertions(+), 22 deletions(-) diff --git a/design.md b/design.md index 9dd64b64406..75928360d99 100644 --- a/design.md +++ b/design.md @@ -1339,10 +1339,11 @@ is a member of a lambda set in the signature of a procedure without a code key, or an app function such a procedure's code reaches. These are exactly the procedures a program taking that procedure from a pack lowers for it, and they follow from the program's types and source, so every program finds -the same ones whatever it took from a pack. Every other procedure with a code +the same ones whatever it took from a pack. When `owned_proc_signatures` is disabled, every other procedure with a code key is called only by code compiled with its current body, whose code key changes with that body, so ARC solves its signature and variants like any -other procedure's. A stable procedure keeps its outcome variant ("Outcome- +other procedure's. Dev policy pins those procedures too, as declared under +"Mode Specialization". A stable procedure keeps its outcome variant ("Outcome- Conditioned Argument Restitution") for callers with a code key; a caller without one calls its pinned base. A variant of a procedure with a code key has a code key derived from it, since the variant's code is the source's. @@ -9179,11 +9180,13 @@ The explicit `InlineMode` controls the optional specialization work: - `.wrappers` runs SpecConstr and produces wrapper and exact-single-use inline decisions from Lambda Solved. Size and speed modes select this. - `.wrappers_and_source_single_use` makes the same wrapper decisions, and - decides single use by the source instead of the program (below). Dev mode - selects this, because the object cache links dev procedures into programs - other than the one compiling them, and only decisions that depend on source - alone are the same in all of them. For the same reason dev SpecConstr - (`.iterator_fusion` clone inlining) clones no workers for call patterns or + decides single use by the source instead of the program (below), giving + separately compiled callers the same inline decisions. +- `.procedures` retains standalone procedures rather than substituting their + bodies. Dev builds and compile-time evaluation select it so unchanged + procedures remain eligible for early native-code reuse. SpecConstr still + performs iterator fusion; the LIR passes that bound list copying still run. + Dev SpecConstr (`.iterator_fusion` clone inlining) clones no workers for call patterns or callables (`CloneInlining.specializesCallPatterns`): which clones a function gets depends on every call the program makes to it, so a body rewritten to call one would depend on the rest of the program. @@ -22335,11 +22338,15 @@ procedure's uniqueness results rather than solving them again, so callers of a c procedure prove exactly the uniqueness they would against its body. Every program must lower a procedure an entry offers to the same LIR, since -any of them may link it. Dev inlining depends only on source -(`.wrappers_and_source_single_use`), so a pack program makes the inline -decisions any program makes, and it keeps as a procedure every keyed -specialization except a body its calls inline. A program that takes its -cache hits during specialization, as a pack program does, could not inline a +any of them may link it. Dev mode retains standalone procedures +(`.procedures`), independent of cache state or the surrounding program. +Its explicit `owned_proc_signatures` LIR policy pins each procedure to the +type-determined owned base ABI, with demand holes borrowed and returns owned. +Compile-time evaluation and dev-policy object-pack production select that +same policy. Optimized runtime consumers keep inferred base signatures. +Mandatory outcome-restitution variants remain available to callers that can +demand them; this policy changes no ownership correctness rule. A program +that takes its cache hits during specialization, as a pack program does, could not inline a body a hit replaced, so an entry is never a procedure whose calls the plan inlines (`inlined_at_calls`). Generated code names nothing a program numbers: a Debug invariant check names its procedure by content @@ -22350,6 +22357,14 @@ serves it by, and the CLI suite checks that each offered key has one fingerprint across a build, an edited warm build, and the edited source's cold build. +Dev procedure ownership policy is explicit input to direct LIR lowering, not +inferred from cache hits, source locations, or the presence of code keys. +`LirPolicy` preserves it across shared-program consumer continuations. The +existing site-call stability rule still applies to consumers that infer base +signatures but use site code keys. Standalone dev procedures trade call-boundary +optimization for cache reuse; iterator fusion, tag-case fusion, TRMC, and loop +append promotion retain their existing semantics and eligibility. + ### Outcome-Conditioned Argument Restitution An owned direct-call argument may be returned to its caller as an ownership diff --git a/src/cli/main.zig b/src/cli/main.zig index 5aef76a9e6d..5533b3bb7b5 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -14269,7 +14269,7 @@ test "runtime specialization strategy helpers" { test "post-check optimization scope per opt level" { try std.testing.expectEqual(lir.CheckedPipeline.InlineMode.wrappers, postCheckInlineModeForOpt(.speed)); try std.testing.expectEqual(lir.CheckedPipeline.InlineMode.wrappers, postCheckInlineModeForOpt(.size)); - try std.testing.expectEqual(lir.CheckedPipeline.InlineMode.wrappers_and_source_single_use, postCheckInlineModeForOpt(.dev)); + try std.testing.expectEqual(lir.CheckedPipeline.InlineMode.procedures, postCheckInlineModeForOpt(.dev)); try std.testing.expectEqual(lir.CheckedPipeline.InlineMode.none, postCheckInlineModeForOpt(.interpreter)); try std.testing.expectEqual(lir.CheckedPipeline.SpecConstrCloneInlining.all_calls, specConstrCloneInliningForOpt(.speed)); try std.testing.expectEqual(lir.CheckedPipeline.SpecConstrCloneInlining.all_calls, specConstrCloneInliningForOpt(.size)); @@ -14436,6 +14436,7 @@ fn checkedRuntimeLoweringConfig( .list_in_place_map = listInPlaceMapForOpt(opt), .zero_dict_seed = zeroDictSeedForOpt(opt), .site_code_keys = siteCodeKeysForOpt(opt), + .owned_proc_signatures = opt == .dev, .tag_reachability = tagReachabilityForOpt(opt), .prove_ranges = proveRangesForOpt(opt), .scalarize_joins = optimizeLirForOpt(opt), @@ -14473,6 +14474,7 @@ test "native test generic procedures use the ordinary native dev representation try std.testing.expectEqual(build.target.erased_capture_prefix, tests.target.erased_capture_prefix); try std.testing.expectEqual(build.target.target_usize, tests.target.target_usize); try std.testing.expectEqual(build.target.inline_expects, tests.target.inline_expects); + try std.testing.expect(build.target.owned_proc_signatures); try std.testing.expectEqual(lir.CheckedPipeline.CodeProvision.source_bodies, tests.target.code_provision); try std.testing.expect(tests.target.spec_cache == null); } diff --git a/src/eval/compile_time_finalization.zig b/src/eval/compile_time_finalization.zig index 2b2046bf601..0210bcb57ee 100644 --- a/src/eval/compile_time_finalization.zig +++ b/src/eval/compile_time_finalization.zig @@ -1132,6 +1132,7 @@ fn compileTimeTarget(options: Options, solved_policy: lir.CheckedPipeline.Solved .proc_debug_names = true, .spec_cache = if (options.object_cache) |cache| cache.spec_cache else null, .site_code_keys = true, + .owned_proc_signatures = true, .code_provision = if (!compilerHostMustUseInterpreterForCtfe() and backend.host_lir_codegen_available and options.object_cache != null) .host_dev_objects else diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index e95af587ae1..aeeae6cde23 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -135,11 +135,9 @@ pub const WorkMetrics = struct { lir_continuations: u32 = 0, }; -/// The inlining a dev build specializes under. The object cache links dev -/// procedures into other programs, so dev decides inlining from source alone, -/// the same in every program; compile-time evaluation specializes under it -/// too, so its code is the code a dev build compiles. -pub const dev_inline_mode: InlineMode = .wrappers_and_source_single_use; +/// Dev keeps standalone procedures eligible for reuse in other programs. +/// Compile-time evaluation uses the same source-independent policy. +pub const dev_inline_mode: InlineMode = .procedures; /// SpecConstr's clone-inlining scope for dev builds and compile-time /// evaluation. @@ -219,6 +217,9 @@ pub const TargetConfig = struct { /// procedures' signatures freely. Required whenever `spec_cache` serves /// the program. site_code_keys: bool = false, + /// Keep the dev procedure ownership ABI independent of caller demand. + /// Optimized consumers leave this off and infer their base signatures. + owned_proc_signatures: bool = false, /// Keep every keyed specialization procedure through compaction; a pack /// program offers them from its manifest whether or not its export /// wrappers inlined their calls. @@ -1138,6 +1139,7 @@ pub const LirPolicy = struct { proc_debug_names: bool, spec_cache: ?postcheck.Common.SpecCacheLookup, site_code_keys: bool, + owned_proc_signatures: bool, code_provision: CodeProvision, keep_specialization_procs: bool, promote_loop_appends: base.TestSwitch(true), @@ -1869,6 +1871,7 @@ fn generateConsumerLir(prepared: *PreparedSolved, consumer: Consumer) LowerResou .spec_cache = if (target.code_provision.permitsNativeObjects()) target.spec_cache else null, .spec_cache_takes = target.code_provision.specCacheTakes(), .site_code_keys = target.site_code_keys, + .owned_proc_signatures = target.owned_proc_signatures, .inline_plan = prepared.inline_plan.view(), .keep_specialization_procs = target.keep_specialization_procs, .post_check_executor = target.post_check_executor, @@ -2398,6 +2401,17 @@ fn checkedPipelineInvariant(comptime message: []const u8) noreturn { base.invariant("checked pipeline invariant violated: {s}", .{message}); } +test "LIR ownership policy survives shared consumer continuation without a cache" { + for ([_]bool{ false, true }) |owned| { + const producer: TargetConfig = .{ .owned_proc_signatures = owned }; + const policy = LirPolicy.fromTarget(producer); + var consumer: TargetConfig = .{ .owned_proc_signatures = !owned }; + policy.applyTo(&consumer); + try std.testing.expectEqual(owned, consumer.owned_proc_signatures); + try std.testing.expect(consumer.spec_cache == null); + } +} + test "checked pipeline declarations are referenced" { std.testing.refAllDecls(@This()); } diff --git a/src/postcheck/solved_inline.zig b/src/postcheck/solved_inline.zig index 1b18caa4913..8ed7f149dde 100644 --- a/src/postcheck/solved_inline.zig +++ b/src/postcheck/solved_inline.zig @@ -13,13 +13,14 @@ const GuardedList = collections.GuardedList; /// Post-check inline analysis mode. pub const Mode = enum { none, + /// Retain standalone procedures, with iterator fusion in SpecConstr. + procedures, /// Inline wrappers, and bodies the whole program calls exactly once. wrappers, /// Inline wrappers, and bodies whose module's source calls them at /// exactly one site (`FnTemplate.single_source_call`). Both depend only on /// source, never on which other procedures a program contains, so every - /// program inlines the same calls. Dev builds use this: the object cache - /// links a dev procedure into programs other than the one compiling it. + /// program inlines the same calls, including separately compiled callers. wrappers_and_source_single_use, }; @@ -109,7 +110,7 @@ pub fn analyze( keep_keyed_specializations: bool, ) std.mem.Allocator.Error!OwnedPlan { return switch (mode) { - .none => OwnedPlan.empty(allocator), + .none, .procedures => OwnedPlan.empty(allocator), .wrappers => try InlineAnalyzer.run(allocator, procedure_usage, solved, keep_keyed_specializations, .program), .wrappers_and_source_single_use => try InlineAnalyzer.run(allocator, procedure_usage, solved, keep_keyed_specializations, .source), }; diff --git a/src/postcheck/solved_lir_lower.zig b/src/postcheck/solved_lir_lower.zig index 2f5d78f4b54..942ec3e1a6b 100644 --- a/src/postcheck/solved_lir_lower.zig +++ b/src/postcheck/solved_lir_lower.zig @@ -185,6 +185,8 @@ pub const Options = struct { /// (`CheckedPipeline.TargetConfig.site_code_keys`); required whenever /// `spec_cache` is set. site_code_keys: bool = false, + /// Explicit dev ownership policy, independent of cache availability. + owned_proc_signatures: bool = false, inline_plan: SolvedInline.Plan = .{}, /// Lower every keyed specialization's procedure body, even one whose every /// call was inlined, so a pack program can offer it. @@ -908,6 +910,7 @@ const Lowerer = struct { spec_cache: ?Common.SpecCacheLookup, spec_cache_takes: Common.SpecCacheTakes, site_code_keys: bool, + owned_proc_signatures: bool, /// The module identities of this program's source files, which the /// object cache serves entries for (`Common.ProgramModules`). program_modules: Common.ProgramModules = &.{}, @@ -1282,6 +1285,7 @@ const Lowerer = struct { .spec_cache = options.spec_cache, .spec_cache_takes = options.spec_cache_takes, .site_code_keys = options.site_code_keys, + .owned_proc_signatures = options.owned_proc_signatures, .root_manifest = options.root_manifest, .fn_queue_index = 0, .initializer_queue_index = 0, @@ -1500,7 +1504,8 @@ const Lowerer = struct { self.result.deinit(); } - /// Mark every procedure with a code key that code without one can call + /// The explicit owned-ABI policy pins every procedure. Otherwise, mark + /// every procedure with a code key that code without one can call /// (`LIR.LirProcSpec.stable_signature`): a member of a lambda set in the /// signature of a procedure without a code key, or an app function such /// a procedure's code reaches (`Lifted.Program.appCalleesReachedBy`). @@ -1510,6 +1515,13 @@ const Lowerer = struct { /// from a pack, so every program marks the same procedures. Every /// specialization of a marked source function is marked. fn markStableSignatures(self: *Lowerer) Common.LowerError!void { + if (self.owned_proc_signatures) { + for (self.fn_entries.items) |entry| { + const proc = entry.proc orelse continue; + self.result.store.getProcSpecPtr(proc).stable_signature = true; + } + return; + } // Without code keys no procedure is stable. if (!self.site_code_keys) return; const stable_sources = try self.allocator.alloc(bool, self.solved.lifted.fnCount()); From a356cfffb94a35dc6e17bb28a9b07726187304c3 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 10:40:38 -0400 Subject: [PATCH 44/97] Retain source eligibility facts for appended native artifacts --- design.md | 6 +++ src/backend/dev/CodeExcerpt.zig | 2 + src/cli/pack_store.zig | 69 ++++++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 9 deletions(-) diff --git a/design.md b/design.md index 75928360d99..d5373385a27 100644 --- a/design.md +++ b/design.md @@ -1068,6 +1068,12 @@ it. Source eligibility and linked-entry facts extend over the appended suffix, using settled dependency facts. Decoded component owners and validated record leases outlive every captured set that refers to their provenance. +Source eligibility retains exact program membership per appended source-file +identity. Locations consume those file facts instead of searching the program's +module list for each line. With no marked seed anywhere in a splice graph, +reachability propagation has no work; a marked seed still receives the complete +dependency propagation. Allocation failure preserves only completed prefixes. + Payloads and metadata share content-addressed catalog storage under the local zero-provider identity within the procedure domain. They are independent of the producing module and obey the local native-cache retention window. Offers still route to their explicit diff --git a/src/backend/dev/CodeExcerpt.zig b/src/backend/dev/CodeExcerpt.zig index 7215d401593..8d28cd7d26d 100644 --- a/src/backend/dev/CodeExcerpt.zig +++ b/src/backend/dev/CodeExcerpt.zig @@ -2235,6 +2235,8 @@ pub fn markReferrers(allocator: Allocator, set: *const Set, marked: []bool, proc pub fn markNewReferrers(allocator: Allocator, set: *const Set, marked: []bool, first: usize, proc_symbols: ProcSymbols) Allocator.Error!void { std.debug.assert(marked.len == set.excerpts.len and first <= marked.len); if (first == marked.len) return; + // With no seed anywhere in the graph, no dependency can propagate one. + if (std.mem.indexOfScalar(bool, marked, true) == null) return; var by_proc = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator); defer by_proc.deinit(); var by_thunk = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator); diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 4badc9b4864..b630db41865 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -1016,7 +1016,8 @@ pub const LoadedPacks = struct { /// Per dev pack and asking program, whether a splice of each artifact /// would place a line or a location in a module version the program does /// not lower against (`linesOutsideProgram`). - lines_outside: std.AutoHashMapUnmanaged(ProgramPack, std.ArrayList(bool)) = .empty, + lines_outside: std.AutoHashMapUnmanaged(ProgramPack, SourceEligibility) = .empty, + /// Per object pack, whether the program takes one of its entries, so it /// links the pack's object; settled from the lowered program /// (`settleLinkedObjects`). @@ -1047,6 +1048,16 @@ pub const LoadedPacks = struct { state: enum { pending, ready, unavailable } = .pending, failure: ?LoadError = null, + const SourceEligibility = struct { + present_files: std.ArrayList(bool) = .empty, + marks: std.ArrayList(bool) = .empty, + + fn deinit(self: *SourceEligibility, allocator: Allocator) void { + self.present_files.deinit(allocator); + self.marks.deinit(allocator); + } + }; + fn recordOwner(self: *LoadedPacks, io: std.Io) Allocator.Error!*base.retained_cache.Owner { if (self.record_owner) |owner| return owner; const owner = try base.retained_cache.Owner.create(self.allocator, io, self.record_domain); @@ -2204,17 +2215,25 @@ pub const LoadedPacks = struct { fn sourceMarks(self: *LoadedPacks, set: *const backend.dev.CodeExcerpt.Set, modules: Common.ProgramModules) Allocator.Error![]bool { const slot = try self.lines_outside.getOrPut(self.allocator, ProgramPack.of(set, modules)); - if (!slot.found_existing) slot.value_ptr.* = .empty; - const first = slot.value_ptr.items.len; - if (first == set.excerpts.len) return slot.value_ptr.items; + if (!slot.found_existing) slot.value_ptr.* = .{}; + const facts = slot.value_ptr; + const first = facts.marks.items.len; + if (first == set.excerpts.len) return facts.marks.items; + // File identities and their bindings are immutable as the set grows. + // Test each new file once, rather than once per source location. + const old_files = facts.present_files.items.len; + try facts.present_files.ensureTotalCapacity(self.allocator, set.source_files.len); + for (set.source_files[old_files..]) |identity| { + facts.present_files.appendAssumeCapacity(Common.programHasModule(modules, identity)); + } // Keep the old prefix on allocation failure; retry only the new suffix. - try slot.value_ptr.resize(self.allocator, set.excerpts.len); - errdefer slot.value_ptr.items.len = first; - const marks = slot.value_ptr.items; + try facts.marks.resize(self.allocator, set.excerpts.len); + errdefer facts.marks.items.len = first; + const marks = facts.marks.items; for (set.excerpts[first..], marks[first..]) |artifact, *marked| { - marked.* = artifact.loc.hasLocation() and !Common.programHasModule(modules, set.source_files[artifact.loc.file]); + marked.* = artifact.loc.hasLocation() and !facts.present_files.items[artifact.loc.file]; for (artifact.lines) |line| { - if (line.loc.hasLocation() and !Common.programHasModule(modules, set.source_files[line.loc.file])) marked.* = true; + if (line.loc.hasLocation() and !facts.present_files.items[line.loc.file]) marked.* = true; } } try backend.dev.CodeExcerpt.markNewReferrers(self.allocator, set, marks, first, .skip); @@ -2568,6 +2587,38 @@ const IndexFixture = struct { } }; +test "indexed source eligibility extends file facts and keeps programs independent" { + const F = IndexFixture; + var packs = LoadedPacks.init(F.allocator); + defer packs.deinit(); + var artifacts = [_]backend.dev.CodeExcerpt.Excerpt{ F.excerpt(1, "first"), F.excerpt(2, "second"), F.excerpt(3, "caller") }; + artifacts[0].loc = .{ .file = 0, .line = 1, .column = 1 }; + artifacts[1].lines = &.{.{ .offset = 0, .loc = .{ .file = 1, .line = 2, .column = 1 }, .region = base.Region.zero() }}; + artifacts[2].refs = &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}; + const files = [_][32]u8{ @splat(7), @splat(8) }; + var set = backend.dev.CodeExcerpt.Set{ .arena = undefined, .excerpts = artifacts[0..1], .source_files = files[0..1] }; + try std.testing.expectEqualSlices(bool, &.{false}, try packs.sourceMarks(&set, &.{files[0]})); + set.excerpts = &artifacts; + set.source_files = &files; + try std.testing.expectEqualSlices(bool, &.{ false, true, true }, try packs.sourceMarks(&set, &.{files[0]})); + try std.testing.expectEqualSlices(bool, &.{ true, false, false }, try packs.sourceMarks(&set, &.{files[1]})); + try std.testing.expectEqualSlices(bool, &.{ false, true, true }, try packs.sourceMarks(&set, &.{files[0]})); +} + +test "indexed graph without marked seeds needs no propagation allocation" { + const F = IndexFixture; + var artifacts = [_]backend.dev.CodeExcerpt.Excerpt{ F.excerpt(1, "caller"), F.excerpt(2, "callee") }; + artifacts[0].refs = &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}; + const set = backend.dev.CodeExcerpt.Set{ .arena = undefined, .excerpts = &artifacts }; + var marked = [_]bool{ false, false }; + var failures = std.testing.FailingAllocator.init(F.allocator, .{ .fail_index = 0 }); + try backend.dev.CodeExcerpt.markNewReferrers(failures.allocator(), &set, &marked, 0, .skip); + try std.testing.expectEqualSlices(bool, &.{ false, false }, &marked); + marked[1] = true; + try backend.dev.CodeExcerpt.markNewReferrers(F.allocator, &set, &marked, 0, .skip); + try std.testing.expectEqualSlices(bool, &.{ true, true }, &marked); +} + test "indexed object cache reads only requested closure regardless of unrelated history" { var fixture: IndexFixture = undefined; try fixture.init(); From a47a8872083720c5b925e80f46667ecf55b202d0 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 10:54:52 -0400 Subject: [PATCH 45/97] Retain synchronized catalog mappings across native artifact acquisitions --- design.md | 9 +++++ src/compile/cache_index.zig | 77 +++++++++++++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/design.md b/design.md index d5373385a27..c4f402cc644 100644 --- a/design.md +++ b/design.md @@ -27274,6 +27274,15 @@ read outside shard ownership. Retention leases remain independent of catalog ownership. The session owns its descriptors and path until its acquisition owner releases the final retained records. +The session reads checked catalog nodes through a retained read-only mapping, +while headers and redo records still use positional I/O. Every reacquisition +checks the catalog's current length before using mapped pages. Growth remaps +only when a requested node lies beyond the current mapping. Local writes and +external commits synchronize retained mapping contents through the I/O provider; +a failed refresh discards the mapping. Record bounds and checksums remain +mandatory. The mapping never substitutes for shard ownership or generation +leases. Counters distinguish mapped node reads from positional reads. + Lambda Solved retains completed erased-callable reachability markings only for the exact graph mutation epoch in which a walk made no changes. A later query diff --git a/src/compile/cache_index.zig b/src/compile/cache_index.zig index 3b4e1b823fa..e839a348c65 100644 --- a/src/compile/cache_index.zig +++ b/src/compile/cache_index.zig @@ -1,4 +1,4 @@ -//! Bounded, positional-I/O catalog for the native artifact store. +//! Bounded catalog with positional writes and retained read mappings. //! //! Four independent crit-bit indexes share fixed-size records. A key has a //! fixed width, so a lookup visits at most key_bits branch records. Mutations @@ -35,6 +35,7 @@ pub const Error = error{ IoFailure, CorruptCatalog, CapacityExceeded }; /// Observable work, including recovery. Tests assert bounds on actual I/O. pub const Counters = struct { reads: usize = 0, + mapped_reads: usize = 0, writes: usize = 0, read_bytes: usize = 0, written_bytes: usize = 0, @@ -135,6 +136,7 @@ pub const Index = struct { header: Header, counters: *Counters, read_cache: ?*ReadCache = null, + mapped: ?Io.File.MemoryMap = null, /// `dir` and the shard lock are borrowed. The lock file is separate from /// both files that this protocol replaces or truncates. @@ -155,20 +157,46 @@ pub const Index = struct { /// Reacquire the current published catalog under the kernel shard lock. /// Any external transaction invalidates the invocation's decoded pages. pub fn refresh(self: *Index) Error!void { - errdefer if (self.read_cache) |cache| cache.clear(); + errdefer { + if (self.read_cache) |cache| cache.clear(); + self.dropMapping(); + } try self.recover(); const current = try Header.decode(&try self.readRecord(0)); if ((self.file.length(self.io) catch return error.IoFailure) < current.next * record_size) return error.CorruptCatalog; - if (current.sequence != self.header.sequence) if (self.read_cache) |cache| cache.clear(); + if (current.sequence != self.header.sequence) { + if (self.read_cache) |cache| cache.clear(); + if (self.mapped) |*mapped| mapped.read(self.io) catch return error.IoFailure; + } self.header = current; } + fn dropMapping(self: *Index) void { + if (self.mapped) |*mapped| mapped.destroy(self.io); + self.mapped = null; + } + pub fn close(self: *Index) void { + self.dropMapping(); self.file.close(self.io); } fn readRecord(self: *Index, id: RecordId) Error!Record { if (id > std.math.maxInt(u64) / record_size) return error.CorruptCatalog; + if (id != 0 and self.read_cache != null) { + if (self.mapped) |mapped| { + if (id * record_size >= mapped.memory.len) self.dropMapping(); + } + if (self.mapped == null) { + const len = std.math.cast(usize, self.header.next * record_size) orelse return error.CapacityExceeded; + self.mapped = self.file.createMemoryMap(self.io, .{ .len = len, .protection = .{ .read = true }, .populate = false }) catch return error.IoFailure; + } + const memory = self.mapped.?.memory; + const offset = std.math.cast(usize, id * record_size) orelse return error.CorruptCatalog; + if (offset > memory.len or memory.len - offset < record_size) return error.CorruptCatalog; + self.counters.mapped_reads += 1; + return memory[offset..][0..record_size].*; + } var bytes: Record = undefined; self.counters.reads += 1; self.counters.read_bytes += record_size; @@ -183,6 +211,7 @@ pub const Index = struct { self.counters.writes += 1; self.counters.written_bytes += record_size; self.file.writePositionalAll(self.io, &bytes, id * record_size) catch return error.IoFailure; + if (self.mapped) |*mapped| mapped.read(self.io) catch return error.IoFailure; } pub fn node(self: *Index, id: RecordId) Error!Node { @@ -674,3 +703,45 @@ test "catalog sessions reuse decoded pages and invalidate external commits and i var last = index.transaction(); try std.testing.expectEqual(@as(Value, @splat(4)), (try last.find(.artifacts, key)).?.value); } + +test "mapped catalog validates growth and rejects truncation before reading retained memory" { + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var counters: Counters = .{}; + var index = try Index.open(tmp.dir, io, &counters); + defer index.close(); + var cache: ReadCache = .{}; + index.read_cache = &cache; + const first: Key = @splat(1); + { + var tx = index.transaction(); + _ = try tx.putValue(.artifacts, first, @splat(3)); + try tx.commit(); + } + { + var tx = index.transaction(); + _ = (try tx.find(.artifacts, first)).?; + try std.testing.expect(index.mapped != null); + } + const old_len = index.mapped.?.memory.len; + { + var other = try Index.open(tmp.dir, io, &counters); + defer other.close(); + for (2..24) |number| { + var tx = other.transaction(); + _ = try tx.putValue(.artifacts, @splat(@intCast(number)), @splat(7)); + try tx.commit(); + } + } + try index.refresh(); + try std.testing.expect(index.mapped != null); + { + var tx = index.transaction(); + try std.testing.expectEqual(@as(Value, @splat(7)), (try tx.find(.artifacts, @splat(23))).?.value); + try std.testing.expect(index.mapped.?.memory.len > old_len); + } + try index.file.setLength(io, record_size); + try std.testing.expectError(error.CorruptCatalog, index.refresh()); + try std.testing.expect(index.mapped == null); +} From fc2d9fd687694aa7c63eb486f5faa57b8d23cd64 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 10:58:08 -0400 Subject: [PATCH 46/97] Share immutable expansion reachability across argument scopes --- design.md | 8 +++++ src/postcheck/lambda_solved/solve.zig | 46 ++++++++++++++++++++++++--- 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/design.md b/design.md index c4f402cc644..85f24b4557e 100644 --- a/design.md +++ b/design.md @@ -18404,6 +18404,14 @@ bindings require structural expansion. Non-generic named types do not open argument scopes. This preserves argument/backing row ties without expanding an entire state graph merely because it occurs inside a generic wrapper. +The solver computes the exact set of argument positions reachable during a +Monotype's expansion once, independently of any argument scope. A query +intersects that immutable set with the scope's bindings. The expansion root +itself is not an argument position; nested named types are positions but their +children belong to the nested type's own scope. Adding a binding invalidates +that scope's answer memo without invalidating immutable reachability. Replacing +a bound destination changes neither membership nor reachability. + Unification never splits a class. A pair's deferred link runs after the pair's children are unified, and in a cyclic type those children can bring either endpoint into another class first; for example, each generated interpolation diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index a59a73e960c..e347daa6202 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -356,6 +356,7 @@ const Solver = struct { /// that argument: a row joined through the backing is the argument's row /// too (design.md "Row Joins"). arg_scopes: std.ArrayList(ArgumentScope), + expansion_arg_reachability: collections.DenseMapUnmanaged(MonoType.TypeId, []MonoType.TypeId) = .empty, /// The argument scope a leaf created under a scoped expansion reads its /// positional components from. leaf_arg_scopes: collections.DenseMap(Type.TypeVarId, u32), @@ -613,6 +614,9 @@ const Solver = struct { self.leaf_context_shapes.deinit(self.allocator); for (self.arg_scopes.items) |*scope| scope.deinit(); self.arg_scopes.deinit(self.allocator); + var reachability = self.expansion_arg_reachability.valueIterator(); + while (reachability.next()) |ids| self.allocator.free(ids.*); + self.expansion_arg_reachability.deinit(self.allocator); self.leaf_arg_scopes.deinit(); self.reaches_cycle.deinit(self.allocator); self.shared_clones.deinit(); @@ -3018,12 +3022,28 @@ const Solver = struct { self.countWork(.arg_scope_hits, 1); return answer; } - const answer = try self.walkExpansionArgScope(root, scope); + const reachable = try self.expansionArgReachability(root); + var bindings = self.arg_scopes.items[scope].bindings.keyIterator(); + const answer = found: { + while (bindings.next()) |binding| { + var lo: usize = 0; + var hi: usize = reachable.len; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + if (@backingInt(reachable[mid]) < @backingInt(binding.*)) lo = mid + 1 else hi = mid; + } + if (lo < reachable.len and reachable[lo] == binding.*) break :found true; + } + break :found false; + }; try self.arg_scopes.items[scope].reads.put(root, answer); return answer; } - fn walkExpansionArgScope(self: *Solver, root: MonoType.TypeId, scope: u32) Allocator.Error!bool { + /// The exact immutable positions at which expansion can read a binding. + /// Every scope asks about this same graph, stopping at nested named types. + fn expansionArgReachability(self: *Solver, root: MonoType.TypeId) Allocator.Error![]const MonoType.TypeId { + if (self.expansion_arg_reachability.get(root)) |known| return known; var seen = self.mono_set_pool.acquire(); defer self.mono_set_pool.release(&seen); var pending = std.ArrayList(MonoType.TypeId).empty; @@ -3034,7 +3054,7 @@ const Solver = struct { if (seen.contains(id)) continue; try seen.put(id, {}); self.countWork(.arg_scope_walk_nodes, 1); - if (id != root and self.arg_scopes.items[scope].contains(id)) return true; + if (self.leafSharing(id) != .positional) continue; switch (types.get(id)) { .named, .primitive, .zst, .erased, .func, .hole => {}, @@ -3049,7 +3069,18 @@ const Solver = struct { }, } } - return false; + _ = seen.remove(root); + const reachable = try self.allocator.alloc(MonoType.TypeId, seen.count()); + errdefer self.allocator.free(reachable); + var ids = seen.keyIterator(); + for (reachable) |*id| id.* = ids.next().?.*; + std.mem.sort(MonoType.TypeId, reachable, {}, struct { + fn less(_: void, a: MonoType.TypeId, b: MonoType.TypeId) bool { + return @backingInt(a) < @backingInt(b); + } + }.less); + try self.expansion_arg_reachability.put(self.allocator, root, reachable); + return reachable; } fn linkUntouchedLeafToInstance( @@ -6772,6 +6803,13 @@ test "argument scope expansion queries reuse exact answers and invalidate on new try std.testing.expectEqual(changed_walked, counts.arg_scope_walk_nodes); const independent = try solver.newArgScope(); try std.testing.expect(!try solver.expansionReadsArgScope(shape, independent)); + try std.testing.expectEqual(walked, counts.arg_scope_walk_nodes); + // A root binding does not count as a position within its own expansion. + try solver.arg_scopes.items[independent].put(shape, node); + try std.testing.expect(!try solver.expansionReadsArgScope(shape, independent)); + try solver.arg_scopes.items[independent].put(row, node); + try std.testing.expect(try solver.expansionReadsArgScope(shape, independent)); + try std.testing.expectEqual(walked, counts.arg_scope_walk_nodes); } test "exact cyclic clone proof rejects unexpanded leaves that hide a mutable tie" { From 5b13041e04b819a94eed4b9eb32102ec3374757f Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 11:35:28 -0400 Subject: [PATCH 47/97] Preserve iterator normalization scope in cached procedure identities --- design.md | 7 ++++ src/backend/dev/PackFile.zig | 12 ++++++- src/cli/main.zig | 4 +++ src/cli/pack_store.zig | 16 +++++++-- src/postcheck/common.zig | 2 ++ src/postcheck/monotype_lifted/lift.zig | 47 ++++++++++++++++++++++++++ 6 files changed, 85 insertions(+), 3 deletions(-) diff --git a/design.md b/design.md index 85f24b4557e..aa08e31cdf2 100644 --- a/design.md +++ b/design.md @@ -1038,6 +1038,13 @@ domain. The index broadens no compatibility beyond the declared domain, and a record from a shared domain elides a body only for a consumer with the exact same provider capability (`CodeProvision`). +A served procedure also carries the producer's iterator-normalization scope. +Closure lifting restores that scope from the cache before computing lifted +source identity. An elided body cannot acquire its producer's scope by running +normalization again. Native pack publication, retention, linked entries and +spliced entries preserve the same explicit scope fact; omitting it changes +procedure identity even when the native bytes and request key are unchanged. + Within the procedure domain, catalog variants separate offers, metadata, payloads, completion markers and interface summaries. Offers are grouped by specialization key and keyed by the digest of their complete record. Each diff --git a/src/backend/dev/PackFile.zig b/src/backend/dev/PackFile.zig index 240a9db4768..d70e049c66a 100644 --- a/src/backend/dev/PackFile.zig +++ b/src/backend/dev/PackFile.zig @@ -23,7 +23,7 @@ const Allocator = std.mem.Allocator; const magic = "RPCK"; /// Format version; bump whenever the encoding or artifact contents change. -pub const format_version: u32 = 18; +pub const format_version: u32 = 19; /// One specialization the pack can serve: its reservation-time key, the /// excerpt holding its procedure, and the ownership signature and @@ -36,6 +36,8 @@ pub const SpecEntry = struct { test_compatible: bool = false, /// Whether the native body can return before an erased call completes. may_leave_pending: bool = false, + /// Retained even when Monotype elides the producer body. + iterator_fusion_scope: bool = false, /// The compile-time roots the procedure's code reads, which a linking /// program demands (`Common.SpecCacheHit.comptime_root_reads`). comptime_root_reads: []const lir.CheckedPipeline.ComptimeRootRead = &.{}, @@ -680,6 +682,7 @@ fn writeSpecs(writer: *Writer, specs: []const SpecEntry) Allocator.Error!void { try writer.word(spec.excerpt); try writer.byte(@intFromBool(spec.test_compatible)); try writer.byte(@intFromBool(spec.may_leave_pending)); + try writer.byte(@intFromBool(spec.iterator_fusion_scope)); try writer.word(@intCast(spec.comptime_root_reads.len)); for (spec.comptime_root_reads) |root_read| { try writer.raw(&root_read.module); @@ -714,6 +717,11 @@ fn readSpecs(reader: *Reader, arena_allocator: Allocator, count: u32, excerpt_co 1 => true, else => return error.MalformedPack, }, + .iterator_fusion_scope = switch (try reader.byte()) { + 0 => false, + 1 => true, + else => return error.MalformedPack, + }, .comptime_root_reads = reads: { const reads = try arena_allocator.alloc(lir.CheckedPipeline.ComptimeRootRead, try reader.word()); for (reads) |*root_read| root_read.* = .{ .module = (try reader.raw(32))[0..32].*, .root = try reader.word() }; @@ -921,6 +929,7 @@ test "pack bytes round-trip every excerpt field and spec entry" { .rc_ret_unique = true, .rc_ret_unique_fields = 0b10, .may_leave_pending = true, + .iterator_fusion_scope = true, .rc_ret_conditions = &.{ 0x0001_02ff, 0x0000_0403 }, .comptime_root_reads = &.{ .{ .module = @as([32]u8, @splat(0x11)), .root = 3 }, @@ -946,6 +955,7 @@ test "pack bytes round-trip every excerpt field and spec entry" { try testing.expect(pack.specs[0].rc_ret_unique); try testing.expectEqual(specs[0].rc_ret_unique_fields, pack.specs[0].rc_ret_unique_fields); try testing.expect(pack.specs[0].may_leave_pending); + try testing.expect(pack.specs[0].iterator_fusion_scope); try testing.expectEqualSlices(u32, specs[0].rc_ret_conditions, pack.specs[0].rc_ret_conditions); try testing.expectEqualDeep(specs[0].comptime_root_reads, pack.specs[0].comptime_root_reads); const proc = pack.set.excerpts[0]; diff --git a/src/cli/main.zig b/src/cli/main.zig index 5533b3bb7b5..ee49ab3aae9 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -8866,6 +8866,7 @@ fn objectPackBytes( .rc_ret_unique = proc.rc_ret_unique, .rc_ret_unique_fields = proc.rc_ret_unique_fields, .may_leave_pending = proc.may_leave_pending, + .iterator_fusion_scope = proc.iterator_fusion_scope, .rc_ret_conditions = try GuardedList.dupe(arena, u32, store.getU32Span(proc.rc_ret_conditions)), }); } @@ -9165,6 +9166,7 @@ const PackOffers = struct { withheld: bool, test_compatible: bool, may_leave_pending: bool, + iterator_fusion_scope: bool, rc_borrowed_params: u64, rc_ret_borrowed: bool, rc_ret_lenders: u64, @@ -9286,6 +9288,7 @@ const PackOffers = struct { .rc_ret_unique = proc.rc_ret_unique, .rc_ret_unique_fields = proc.rc_ret_unique_fields, .may_leave_pending = proc.may_leave_pending, + .iterator_fusion_scope = proc.iterator_fusion_scope, .rc_ret_conditions = try GuardedList.dupe(arena, u32, store.getU32Span(proc.rc_ret_conditions)), .name = try arena.dupe(u8, store.procDebugName(id) orelse ""), .fingerprint = fingerprint, @@ -9338,6 +9341,7 @@ const PackOffers = struct { .rc_ret_unique = offer.rc_ret_unique, .rc_ret_unique_fields = offer.rc_ret_unique_fields, .may_leave_pending = offer.may_leave_pending, + .iterator_fusion_scope = offer.iterator_fusion_scope, .rc_ret_conditions = offer.rc_ret_conditions, }); } diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index b630db41865..f8850decedf 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -494,6 +494,7 @@ test "object cache loading owns decoded packs and commits both indexes atomicall const bytes = try PackFile.write(allocator, &set, &.{.{ .key = key, .excerpt = 0, + .iterator_fusion_scope = true, .rc_borrowed_params = 1, .rc_ret_borrowed = false, .rc_ret_lenders = 0, @@ -515,7 +516,9 @@ test "object cache loading owns decoded packs and commits both indexes atomicall try std.testing.expectEqual(@as(usize, 1), packs.packs.items.len); packs.indexPacks() catch |err| return rejected(&packs, expected_key, err); - const hit = packs.specCacheLookup().lookup(expected_key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + // This fault-injection boundary covers loading and atomic indexing. + // Admission can allocate separately and decline an offer on OOM. + const hit = packs.specs.get(expected_key) orelse return error.TestUnexpectedResult; try std.testing.expectEqualSlices(u8, &expected_identity.bytes, &hit.identity); try std.testing.expectEqual(@as(u64, 1), hit.rc_borrowed_params); // A consumer that cannot splice is never served dev excerpts. @@ -551,6 +554,13 @@ test "object cache loading owns decoded packs and commits both indexes atomicall // layout, so the failure check runs over an allocator that refuses it. var deterministic = @import("base").DeterministicAllocator.init(allocator); try std.testing.checkAllAllocationFailures(deterministic.allocator(), Attempt.run, .{ dir_path, key, identity }); + var admitted = LoadedPacks.init(allocator); + defer admitted.deinit(); + try admitted.loadDirInto(io, dir_path, .cache_offers); + try admitted.indexPacks(); + const hit = admitted.specCacheLookup().lookup(key, .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqualSlices(u8, &identity.bytes, &hit.identity); + try std.testing.expect(hit.iterator_fusion_scope); try std.testing.expectError(error.UnsupportedPackVersion, LoadedPacks.loadDir(allocator, io, dir_path)); } try tmp.dir.createDirPath(io, "malformed"); @@ -1091,7 +1101,7 @@ pub const LoadedPacks = struct { const origin = old.set.excerpts[old.index].metadata_origin orelse return false; if (!std.mem.eql(u8, &origin.record.owner.domain, &store.domain) or !std.mem.eql(u8, &origin.record.digest, &component) or origin.index != ordinal) return false; const hit = self.specs.get(spec.key) orelse return false; - if (hit.may_leave_pending != spec.may_leave_pending or hit.rc_borrowed_params != spec.rc_borrowed_params or hit.rc_ret_borrowed != spec.rc_ret_borrowed or + if (hit.iterator_fusion_scope != spec.iterator_fusion_scope or hit.may_leave_pending != spec.may_leave_pending or hit.rc_borrowed_params != spec.rc_borrowed_params or hit.rc_ret_borrowed != spec.rc_ret_borrowed or hit.rc_ret_lenders != spec.rc_ret_lenders or hit.rc_read_only_params != spec.rc_read_only_params or hit.rc_ret_unique != spec.rc_ret_unique or hit.rc_ret_unique_fields != spec.rc_ret_unique_fields or !std.mem.eql(u32, hit.rc_ret_conditions, spec.rc_ret_conditions) or hit.comptime_root_reads.len != spec.comptime_root_reads.len) return false; @@ -1768,6 +1778,7 @@ pub const LoadedPacks = struct { .rc_ret_unique = spec.rc_ret_unique, .rc_ret_unique_fields = spec.rc_ret_unique_fields, .may_leave_pending = spec.may_leave_pending, + .iterator_fusion_scope = spec.iterator_fusion_scope, .rc_ret_conditions = spec.rc_ret_conditions, }); } @@ -1794,6 +1805,7 @@ pub const LoadedPacks = struct { .rc_ret_unique = spec.rc_ret_unique, .rc_ret_unique_fields = spec.rc_ret_unique_fields, .may_leave_pending = spec.may_leave_pending, + .iterator_fusion_scope = spec.iterator_fusion_scope, .rc_ret_conditions = spec.rc_ret_conditions, }, .pack = @intCast(pack_index), diff --git a/src/postcheck/common.zig b/src/postcheck/common.zig index 00e1bb5210f..8ff8e850fd3 100644 --- a/src/postcheck/common.zig +++ b/src/postcheck/common.zig @@ -456,6 +456,8 @@ pub const SpecCacheHit = struct { identity: [32]u8, /// The producer's tail-drive summary for this procedure's native body. may_leave_pending: bool = false, + /// Producer-authored iterator normalization scope, part of source identity. + iterator_fusion_scope: bool = false, /// The entry is a linked object reached through its C-ABI entry rather /// than spliced code (`LIR.LirProcSpec.c_abi_entry`). c_abi_entry: bool = false, diff --git a/src/postcheck/monotype_lifted/lift.zig b/src/postcheck/monotype_lifted/lift.zig index 2dcfdd7a180..a121c118dc6 100644 --- a/src/postcheck/monotype_lifted/lift.zig +++ b/src/postcheck/monotype_lifted/lift.zig @@ -576,6 +576,9 @@ const Lifter = struct { self.output.setFn(fn_id, .{ .symbol = def.symbol, .source = source, + // A served body already passed this normalization. Its scope is + // part of source identity even though no body remains to rewrite. + .iterator_fusion_scope = if (source) |template| if (template.cached) |hit| hit.iterator_fusion_scope else false else false, .root_identity = def.root_identity, .signature = if (def.fn_id) |source_fn_id| switch (self.source.fnSignatureRelation(source_fn_id)) { .independent_roots => null, @@ -3227,3 +3230,47 @@ test "if_initialized_payload reads its payload as a free local" { test "monotype lifted lower declarations are referenced" { std.testing.refAllDecls(@This()); } + +test "lift restores cached iterator normalization scope before source identity is computed" { + const a = std.testing.allocator; + for ([_]bool{ false, true }) |normalized| { + var mono = Mono.Program.init(a); + var owned = true; + defer if (owned) mono.deinit(); + const result = try mono.types.add(.zst); + const function = try mono.types.add(.{ .func = .{ .args = .empty(), .ret = result } }); + const template: Mono.FnTemplate = .{ + .fn_def = .{ .local_template = .{ + .artifact = .{}, + .proc_base = try mono.names.internProcBase(.{ + .module_name = try mono.names.internModuleName("Cached"), + .export_name = null, + .kind = .checked_source, + .ordinal = 0, + }), + .template = @fromBackingInt(0), + } }, + .source_fn_ty = @fromBackingInt(0), + .source_fn_key = .{}, + .mono_fn_ty = function, + .cached = .{ + .identity = @splat(1), + .iterator_fusion_scope = normalized, + .rc_borrowed_params = 0, + .rc_ret_borrowed = false, + .rc_ret_lenders = 0, + .rc_read_only_params = 0, + .rc_ret_unique = false, + .rc_ret_unique_fields = 0, + .rc_ret_conditions = &.{}, + }, + }; + const fn_id = try mono.addFn(template); + _ = try mono.addDef(.{ .symbol = @fromBackingInt(0), .fn_id = fn_id, .fn_def = template, .args = .empty(), .body = .hosted, .ret = result }); + mono.freeze(); + owned = false; + var lifted = try run(a, mono); + defer lifted.deinit(); + try std.testing.expectEqual(normalized, lifted.getFn(@fromBackingInt(0)).iterator_fusion_scope); + } +} From 135d6dbbff7e1f6e2a9168c25f51cabe3354b760 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 11:44:09 -0400 Subject: [PATCH 48/97] Borrow catalog nodes while traversing immutable query paths --- src/compile/cache_index.zig | 44 ++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/src/compile/cache_index.zig b/src/compile/cache_index.zig index e839a348c65..0aaec9821bb 100644 --- a/src/compile/cache_index.zig +++ b/src/compile/cache_index.zig @@ -215,16 +215,23 @@ pub const Index = struct { } pub fn node(self: *Index, id: RecordId) Error!Node { + var storage: Node = undefined; + return (try self.nodeRef(id, &storage)).*; + } + + /// Borrowed only until another node read, refresh, or transaction write. + fn nodeRef(self: *Index, id: RecordId, storage: *Node) Error!*const Node { if (id == 0 or id >= self.header.next) return error.CorruptCatalog; if (self.read_cache) |cache| { const slot = id % cache.ids.len; - if (cache.ids[slot] == id) return cache.nodes[slot]; - const value = try Node.decode(&try self.readRecord(id), self.header.next); - cache.ids[slot] = id; - cache.nodes[slot] = value; - return value; + if (cache.ids[slot] != id) { + cache.nodes[slot] = try Node.decode(&try self.readRecord(id), self.header.next); + cache.ids[slot] = id; + } + return &cache.nodes[slot]; } - return Node.decode(&try self.readRecord(id), self.header.next); + storage.* = try Node.decode(&try self.readRecord(id), self.header.next); + return storage; } pub fn transaction(self: *Index) Transaction { @@ -281,14 +288,21 @@ pub const Transaction = struct { read_nodes: [64]Node = undefined, pub fn node(self: *Transaction, id: RecordId) Error!Node { + return (try self.nodeRef(id)).*; + } + + /// Borrow until the next node read or mutation. Traversals consume each + /// branch before advancing, so they need not copy its leaf-sized payload. + fn nodeRef(self: *Transaction, id: RecordId) Error!*const Node { if (id == 0 or id >= self.header.next) return error.CorruptCatalog; - for (self.changes[0..self.count]) |change| if (change.id == id) return change.node; + for (self.changes[0..self.count]) |*change| if (change.id == id) return &change.node; const slot = id % self.read_ids.len; - if (self.read_ids[slot] == id) return self.read_nodes[slot]; - const read = try self.index.node(id); - self.read_nodes[slot] = read; - self.read_ids[slot] = id; - return read; + if (self.index.read_cache != null) return self.index.nodeRef(id, &self.read_nodes[slot]); + if (self.read_ids[slot] != id) { + _ = try self.index.nodeRef(id, &self.read_nodes[slot]); + self.read_ids[slot] = id; + } + return &self.read_nodes[slot]; } fn set(self: *Transaction, id: RecordId, value: Node) void { @@ -338,7 +352,7 @@ pub const Transaction = struct { var id = self.header.roots[@backingInt(tree)]; var lower_bit: usize = 0; while (id != 0) { - const entry = try self.node(id); + const entry = try self.nodeRef(id); switch (entry.kind) { .leaf => return if (std.mem.eql(u8, &entry.key, &key)) .{ .id = id, .value = entry.value } else null, .free => return error.CorruptCatalog, @@ -360,7 +374,7 @@ pub const Transaction = struct { var count: usize = 0; var lower_bit: usize = 0; while (id != 0) { - const entry = try self.node(id); + const entry = try self.nodeRef(id); switch (entry.kind) { .free => return error.CorruptCatalog, .leaf => { @@ -397,7 +411,7 @@ pub const Transaction = struct { var id = start; var lower_bit: usize = 0; while (id != 0) { - const entry = try self.node(id); + const entry = try self.nodeRef(id); switch (entry.kind) { .free => return error.CorruptCatalog, .leaf => return .{ .id = id, .key = entry.key, .value = entry.value }, From e7b31bd457b5f9970cd592f3228b813fef98856e Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 11:59:55 -0400 Subject: [PATCH 49/97] Limit dev specialization rewrites to iterator fusion --- design.md | 14 ++++++++------ src/lir/checked_pipeline.zig | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/design.md b/design.md index aa08e31cdf2..f74115f4cc3 100644 --- a/design.md +++ b/design.md @@ -9188,8 +9188,8 @@ record the same continuation proof rather than relying on downstream discovery. The explicit `InlineMode` controls the optional specialization work: -- `.none` skips Monotype Lifted SpecConstr and produces an empty solved inline - plan. The interpreter selects this. +- `.none` runs only iterator fusion from Monotype Lifted SpecConstr and + produces an empty solved inline plan. The interpreter selects this. - `.wrappers` runs SpecConstr and produces wrapper and exact-single-use inline decisions from Lambda Solved. Size and speed modes select this. - `.wrappers_and_source_single_use` makes the same wrapper decisions, and @@ -9197,8 +9197,10 @@ The explicit `InlineMode` controls the optional specialization work: separately compiled callers the same inline decisions. - `.procedures` retains standalone procedures rather than substituting their bodies. Dev builds and compile-time evaluation select it so unchanged - procedures remain eligible for early native-code reuse. SpecConstr still - performs iterator fusion; the LIR passes that bound list copying still run. + procedures remain eligible for early native-code reuse. Like `.none`, it + runs only iterator fusion, without general SpecConstr normalization, + specialization, or procedure-usage collection for an empty inline plan. + The LIR passes that bound list copying still run. Dev SpecConstr (`.iterator_fusion` clone inlining) clones no workers for call patterns or callables (`CloneInlining.specializesCallPatterns`): which clones a function gets depends on every call the program makes to it, so a body rewritten to @@ -9215,8 +9217,8 @@ function-reference/direct-call operands. A call whose function is a function definition written as its callee, such as `x |> (|a| a + n)`, is lifted as a direct call to that function with the definition's capture values as its capture operands, evaluated before the arguments; it constructs no function -value. When `.none` skips SpecConstr, the -pipeline consumes that output directly and must not repeat capture analysis. +value. The iterator-only pass used by `.none` and `.procedures` preserves +this ownership boundary: the pipeline must not repeat capture analysis. SpecConstr can change free-variable use while cloning and rewriting function bodies, so SpecConstr owns one exact capture finalization before it returns its Monotype Lifted output. No later pipeline stage repeats that finalization. diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index aeeae6cde23..af89f6595f3 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -1650,7 +1650,7 @@ pub fn prepareMonotypeToSolved(prepared: PreparedMonotype) Allocator.Error!Prepa .executor = target.post_check_executor, .metrics_out = spec_constr_metrics, }; - var procedure_usage = if (target.inline_mode != .none) blk: { + var procedure_usage = if (target.inline_mode != .none and target.inline_mode != .procedures) blk: { var spec_constr_timing_scope = PipelineStageScope.begin(target.timing, .spec_constr); defer spec_constr_timing_scope.end(); const usage = try postcheck.MonotypeLifted.SpecConstr.runAndCollectProcedureUsageWithOptions(allocator, &lifted, target.spec_constr_clone_inlining, spec_constr_options); From 90bdf1aeca7ffe994f9db6a3f71161202d2df7d4 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 12:25:51 -0400 Subject: [PATCH 50/97] Avoid continuation frames for cached checked type requests --- src/postcheck/monotype/lower.zig | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index e8caf72c0ba..93866e4f9a4 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -26665,6 +26665,9 @@ const BodyContext = struct { }; const InstStep = union(enum) { + /// A child type request carries only its checked identity. Most hits + /// need no frame and never copy an uninitialized continuation payload. + node: checked.CheckedTypeId, call: InstTask, ret: InstResult, }; @@ -26724,6 +26727,18 @@ const BodyContext = struct { while (true) { const frame = &frames.items[frames.items.len - 1]; switch (try self.stepInst(frame, input)) { + .node => |checked_ty| { + var task = InstNodeTask{ .checked_ty = checked_ty }; + if (try self.probeInstNode(&task)) |existing| { + input = .{ .node = existing }; + } else { + const pushed = try frames.addOne(self.allocator); + pushed.cursor = 0; + pushed.index = 0; + pushed.task = .{ .node = task }; + input = null; + } + }, .call => |called| { const pushed = frames.addOne(self.allocator) catch |err| { self.destroyInstTaskBox(called); @@ -26734,18 +26749,6 @@ const BodyContext = struct { pushed.index = 0; writeActiveVariant(InstTask, &pushed.task, called); input = null; - // Most checked types are already instantiated in their - // scope; such a request answers without keeping a frame. - if (pushed.task == .node) { - const probed = self.probeInstNode(&pushed.task.node) catch |err| { - frames.items.len -= 1; - return err; - }; - if (probed) |existing| { - frames.items.len -= 1; - input = .{ .node = existing }; - } - } }, .ret => |result| { self.destroyInstTaskBox(frames.items[frames.items.len - 1].task); @@ -26795,7 +26798,7 @@ const BodyContext = struct { } fn instNodeStep(checked_ty: checked.CheckedTypeId) InstStep { - return .{ .call = .{ .node = .{ .checked_ty = checked_ty } } }; + return .{ .node = checked_ty }; } fn instSliceStep(checked_tys: []const checked.CheckedTypeId) InstStep { From a7acf6f09851100eda4cd1cdafe4d503c61e021e Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 14:15:19 -0400 Subject: [PATCH 51/97] Instantiate simple builtins without allocating nominal tasks --- src/postcheck/monotype/lower.zig | 40 ++++++++++++++------------------ 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 93866e4f9a4..3482d2676b1 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -26826,6 +26826,8 @@ const BodyContext = struct { function_ret: checked.CheckedTypeId, tag_union_ext: checked.CheckedTypeId, nominal, + list_elem, + box_elem, }; }; @@ -26939,6 +26941,8 @@ const BodyContext = struct { } }); }, .nominal => break :built result.get(.node), + .list_elem => break :built try self.graph.newNode(.{ .list = result.get(.node) }), + .box_elem => break :built try self.graph.newNode(.{ .box = result.get(.node) }), }; switch (checkedPayload(self.view, task.checked_ty)) { .pending => Common.invariant("pending checked type reached Monotype instantiation"), @@ -26985,6 +26989,15 @@ const BodyContext = struct { return .{ .call = .{ .tags = .{ .tags = tag_union.tags } } }; }, .nominal => |nominal| { + if (nominal.representation == .builtin) switch (checked.builtinRuntimeEncoding(nominal.representation.builtin)) { + .primitive => |primitive| break :built try self.graph.newNode(.{ .primitive = primitive }), + .list, .box => { + if (nominal.args.len != 1) Common.invariant("checked builtin container must have exactly one type argument"); + task.next = if (checked.builtinRuntimeEncoding(nominal.representation.builtin) == .list) .list_elem else .box_elem; + return instNodeStep(nominal.args[0]); + }, + .bool_tag_union, .try_nominal, .iterator, .parse_tag_union_spec, .fields, .field, .dict, .set, .crypto_sha256_digest, .crypto_sha256_hasher, .crypto_blake3_digest, .crypto_blake3_hasher => {}, + }; task.next = .nominal; return .{ .call = .{ .nominal = try self.boxInstTask(InstNominalTask, .{ .checked_ty = task.checked_ty, .nominal = nominal }) } }; }, @@ -27214,10 +27227,9 @@ const BodyContext = struct { /// Cursor states of a nominal instantiation. const InstNominalCursor = struct { const start = 0; - const builtin_elem = 1; - const args = 2; - const backing = 3; - const declared_order = 4; + const args = 1; + const backing = 2; + const declared_order = 3; }; fn stepInstNominal(self: *BodyContext, frame: *InstFrame, task: *InstNominalTask, input: ?InstResult) Allocator.Error!InstStep { @@ -27226,18 +27238,8 @@ const BodyContext = struct { InstNominalCursor.start => { switch (nominal.representation) { .builtin => |builtin| switch (checked.builtinRuntimeEncoding(builtin)) { - .primitive => |primitive| return .{ .ret = .{ .node = try self.graph.newNode(.{ .primitive = primitive }) } }, + .primitive, .list, .box => Common.invariant("simple builtin reached nominal instantiation frame"), .bool_tag_union => {}, - .list => { - if (nominal.args.len != 1) Common.invariant("checked List nominal must have exactly one type argument"); - frame.cursor = InstNominalCursor.builtin_elem; - return instNodeStep(nominal.args[0]); - }, - .box => { - if (nominal.args.len != 1) Common.invariant("checked Box nominal must have exactly one type argument"); - frame.cursor = InstNominalCursor.builtin_elem; - return instNodeStep(nominal.args[0]); - }, .try_nominal, .iterator, .parse_tag_union_spec, @@ -27256,14 +27258,6 @@ const BodyContext = struct { frame.cursor = InstNominalCursor.args; return instSliceStep(nominal.args); }, - InstNominalCursor.builtin_elem => { - const elem = input.?.get(.node); - return .{ .ret = .{ .node = try self.graph.newNode(switch (checked.builtinRuntimeEncoding(nominal.representation.builtin)) { - .list => .{ .list = elem }, - .box => .{ .box = elem }, - .primitive, .bool_tag_union, .try_nominal, .iterator, .parse_tag_union_spec, .fields, .field, .dict, .set, .crypto_sha256_digest, .crypto_sha256_hasher, .crypto_blake3_digest, .crypto_blake3_hasher => Common.invariant("builtin nominal element resumed for a builtin without one"), - }) } }; - }, InstNominalCursor.args => { task.args = input.?.get(.nodes); switch (nominal.representation) { From 2f7efac65531c09977348ee947e8a486d49e0c29 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 14:56:41 -0400 Subject: [PATCH 52/97] Preserve structural backings when joining nominal owners --- design.md | 7 +++ src/postcheck/monotype/lower.zig | 10 +++- src/postcheck/monotype/solve.zig | 92 +++++++++++++++++++++++++++++++- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/design.md b/design.md index f74115f4cc3..680ff6469dd 100644 --- a/design.md +++ b/design.md @@ -16242,6 +16242,13 @@ leaf the same way. An equal structural value elsewhere in the scope is a different occurrence, and relating it to a nominal lifts its class into that nominal; a backing sharing that class would come to name the nominal it backs, and joining the two nominal instances would make a nominal its own backing. +Structural relations can also make one instance's backing pass through another +instance of the same nominal. Before joining those owners, the relation resolves +their backing paths using the explicit equality of the two owner roots, including +when their argument relations are still pending. Otherwise merging the roots +would turn that path into a new self-reference. This preserves each backing's +access and authority; backings of different authorities remain separate, and a +backing cycle that already existed retains its recursive meaning. An import enters the graph as a leaf: a node standing for the finished type whose structure nothing has read yet. Reading a leaf's content expands one diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 3482d2676b1..503427a2ff5 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -72591,9 +72591,15 @@ test "issue 11453: direct alias lowering shares runtime types without wrapper al builder.active_body_draft = null; builder.type_cache = .empty; defer builder.type_cache.deinit(gpa); - var view_data: ModuleViewData = undefined; - const view = &view_data; + var views: [1]ModuleViewData = undefined; + const view = &views[0]; + builder.borrowed_module_views = &views; + builder.persistent_closed_occurrences = .empty; + defer builder.persistent_closed_occurrences.deinit(gpa); + builder.module_index = @TypeOf(builder.module_index).init(gpa); + defer builder.module_index.deinit(); view.key = .{}; + try builder.module_index.put(view.key.bytes, .root); view.names = &source_names; view.types = checked_types.view(); // The program seeds this module as its lowering input, as the coordinator does. diff --git a/src/postcheck/monotype/solve.zig b/src/postcheck/monotype/solve.zig index 8f1c900b18c..136afd10628 100644 --- a/src/postcheck/monotype/solve.zig +++ b/src/postcheck/monotype/solve.zig @@ -7527,7 +7527,13 @@ pub const InstGraph = struct { if (left_named.backing) |left_backing| { if (right_named.backing) |right_backing| { if (left_backing.authority == right_backing.authority) { - try pending.append(self.allocator, .{ .left = left_backing.node, .right = right_backing.node, .row_width = row_width }); + // Each backing may pass through the other + // nominal instance after a structural lift. + // Resolve that edge before merging the two + // owners, which would turn it into a cycle. + const left_node = try self.backingBeforeNamedJoin(left, left_named, right); + const right_node = try self.backingBeforeNamedJoin(right, right_named, left); + try pending.append(self.allocator, .{ .left = left_node, .right = right_node, .row_width = row_width }); } else { const private_is_left = left_backing.authority == .generated_private; const private_is_right = right_backing.authority == .generated_private; @@ -7688,6 +7694,41 @@ pub const InstGraph = struct { try pending.append(self.allocator, .{ .left = backing_node, .right = moved, .row_width = row_width }); } + /// The named relation has established that these two owner roots will + /// denote one instance. That explicit relation authorizes following the + /// other owner even before its argument pairs have been processed. + fn backingBeforeNamedJoin(self: *InstGraph, root: NodeId, owner: *const InstNamed, related_owner: NodeId) Allocator.Error!NodeId { + const declared = owner.backing.?; + var current = self.find(declared.node); + var next = (try self.namedJoinBackingNext(current, owner, related_owner)) orelse return current; + var seen = self.node_set_pool.acquire(); + defer self.node_set_pool.release(&seen); + while (true) { + if ((try seen.getOrPut(current)).found_existing) { + // A pre-existing recursive backing remains recursive. This + // operation only prevents a join from creating a new cycle. + return declared.node; + } + current = next; + next = (try self.namedJoinBackingNext(current, owner, related_owner)) orelse break; + } + if (declared.node != current) { + var normalized = owner.*; + normalized.backing = .{ .node = current, .use = declared.use, .authority = declared.authority }; + _ = try self.replaceContentWithoutSnapshotInvalidation(root, try self.namedContent(normalized)); + } + return current; + } + + fn namedJoinBackingNext(self: *InstGraph, current: NodeId, owner: *const InstNamed, related_owner: NodeId) Allocator.Error!?NodeId { + if (current != related_owner) return self.structuralBackingNext(current, owner); + const content_ = try self.rootContent(current); + if (content_ != .named) unreachable; // both owners were matched as named roots + const backing = content_.named.backing orelse + Common.invariant("related nominal owner has no declared backing"); + return self.find(backing.node); + } + const StructuralBacking = struct { node: NodeId, recursive: bool, @@ -15350,3 +15391,52 @@ test "interface request identity ignores the tag sorting memo" { } try std.testing.expect(try ids[0].eql(ids[1], &types, &ns)); } + +test "nominal joins after lifting another instance backing preserve structure in either order" { + const Case = struct { + fn run(a: Allocator) !void { + for (0..4) |variant| { + const reverse = variant & 1 != 0; + const phantom = variant & 2 != 0; + var types = Type.Store.init(a); + defer types.deinit(); + var ns = names.NameStore.init(a); + defer ns.deinit(); + const graph = try InstGraph.create(a, &types, &ns); + defer graph.destroy(); + const module = try ns.internModuleIdentity(&@as([32]u8, @splat(0x74))); + const name = try ns.internTypeName("Wrapped"); + const first_backing = try graph.newNode(.empty_record); + const second_backing = try graph.newNode(.empty_record); + const first = try graph.newNode(try graph.namedContent(.{ + .named_type = .{ .module = .{}, .ty = testCheckedTypeId(1), .ty_module = .{} }, + .def = .{ .module = module, .type_name = name }, + .kind = .nominal, + .builtin_owner = null, + .args = if (phantom) try graph.arena().dupe(NodeId, &.{try graph.newNode(.{ .primitive = .u64 })}) else &.{}, + .backing = .{ .node = first_backing, .use = .inspectable }, + })); + var second_content = (try graph.content(first)).named.*; + second_content.backing.?.node = second_backing; + if (phantom) second_content.args = try graph.arena().dupe(NodeId, &.{try graph.newNode(.{ .primitive = .u64 })}); + const second = try graph.newNode(try graph.namedContent(second_content)); + try graph.unify(first, second_backing); + if (reverse) try graph.unify(second, first) else try graph.unify(first, second); + const retained = (try graph.namedNodes(first)).backing.?.node; + try std.testing.expect(!graph.sameClass(first, retained)); + try std.testing.expect((try graph.content(retained)) == .empty_record); + } + } + }; + var counting = std.testing.FailingAllocator.init(std.testing.allocator, .{}); + try Case.run(counting.allocator()); + for (0..counting.alloc_index) |fail_index| { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = fail_index }); + // Optional scratch retention may fail after the relation succeeds. + // Both success and propagated OOM must leave no owned allocations. + Case.run(failing.allocator()) catch |err| switch (err) { + error.OutOfMemory => {}, + else => return err, + }; + } +} From cf18ade3c563fbd1d0d92071b5455995d36a6d0d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 26 Oct 2026 23:32:48 -0400 Subject: [PATCH 53/97] Add transactional codec for immutable specialization payloads --- design.md | 21 ++ src/postcheck/monotype/interface_summary.zig | 260 ++++++++++++++++++- 2 files changed, 273 insertions(+), 8 deletions(-) diff --git a/design.md b/design.md index 680ff6469dd..be23495ea5f 100644 --- a/design.md +++ b/design.md @@ -27325,3 +27325,24 @@ closure still receives relocation entries, including recursive members and children of reused roots. This shares immutable type content, never mutable instantiation cells or ownership scopes. All fallible work precedes relocation publication, and allocation failure preserves the original store and mapping. + +### Persistent completed specialization data + +Completed specialization data uses an explicit immutable record schema. It +does not persist the mutable instantiation graph, worker workspace, allocation +addresses, or coordinator reservation numbers. Its type closure uses the same +portable representation as persistent interface constraints: table references +preserve cycles and sharing, names are stored as content, and named types and +executable defaults retain checked provenance. A distinct format and schema +identity separate completed data from interface constraints. + +Each record producer supplies its complete dependency identity and an explicit +validation operation for its non-type references. Loading validates that schema, +all references, provenance, and the complete reachable type closure before +starting a destination type-store transaction. All returned storage is allocated +before committing the transaction. Rebinding after commit is allocation-free. +Corrupt or obsolete optional records are cache misses; allocation failure is +propagated. A record is not reusable merely because its serialized types match: +its source, dispatch context, compile-time inputs, and lowering policy must match +as well. Persisting a completed result does not change the meaning or ownership +of captures and does not introduce runtime closure dispatch into specialization. diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index 3f1d7b82a2e..a235f8c33ab 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -11,6 +11,7 @@ const Constraints = Solve.InterfaceConstraints; const GuardedList = @import("collections").GuardedList; const invariant = base.invariant; const magic = "RIFSUM04"; +const frozen_magic = "RIFRZ001"; /// A checked occurrence either names its immutable artifact directly, or /// names a closed checked type by canonical content. The latter has no @@ -154,6 +155,129 @@ pub fn decodeForRequest(arena: Allocator, types: *Type.Store, ns: *names.NameSto return result; } +/// Persist an explicitly declared immutable payload and its reachable type +/// closure. Payload slices are owned data, never references into a mutable +/// graph. The caller's schema tag versions all non-type fields. Unlike an +/// interface relation, this result has no request-relative type leaves. +pub fn encodeFrozen(comptime Payload: type, schema: [8]u8, allocator: Allocator, types: *Type.Store, ns: *const names.NameStore, payload: Payload, context: anytype, stats: ?*Stats) EncodeError![]u8 { + var writer: Writer(@TypeOf(context)) = .{ .allocator = allocator, .types = types, .names = ns, .leaves = &.{}, .context = context }; + defer writer.bytes.deinit(allocator); + defer writer.references.deinit(allocator); + defer writer.pending.deinit(allocator); + // No request leaves are indexed in this format. + try writer.bytes.appendSlice(allocator, frozen_magic); + try writer.write([8]u8, schema); + const count_offset = writer.bytes.items.len; + try writer.write(u32, 0); + try writer.write(Payload, payload); + var index: usize = 0; + while (index < writer.pending.items.len) : (index += 1) { + try writer.writeContent(types.get(writer.pending.items[index])); + } + writer.stats.types = writer.pending.items.len; + std.mem.writeInt(u32, writer.bytes.items[count_offset..][0..4], @intCast(writer.pending.items.len), .little); + if (stats) |out| out.* = writer.stats; + return writer.bytes.toOwnedSlice(allocator); +} + +/// Read completed immutable data. The context must validate all non-type +/// references through validateFrozenPayload before any type-store mutation. +/// That operation sees wire type references and must not dereference them in +/// the destination store. Returned slices belong to the supplied arena. +pub fn decodeFrozen(comptime Payload: type, schema: [8]u8, arena: Allocator, types: *Type.Store, ns: *names.NameStore, bytes: []const u8, context: anytype, stats: ?*Stats) DecodeError!Payload { + var scratch = std.heap.ArenaAllocator.init(types.allocator); + defer scratch.deinit(); + var reader: Reader(@TypeOf(context)) = .{ .arena = scratch.allocator(), .names = ns, .leaves = &.{}, .bytes = bytes, .context = context }; + if (!std.mem.startsWith(u8, reader.bytes, frozen_magic)) return invalidSummary(@src().line); + reader.bytes = reader.bytes[frozen_magic.len..]; + if (!std.mem.eql(u8, &(try reader.read([8]u8)), &schema)) return invalidSummary(@src().line); + reader.type_count = try reader.read(u32); + if (reader.type_count > reader.bytes.len) return invalidSummary(@src().line); + const payload = try reader.read(Payload); + const nodes = try scratch.allocator().alloc(WireType, reader.type_count); + for (nodes) |*node| node.* = try reader.read(WireType); + if (reader.bytes.len != 0) return invalidSummary(@src().line); + try context.validateFrozenPayload(payload); + try validateTypeGraph(scratch.allocator(), 0, payload, nodes); + const relocated = try scratch.allocator().alloc(Type.TypeId, nodes.len); + const transaction = types.beginTransaction(); + errdefer transaction.abort(types); + for (relocated) |*slot| slot.* = try transaction.reserve(types); + const mapper = Relocation{ .types = types, .ids = relocated, .allocator = scratch.allocator() }; + for (nodes, relocated) |node, slot| transaction.fill(types, slot, try mapper.content(node)); + var result = try copyFrozen(Payload, arena, payload); + if (nodes.len == 0) { + transaction.abort(types); + } else { + var committed = try types.commitTransaction(ns, transaction, relocated[0]); + defer committed.deinit(); + // commitTransaction maps every reserved node, including roots not + // reachable from the first root. This walk allocates nothing. + for (relocated) |*slot| slot.* = committed.remapType(slot.*); + relocateFrozen(Payload, &result, relocated); + } + reader.stats.types = nodes.len; + if (stats) |out| out.* = reader.stats; + return result; +} + +// These traversals follow only fixed-depth schema structure and owned slices; +// recursive user types are integer edges in the separately validated table. +fn copyFrozen(comptime T: type, arena: Allocator, value: T) Allocator.Error!T { + return switch (@typeInfo(T)) { + .@"struct" => |info| blk: { + var out: T = undefined; + inline for (info.field_names, info.field_types) |field, F| @field(out, field) = try copyFrozen(F, arena, @field(value, field)); + break :blk out; + }, + .@"union" => |info| blk: { + inline for (info.field_names, info.field_types) |field, F| if (std.meta.activeTag(value) == @field(info.tag_type.?, field)) break :blk @unionInit(T, field, try copyFrozen(F, arena, @field(value, field))); + unreachable; + }, + .optional => |info| if (value) |v| try copyFrozen(info.child, arena, v) else null, + .pointer => |info| blk: { + if (info.size != .slice) @compileError("frozen payloads contain owned slices, not object pointers"); + const out = try arena.alloc(info.child, value.len); + if (info.child == u8) { + @memcpy(out, value); + } else for (value, out) |v, *o| o.* = try copyFrozen(info.child, arena, v); + break :blk out; + }, + .array => |info| blk: { + var out: T = undefined; + for (value, &out) |v, *o| o.* = try copyFrozen(info.child, arena, v); + break :blk out; + }, + .@"enum", .int, .float, .bool, .void => value, + else => @compileError("unsupported frozen payload field: " ++ @typeName(T)), + }; +} + +fn relocateFrozen(comptime T: type, value: *T, ids: []const Type.TypeId) void { + if (T == Type.TypeId) { + value.* = ids[@backingInt(value.*)]; + return; + } + if (T == Type.MaybeTypeId) { + if (value.get()) |ty| value.* = .of(ids[@backingInt(ty)]); + return; + } + switch (@typeInfo(T)) { + .@"struct" => |info| inline for (info.field_names, info.field_types) |field, F| relocateFrozen(F, &@field(value, field), ids), + .@"union" => |info| inline for (info.field_names, info.field_types) |field, F| { + if (std.meta.activeTag(value.*) == @field(info.tag_type.?, field)) return relocateFrozen(F, &@field(value, field), ids); + }, + .optional => |info| if (value.*) |*v| relocateFrozen(info.child, v, ids), + .pointer => |info| { + if (info.size != .slice) @compileError("frozen payloads contain owned slices, not object pointers"); + for (@constCast(value.*)) |*v| relocateFrozen(info.child, v, ids); + }, + .array => |info| for (value) |*v| relocateFrozen(info.child, v, ids), + .@"enum", .int, .float, .bool, .void => {}, + else => @compileError("unsupported frozen payload field: " ++ @typeName(T)), + } +} + // This wire graph deliberately has no arena spans or producer indices. Its // nesting has fixed schema depth; all user-controlled recursion uses TypeId. const WireTag = struct { name: names.TagNameId, checked_name: names.TagNameId, payloads: []const Type.TypeId }; @@ -205,16 +329,16 @@ const TypeEdges = struct { .many, .c => @compileError("unbounded summary pointer"), }, .array => |info| for (value) |v| try self.collect(info.child, v), - .@"enum", .int, .bool, .void => {}, + .@"enum", .int, .float, .bool, .void => {}, else => @compileError("unsupported summary graph field"), } } }; -fn validateTypeGraph(allocator: Allocator, leaf_count: usize, summary: Summary, nodes: []const WireType) DecodeError!void { +fn validateTypeGraph(allocator: Allocator, leaf_count: usize, summary: anytype, nodes: []const WireType) DecodeError!void { var edges: TypeEdges = .{ .allocator = allocator }; defer edges.edges.deinit(allocator); - try edges.collect(Summary, summary); + try edges.collect(@TypeOf(summary), summary); const roots_end = edges.edges.items.len; const starts = try allocator.alloc(usize, nodes.len + 1); defer allocator.free(starts); @@ -483,7 +607,9 @@ fn Writer(comptime Context: type) type { .pointer => |info| switch (info.size) { .slice => { try self.write(u32, std.math.cast(u32, value.len) orelse return error.NotRepresentable); - for (value) |v| try self.write(info.child, v); + if (info.child == u8) { + try self.bytes.appendSlice(self.allocator, value); + } else for (value) |v| try self.write(info.child, v); }, .one => try self.write(info.child, value.*), .many, .c => @compileError("unbounded summary pointer"), @@ -496,12 +622,13 @@ fn Writer(comptime Context: type) type { const size = (@bitSizeOf(T) + 7) / 8; var bits: U = @bitCast(value); for (0..size) |_| { - try self.bytes.append(self.allocator, @truncate(@as(u64, bits))); + try self.bytes.append(self.allocator, @truncate(@as(@Int(.unsigned, @max(8, @bitSizeOf(T))), bits))); bits = std.math.shr(U, bits, 8); } }, + .float => try self.write(@Int(.unsigned, @bitSizeOf(T)), @bitCast(value)), .void => {}, - .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), + .type, .noreturn, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), } } }; @@ -578,7 +705,10 @@ fn Reader(comptime Context: type) type { // Every supported element consumes at least one byte. if (len > self.bytes.len) return invalidSummary(@src().line); const values = try self.arena.alloc(info.child, len); - for (values) |*value| value.* = try self.read(info.child); + if (info.child == u8) { + @memcpy(values, self.bytes[0..len]); + self.bytes = self.bytes[len..]; + } else for (values) |*value| value.* = try self.read(info.child); break :blk values; }, .array => |info| blk: { @@ -609,8 +739,9 @@ fn Reader(comptime Context: type) type { self.bytes = self.bytes[size..]; break :blk @bitCast(value); }, + .float => @bitCast(try self.read(@Int(.unsigned, @bitSizeOf(T)))), .void => {}, - .type, .noreturn, .float, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), + .type, .noreturn, .comptime_float, .comptime_int, .undefined, .null, .error_union, .error_set, .@"fn", .@"opaque", .frame, .@"anyframe", .vector, .enum_literal, .spirv => @compileError("unsupported summary field: " ++ @typeName(T)), }; if (T == Type.NamedType) if (!self.context.validNamedType(result)) return invalidSummary(@src().line); if (T == WireNamed or T == @typeInfo(@FieldType(Solve.InstNode, "named")).pointer.child or T == Solve.InstIteratorPublicSource) { @@ -667,8 +798,121 @@ const TestProvenance = struct { pub fn validNamedDefinition(self: @This(), _: *const names.NameStore, named: Type.NamedType, _: Type.TypeDef, _: Type.NamedKind, _: ?check.StaticDispatchRegistry.BuiltinOwner) bool { return self.validNamedType(named); } + pub fn validateFrozenPayload(_: @This(), payload: FrozenTestPayload) DecodeError!void { + for (payload.edges) |edge| if (edge >= payload.types.len) return error.InvalidSummary; + } }; +const FrozenTestPayload = struct { + types: []const Type.TypeId, + edges: []const u32, + name: names.RecordFieldNameId, + data: []const u8, + integer: i128, + float: f64, + optional: Type.MaybeTypeId, +}; + +fn frozenTestBytes(allocator: Allocator) ![]u8 { + var ns = names.NameStore.init(allocator); + defer ns.deinit(); + var source = Type.Store.init(allocator); + defer source.deinit(); + const slot = try source.beginRecursive(); + const function = try source.add(.{ .func = .{ .args = .empty(), .ret = slot.ty } }); + const name = try ns.internRecordFieldLabel("step"); + source.finishRecursive(slot, .{ .record = try source.addFields(&.{.{ .name = name, .ty = function, .default = null }}) }); + // A disconnected root tests that committing the first root also retains + // every other explicitly referenced result. The repeated root tests sharing. + const primitive = try source.internPrimitive(&ns, .u64); + return encodeFrozen(FrozenTestPayload, "TEST0001".*, allocator, &source, &ns, .{ + .types = &.{ slot.ty, slot.ty, primitive }, + .edges = &.{ 0, 1, 2 }, + .name = name, + .data = "owned bytes\x00\xff", + .integer = std.math.minInt(i128) + 17, + .float = @bitCast(@as(u64, 0x7ff8000000000053)), + .optional = .of(function), + }, TestProvenance{}, null); +} + +test "frozen specialization payload owns data and relocates cyclic shared type roots" { + const gpa = std.testing.allocator; + const bytes = try frozenTestBytes(gpa); + defer gpa.free(bytes); + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + _ = try ns.internRecordFieldLabel("unrelated"); + var dest = Type.Store.init(gpa); + defer dest.deinit(); + _ = try dest.internPrimitive(&ns, .bool); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const result = try decodeFrozen(FrozenTestPayload, "TEST0001".*, arena.allocator(), &dest, &ns, bytes, TestProvenance{}, null); + try std.testing.expectEqual(result.types[0], result.types[1]); + const field = GuardedList.at(dest.fieldSpan(dest.get(result.types[0]).record), 0); + try std.testing.expectEqual(result.types[0], dest.get(field.ty).func.ret); + try std.testing.expectEqual(field.ty, result.optional.get().?); + try std.testing.expectEqual(Type.Primitive.u64, dest.get(result.types[2]).primitive); + try std.testing.expectEqualStrings("step", ns.recordFieldLabelText(result.name)); + try std.testing.expectEqualStrings("owned bytes\x00\xff", result.data); + try std.testing.expectEqual(std.math.minInt(i128) + 17, result.integer); + try std.testing.expectEqual(@as(u64, 0x7ff8000000000053), @as(u64, @bitCast(result.float))); +} + +test "frozen specialization payload rejects corrupt schema references and truncation before mutation" { + const gpa = std.testing.allocator; + const bytes = try frozenTestBytes(gpa); + defer gpa.free(bytes); + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var dest = Type.Store.init(gpa); + defer dest.deinit(); + _ = try dest.internPrimitive(&ns, .bool); + const before = dest.types.len(); + for (0..bytes.len) |len| { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + try std.testing.expectError(error.InvalidSummary, decodeFrozen(FrozenTestPayload, "TEST0001".*, arena.allocator(), &dest, &ns, bytes[0..len], TestProvenance{}, null)); + try std.testing.expectEqual(before, dest.types.len()); + try std.testing.expect(!dest.hasSpeculativeConstruction()); + } + const bad = try gpa.dupe(u8, bytes); + defer gpa.free(bad); + // Schema byte, type reference, and payload-local edge, respectively. + for ([_]usize{ 8, 24, 40 }) |offset| { + @memcpy(bad, bytes); + bad[offset] = 255; + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + try std.testing.expectError(error.InvalidSummary, decodeFrozen(FrozenTestPayload, "TEST0001".*, arena.allocator(), &dest, &ns, bad, TestProvenance{}, null)); + try std.testing.expectEqual(before, dest.types.len()); + try std.testing.expect(!dest.hasSpeculativeConstruction()); + } +} + +fn frozenAllocationRoundTrip(gpa: Allocator) !void { + const bytes = try frozenTestBytes(gpa); + defer gpa.free(bytes); + var ns = names.NameStore.init(gpa); + defer ns.deinit(); + var dest = Type.Store.init(gpa); + defer dest.deinit(); + _ = try dest.internPrimitive(&ns, .bool); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const before = dest.types.len(); + _ = decodeFrozen(FrozenTestPayload, "TEST0001".*, arena.allocator(), &dest, &ns, bytes, TestProvenance{}, null) catch |err| { + try std.testing.expectEqual(before, dest.types.len()); + try std.testing.expect(!dest.hasSpeculativeConstruction()); + return err; + }; +} + +test "frozen specialization payload allocation failures roll back construction" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, frozenAllocationRoundTrip, .{}); +} + test "persistent interface constraints relocate request leaves and preserve cycles" { const allocator = std.testing.allocator; var arena = std.heap.ArenaAllocator.init(allocator); From c06337cbada1fc94d16df622e9a97dd225a76b9f Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 00:16:21 -0400 Subject: [PATCH 54/97] Prototype completed lambda analysis reuse for exact lifted inputs --- design.md | 12 + src/lir/checked_pipeline.zig | 2 +- .../lambda_solved/analysis_cache.zig | 516 ++++++++++++++++++ src/postcheck/lambda_solved/ast.zig | 7 + src/postcheck/lambda_solved/solve.zig | 34 ++ 5 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 src/postcheck/lambda_solved/analysis_cache.zig diff --git a/design.md b/design.md index be23495ea5f..e90781d6235 100644 --- a/design.md +++ b/design.md @@ -27346,3 +27346,15 @@ propagated. A record is not reusable merely because its serialized types match: its source, dispatch context, compile-time inputs, and lowering policy must match as well. Persisting a completed result does not change the meaning or ownership of captures and does not introduce runtime closure dispatch into specialization. + +Lambda Solved may reuse a completed analysis only under the exact identity of +its consumed lifted input. That identity includes the monomorphic type graph, +name meanings, function signatures and bodies, expression and binding topology, +capture identities, and ABI, layout, and runtime-schema boundaries. Literal byte +backings and reporting locations are not solver inputs. The current lifted +program always supplies executable expressions, literal contents, and reporting +metadata; an analysis cache never replaces it with an earlier program. Numeric +ids in an analysis record belong to the precisely identified input tables, not +to an assumed stable source position. A changed input-table identity invalidates +the record. This whole-analysis boundary is independent of finer-grained +specialization body caching. diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index af89f6595f3..f5ca7e02647 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -1675,7 +1675,7 @@ pub fn prepareMonotypeToSolved(prepared: PreparedMonotype) Allocator.Error!Prepa const lifted_input = lifted; lifted_owned = false; lifted = undefined; - var solved = try postcheck.LambdaSolved.Solve.run(allocator, lifted_input); + var solved = try postcheck.LambdaSolved.Solve.runWithCache(allocator, lifted_input, if (target.spec_cache) |cache| cache.interfaces else null); errdefer solved.deinit(); lambda_solve_timing_scope.end(); diff --git a/src/postcheck/lambda_solved/analysis_cache.zig b/src/postcheck/lambda_solved/analysis_cache.zig new file mode 100644 index 00000000000..410aaf4758e --- /dev/null +++ b/src/postcheck/lambda_solved/analysis_cache.zig @@ -0,0 +1,516 @@ +//! Completed lambda analysis over an exactly identified lifted input. +const std = @import("std"); +const base = @import("base"); +const check = @import("check"); +const Lifted = @import("../monotype_lifted/ast.zig"); +const Mono = @import("../monotype/type.zig"); +const Ast = @import("ast.zig"); +const Type = @import("type.zig"); +const Codec = @import("../monotype/interface_summary.zig"); +const names = check.CheckedNames; +const Allocator = std.mem.Allocator; + +/// All these columns are read by lambda solving. Source locations, literal +/// byte backings and Fn.source metadata are carried by the current program, +/// and are never part of the analysis result. Numeric literal payloads remain +/// in the key conservatively; this format makes no value-equivalence claims. +const columns = .{ + "exprs", "pats", "stmts", "locals", "expr_ids", "pat_ids", "typed_locals", + "stmt_ids", "field_exprs", "field_access_segments", "fn_def_captures", "capture_operands", "record_destructs", "str_pattern_steps", + "branches", "if_branches", "layout_requests", "runtime_schema_requests", +}; +const type_columns = .{ "types", "named_nodes", "erased_digests", "spans", "fields", "tags", "declared_fields" }; + +/// Versioned identity of the complete solver input, including table order. +/// No source-position identity or inference from a prior result is involved. +pub fn identity(input: Lifted.ProgramView) [32]u8 { + var out = Fingerprint{ .names = input.names }; + out.bytes("roc.lambda-analysis-input.v1"); + inline for (columns) |field| { + out.value(@TypeOf(@field(input, field)), @field(input, field)); + } + inline for (type_columns) |field| { + out.value(@TypeOf(@field(input.types, field)), @field(input.types, field)); + } + out.value(usize, input.fns.len); + for (input.fns) |f| { + out.value(@TypeOf(f.symbol), f.symbol); + out.value(@TypeOf(f.signature), f.signature); + out.value(@TypeOf(f.args), f.args); + out.value(@TypeOf(f.captures), f.captures); + out.value(@TypeOf(f.body), f.body); + out.value(@TypeOf(f.ret), f.ret); + } + out.value(usize, input.roots.len); + for (input.roots) |root| { + out.value(@TypeOf(root.fn_id), root.fn_id); + out.value(@TypeOf(root.request.abi), root.request.abi); + } + return out.finish(); +} + +const Fingerprint = struct { + names: *const names.NameStore, + hash: base.Sha256 = .init(.{}), + buffer: [4096]u8 = undefined, + used: usize = 0, + fn bytes(self: *Fingerprint, data: []const u8) void { + if (data.len > self.buffer.len - self.used) { + self.hash.update(self.buffer[0..self.used]); + self.used = 0; + if (data.len >= self.buffer.len) { + self.hash.update(data); + return; + } + } + @memcpy(self.buffer[self.used..][0..data.len], data); + self.used += data.len; + } + fn finish(self: *Fingerprint) [32]u8 { + self.hash.update(self.buffer[0..self.used]); + return self.hash.finalResult(); + } + fn value(self: *Fingerprint, comptime T: type, v: T) void { + // Keep both the table id and its meaning: output annotations retain + // numeric references to this input, and name equality reads the text. + if (T == names.ModuleIdentityId) { + self.value(u32, @backingInt(v)); + return self.bytes(self.names.moduleIdentityBytes(v)); + } + if (T == names.RecordFieldNameId or T == names.TagNameId or T == names.TypeNameId or T == names.ExportNameId or T == names.ExternalSymbolNameId) { + self.value(u32, @backingInt(v)); + const text = if (T == names.RecordFieldNameId) self.names.recordFieldLabelText(v) else if (T == names.TagNameId) self.names.tagLabelText(v) else if (T == names.TypeNameId) self.names.typeNameText(v) else if (T == names.ExportNameId) self.names.exportNameText(v) else self.names.externalSymbolNameText(v); + return self.value([]const u8, text); + } + switch (@typeInfo(T)) { + .@"struct" => |info| inline for (info.field_names, info.field_types) |field, F| self.value(F, @field(v, field)), + .@"union" => |info| { + self.value(info.tag_type.?, std.meta.activeTag(v)); + inline for (info.field_names, info.field_types) |field, F| if (std.meta.activeTag(v) == @field(info.tag_type.?, field)) return self.value(F, @field(v, field)); + unreachable; + }, + .optional => |info| { + self.value(bool, v != null); + if (v) |present| self.value(info.child, present); + }, + .pointer => |info| { + if (info.size != .slice) @compileError("analysis identity contains an object pointer: " ++ @typeName(T)); + self.value(usize, v.len); + if (info.child == u8) return self.bytes(v); + for (v) |element| self.value(info.child, element); + }, + .array => |info| { + if (info.child == u8) return self.bytes(&v); + for (v) |element| self.value(info.child, element); + }, + .@"enum" => |info| self.value(info.tag_type, @backingInt(v)), + .bool => self.value(u8, @intFromBool(v)), + .float => self.value(@Int(.unsigned, @bitSizeOf(T)), @as(@Int(.unsigned, @bitSizeOf(T)), @bitCast(v))), + .int => { + const U = @Int(.unsigned, @max(8, @bitSizeOf(T))); + var bits: U = @as(@Int(.unsigned, @bitSizeOf(T)), @bitCast(v)); + var bytes_: [(@bitSizeOf(T) + 7) / 8]u8 = undefined; + for (&bytes_) |*byte| { + byte.* = @truncate(bits); + bits = std.math.shr(U, bits, 8); + } + self.bytes(&bytes_); + }, + .void => {}, + else => @compileError("unsupported analysis identity field: " ++ @typeName(T)), + } + } +}; + +/// Solver annotations only. The executable IR always comes from this build. +const Payload = struct { + input_identity: [32]u8, + types: Type.Store.View, + defs: []const Ast.Def, + local_tys: []const Type.TypeVarId, + expr_tys: []const Type.TypeVarId, + pat_tys: []const Type.TypeVarId, + fn_tys: []const Type.TypeVarId, + layout_requests: []const Ast.LayoutRequest, + runtime_schema_requests: []const Ast.RuntimeSchemaRequest, +}; + +fn payloadOf(program: *const Ast.Program, key: [32]u8) Payload { + return .{ + .input_identity = key, + .types = program.types.view(), + .defs = program.defs.items, + .local_tys = program.local_tys.items, + .expr_tys = program.expr_tys.items, + .pat_tys = program.pat_tys.items, + .fn_tys = program.fn_tys.items, + .layout_requests = program.layout_requests.items, + .runtime_schema_requests = program.runtime_schema_requests.items, + }; +} + +const Context = struct { + allocator: Allocator, + input: Lifted.ProgramView, + key: [32]u8, + named: std.AutoHashMapUnmanaged(Mono.NamedType, void) = .empty, + modules: std.AutoHashMapUnmanaged([32]u8, void) = .empty, + defaults: std.AutoHashMapUnmanaged([36]u8, void) = .empty, + + fn init(allocator: Allocator, input: Lifted.ProgramView, key: [32]u8) Allocator.Error!Context { + var self = Context{ .allocator = allocator, .input = input, .key = key }; + errdefer self.deinit(); + for (input.types.named_nodes) |named| { + try self.named.put(allocator, named.named_type, {}); + try self.modules.put(allocator, input.names.moduleIdentityBytes(named.def.module).*, {}); + } + for (input.types.fields) |field| if (field.default) |default| { + const module = input.names.moduleIdentityBytes(default.module).*; + try self.modules.put(allocator, module, {}); + try self.defaults.put(allocator, defaultKey(module, default.expr_node), {}); + }; + for (input.runtime_schema_requests) |request| try self.modules.put(allocator, input.names.moduleIdentityBytes(request.def.module).*, {}); + return self; + } + fn deinit(self: *Context) void { + self.defaults.deinit(self.allocator); + self.modules.deinit(self.allocator); + self.named.deinit(self.allocator); + } + fn defaultKey(module: [32]u8, expr: u32) [36]u8 { + var key: [36]u8 = undefined; + key[0..32].* = module; + std.mem.writeInt(u32, key[32..36], expr, .little); + return key; + } + pub fn defaultArtifact(self: *const Context, module: [32]u8, expr: u32) ?[32]u8 { + // Analysis only carries a current input default reference; it never + // persists its executable body. Its validation token is the complete + // input identity, rather than a newly reconstructed checked artifact. + return if (self.defaults.contains(defaultKey(module, expr))) self.key else null; + } + pub fn validModuleIdentity(self: *const Context, module: [32]u8) bool { + return self.modules.contains(module); + } + pub fn describeNamedType(_: *const Context, named: Mono.NamedType) Codec.NamedOccurrence { + return .{ .exact = .{ .owner = named.module.bytes, .occurrence = named.ty_module.bytes, .ty = @backingInt(named.ty) } }; + } + pub fn resolveNamedType(self: *const Context, stored: Codec.NamedOccurrence) Allocator.Error!?Mono.NamedType { + if (stored != .exact) return null; + const named = Mono.NamedType{ .module = .{ .bytes = stored.exact.owner }, .ty_module = .{ .bytes = stored.exact.occurrence }, .ty = @fromBackingInt(stored.exact.ty) }; + return if (self.named.contains(named)) named else null; + } + pub fn validNamedType(self: *const Context, named: Mono.NamedType) bool { + return self.named.contains(named); + } + pub fn validNamedDefinition(self: *const Context, _: *const names.NameStore, named: Mono.NamedType, _: Mono.TypeDef, _: Mono.NamedKind, _: ?check.StaticDispatchRegistry.BuiltinOwner) bool { + return self.validNamedType(named); + } + pub fn validateFrozenPayload(self: *const Context, payload: Payload) Codec.DecodeError!void { + if (!std.mem.eql(u8, &payload.input_identity, &self.key)) return error.InvalidSummary; + try validate(self.allocator, self.input, payload); + } +}; + +fn validVar(id: Type.TypeVarId, len: usize) Codec.DecodeError!void { + if (@backingInt(id) >= len) return error.InvalidSummary; +} +fn validSpan(span: Type.Span, len: usize) Codec.DecodeError!void { + if (span.start > len or span.len > len - span.start) return error.InvalidSummary; +} +fn validate(allocator: Allocator, input: Lifted.ProgramView, p: Payload) Codec.DecodeError!void { + if (p.defs.len != input.fns.len or p.fn_tys.len != input.fns.len or p.local_tys.len != input.locals.len or p.expr_tys.len != input.exprs.len or p.pat_tys.len != input.pats.len or p.layout_requests.len != input.layout_requests.len or p.runtime_schema_requests.len != input.runtime_schema_requests.len) return error.InvalidSummary; + const t = p.types; + const count = t.vars.len; + for (t.vars) |node| switch (node) { + .link, .list, .box => |id| try validVar(id, count), + .named => |id| if (id >= t.named_payloads.len) return error.InvalidSummary, + .erased => |id| if (id >= t.erased_payloads.len) return error.InvalidSummary, + .record => |span| try validSpan(span, t.fields.len), + .tag_union => |span| try validSpan(span, t.tags.len), + .tuple => |span| try validSpan(span, t.spans.len), + .lambda_set => |span| try validSpan(span, t.fn_members.len), + .func => |func| { + try validSpan(func.args, t.spans.len); + try validVar(func.callable, count); + try validVar(func.ret, count); + }, + .mono => return error.InvalidSummary, + .primitive, .unbound, .forall, .zst, .hole => {}, + }; + for (t.named_payloads) |named| { + try validSpan(named.args, t.spans.len); + try validSpan(named.declared_order, t.declared_fields.len); + if (named.backing) |backing| try validVar(backing.ty, count); + } + for (t.erased_payloads) |erased| { + try validSpan(erased.members, t.fn_members.len); + if (erased.abi_fn) |ty| try validVar(ty, count); + } + for (t.spans) |ty| try validVar(ty, count); + for (t.fields) |field| { + try validVar(field.ty, count); + if (field.value_ty) |ty| try validVar(ty, count); + } + for (t.tags) |tag| try validSpan(tag.payloads, t.spans.len); + for (t.declared_fields) |field| if (field == .padding) try validVar(field.padding, count); + var symbols = std.AutoHashMapUnmanaged(@import("../common.zig").Symbol, void).empty; + defer symbols.deinit(allocator); + for (input.fns) |func| try symbols.put(allocator, func.symbol, {}); + for (t.fn_members) |member| { + if (!symbols.contains(member.lambda)) return error.InvalidSummary; + try validSpan(member.captures, t.captures.len); + } + for (t.captures) |capture| { + if (@backingInt(capture.local) >= input.locals.len) return error.InvalidSummary; + const local = input.locals[@backingInt(capture.local)]; + if (capture.symbol != local.symbol) return error.InvalidSummary; + try validVar(capture.ty, count); + } + inline for (.{ "local_tys", "expr_tys", "pat_tys", "fn_tys" }) |field| for (@field(p, field)) |ty| try validVar(ty, count); + for (p.defs, input.fns, p.fn_tys) |def, func, ty| { + if (def.symbol != func.symbol or def.ty != ty) return error.InvalidSummary; + switch (def.body) { + .roc => |expr| if (func.body != .roc or func.body.roc != expr) return error.InvalidSummary, + .hosted => if (func.body != .hosted) return error.InvalidSummary, + } + } + for (p.layout_requests, input.layout_requests) |result, request| { + if (result.checked_type != request.checked_type or result.fn_id != request.fn_id or !std.meta.eql(result.const_locator, request.const_locator)) return error.InvalidSummary; + try validVar(result.ty, count); + } + for (p.runtime_schema_requests, input.runtime_schema_requests) |result, request| { + if (!std.meta.eql(result.def, request.def)) return error.InvalidSummary; + try validVar(result.ty, count); + } + // Redirect cycles would hang readers. Validate every chain once, including + // unreachable rows: optional records never enter the compiler as invariants. + const colors = try allocator.alloc(enum { unseen, active, done }, count); + defer allocator.free(colors); + @memset(colors, .unseen); + for (0..count) |start| { + var index = start; + while (colors[index] == .unseen) { + colors[index] = .active; + if (t.vars[index] != .link) break; + index = @backingInt(t.vars[index].link); + if (colors[index] == .active) return error.InvalidSummary; + } + index = start; + while (colors[index] == .active) { + colors[index] = .done; + if (t.vars[index] != .link) break; + index = @backingInt(t.vars[index].link); + } + } + for (p.fn_tys) |ty| if (t.rootContent(ty) != .func) return error.InvalidSummary; +} + +/// Encode only annotations; no source or executable payload is persisted. +pub fn encode(allocator: Allocator, program: *Ast.Program, key: [32]u8) Allocator.Error!?[]u8 { + var context = try Context.init(allocator, program.lifted.view(), key); + defer context.deinit(); + return Codec.encodeFrozen(Payload, "LSAN0001".*, allocator, &program.lifted.types, &program.lifted.names, payloadOf(program, key), &context, null) catch |err| switch (err) { + error.NotRepresentable => return null, + error.OutOfMemory => return error.OutOfMemory, + }; +} + +/// On success transfers the current lifted program into the restored result. +/// Invalid optional data leaves the current program available for fresh solving. +pub fn restore(allocator: Allocator, lifted: *Lifted.Program, key: [32]u8, bytes: []const u8) Allocator.Error!?Ast.Program { + const arena = try allocator.create(std.heap.ArenaAllocator); + arena.* = .init(allocator); + var owned = true; + defer if (owned) { + arena.deinit(); + allocator.destroy(arena); + }; + var context = try Context.init(allocator, lifted.view(), key); + defer context.deinit(); + const payload = Codec.decodeFrozen(Payload, "LSAN0001".*, arena.allocator(), &lifted.types, &lifted.names, bytes, &context, null) catch |err| switch (err) { + error.InvalidSummary => return null, + error.OutOfMemory => return error.OutOfMemory, + }; + // This producer's completed graph has no private solver state. Ownership + // flags are meaningful only while solving and start empty on immutable reuse. + const flags = try arena.allocator().alloc(bool, payload.types.vars.len); + @memset(flags, false); + var result = Ast.Program.init(arena.allocator(), lifted.*); + result.analysis_backing = arena; + inline for (.{ "defs", "local_tys", "expr_tys", "pat_tys", "fn_tys", "layout_requests", "runtime_schema_requests" }) |field| { + @field(result, field) = .fromOwnedSlice(@constCast(@field(payload, field))); + } + inline for (@typeInfo(Type.Store.View).@"struct".field_names) |field| { + @field(result.types, field) = .fromOwnedSlice(@constCast(@field(payload.types, field))); + } + result.types.owned_named_backings = .fromOwnedSlice(flags); + owned = false; + lifted.* = undefined; + return result; +} + +fn emptyInput(allocator: Allocator) Lifted.Program { + return Lifted.Program.init( + allocator, + names.NameStore.init(allocator), + Mono.Store.init(allocator), + .empty, // const_fn_evidence + .empty, // const_fn_evidence_targets + .empty, // const_fn_evidence_structurals + .empty, // const_fn_evidence_frames + .empty, // exprs + .empty, // pats + .empty, // stmts + .empty, // locals + .empty, // expr_ids + .empty, // pat_ids + .empty, // typed_locals + .empty, // stmt_ids + .empty, // field_exprs + .empty, // field_access_segments + .empty, // fn_def_captures + .empty, // capture_operands + .empty, // record_destructs + .empty, // str_pattern_steps + .empty, // branches + .empty, // if_branches + .empty, // string_literals + Lifted.ProcDebugNameMap.init(allocator), + .empty, // source_files + .empty, // expr_locs + .empty, // expr_regions + .empty, // stmt_locs + .empty, // stmt_regions + .empty, // inline_scopes + .empty, // expr_inline_scopes + .empty, // stmt_inline_scopes + .empty, // local_names + .empty, // static_data_values + .empty, // comptime_sites + 0, // next_symbol + ); +} + +fn literalInput(allocator: Allocator, text: []const u8) !Lifted.Program { + var input = emptyInput(allocator); + errdefer input.deinit(); + const ty = try input.types.internPrimitive(&input.names, .str); + const literal = try input.addStringLiteral(text); + const body = try input.addExpr(.{ .ty = ty, .data = .{ .str_lit = literal } }); + _ = try input.addFn(.{ .symbol = @fromBackingInt(0), .args = .empty(), .captures = .empty(), .body = .{ .roc = body }, .ret = ty }); + return input; +} + +test "lambda analysis reuse keeps the current literal and reporting metadata" { + const gpa = std.testing.allocator; + var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); + defer first.deinit(); + const key = identity(first.lifted.view()); + const bytes = (try encode(gpa, &first, key)).?; + defer gpa.free(bytes); + var current = try literalInput(gpa, "after with different length"); + var current_owned = true; + defer if (current_owned) current.deinit(); + current.current_loc = .{ .file = 0, .line = 88, .column = 3 }; + try std.testing.expectEqual(key, identity(current.view())); + var restored = (try restore(gpa, ¤t, key, bytes)).?; + current_owned = false; + defer restored.deinit(); + try std.testing.expectEqualStrings("after with different length", restored.lifted.view().string_literals[0].text()); + try std.testing.expectEqual(@as(u32, 88), restored.lifted.current_loc.line); + try std.testing.expectEqualDeep(first.types.view(), restored.types.view()); + try std.testing.expectEqualDeep(first.fn_tys.items, restored.fn_tys.items); +} + +test "lambda analysis identity includes callable topology and rejects mismatched records" { + const gpa = std.testing.allocator; + var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); + defer first.deinit(); + const key = identity(first.lifted.view()); + const bytes = (try encode(gpa, &first, key)).?; + defer gpa.free(bytes); + var current = try literalInput(gpa, "after"); + defer current.deinit(); + _ = try current.types.internPrimitive(¤t.names, .u64); + const changed = identity(current.view()); + try std.testing.expect(!std.mem.eql(u8, &key, &changed)); + try std.testing.expectEqual(null, try restore(gpa, ¤t, changed, bytes)); + for (0..bytes.len) |end| try std.testing.expectEqual(null, try restore(gpa, ¤t, key, bytes[0..end])); +} + +fn analysisAllocationRoundTrip(gpa: Allocator) !void { + var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); + defer first.deinit(); + const key = identity(first.lifted.view()); + const bytes = (try encode(gpa, &first, key)).?; + defer gpa.free(bytes); + var current = try literalInput(gpa, "after"); + var owned = true; + defer if (owned) current.deinit(); + var restored = (try restore(gpa, ¤t, key, bytes)).?; + owned = false; + defer restored.deinit(); +} + +test "lambda analysis allocation failures preserve input ownership" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, analysisAllocationRoundTrip, .{}); +} + +fn recursiveInput(allocator: Allocator) !Lifted.Program { + var input = emptyInput(allocator); + errdefer input.deinit(); + const root = try input.types.beginRecursive(); + const callback = try input.types.add(.{ .func = .{ .args = .empty(), .ret = root.ty } }); + input.types.finishRecursive(root, .{ .record = try input.types.addFields(&.{.{ .name = try input.names.internRecordFieldLabel("next"), .ty = callback, .default = null }}) }); + const local = try input.addLocal(@fromBackingInt(0), root.ty); + const args = try input.addTypedLocalSpan(&.{.{ .local = local, .ty = root.ty }}); + const body = try input.addExpr(.{ .ty = root.ty, .data = .{ .local = local } }); + _ = try input.addFn(.{ .symbol = @fromBackingInt(1), .args = args, .captures = .empty(), .body = .{ .roc = body }, .ret = root.ty }); + return input; +} + +test "lambda analysis preserves recursive callable relations and rejects redirect cycles" { + const gpa = std.testing.allocator; + var first = try @import("solve.zig").run(gpa, try recursiveInput(gpa)); + defer first.deinit(); + const key = identity(first.lifted.view()); + const bytes = (try encode(gpa, &first, key)).?; + defer gpa.free(bytes); + var current = try recursiveInput(gpa); + var owned = true; + defer if (owned) current.deinit(); + var restored = (try restore(gpa, ¤t, key, bytes)).?; + owned = false; + defer restored.deinit(); + try std.testing.expectEqualDeep(first.types.view(), restored.types.view()); + try std.testing.expectEqualDeep(first.local_tys.items, restored.local_tys.items); + var bad = payloadOf(&first, key); + const vars = try gpa.dupe(Type.StoredNode, bad.types.vars); + defer gpa.free(vars); + vars[0] = .{ .link = @fromBackingInt(0) }; + bad.types.vars = vars; + var context = try Context.init(gpa, first.lifted.view(), key); + defer context.deinit(); + const corrupt = try Codec.encodeFrozen(Payload, "LSAN0001".*, gpa, &first.lifted.types, &first.lifted.names, bad, &context, null); + defer gpa.free(corrupt); + var rejected_input = try recursiveInput(gpa); + defer rejected_input.deinit(); + try std.testing.expectEqual(null, try restore(gpa, &rejected_input, key, corrupt)); +} + +test "lambda analysis identity distinguishes callbacks with the same signature" { + const gpa = std.testing.allocator; + var input = try literalInput(gpa, "first callback"); + defer input.deinit(); + const first = input.view().fns[0]; + const literal = try input.addStringLiteral("second callback"); + const body = try input.addExpr(.{ .ty = first.ret, .data = .{ .str_lit = literal } }); + const second = try input.addFn(.{ .symbol = @fromBackingInt(1), .args = .empty(), .captures = .empty(), .body = .{ .roc = body }, .ret = first.ret }); + const signature = try input.types.add(.{ .func = .{ .args = .empty(), .ret = first.ret } }); + const closure = try input.addExpr(.{ .ty = signature, .data = .{ .fn_ref = .{ .fn_id = @fromBackingInt(0), .captures = .empty() } } }); + _ = try input.addFn(.{ .symbol = @fromBackingInt(2), .args = .empty(), .captures = .empty(), .body = .{ .roc = closure }, .ret = signature }); + const before = identity(input.view()); + input.setExprData(closure, .{ .fn_ref = .{ .fn_id = second, .captures = .empty() } }); + try std.testing.expect(!std.mem.eql(u8, &before, &identity(input.view()))); +} diff --git a/src/postcheck/lambda_solved/ast.zig b/src/postcheck/lambda_solved/ast.zig index 7da70123acd..1585057cc29 100644 --- a/src/postcheck/lambda_solved/ast.zig +++ b/src/postcheck/lambda_solved/ast.zig @@ -53,6 +53,8 @@ pub const ProgramView = struct { /// Lambda Solved program plus the solved type store. pub const Program = struct { allocator: std.mem.Allocator, + /// Owned immutable annotations restored from an exact analysis record. + analysis_backing: ?*std.heap.ArenaAllocator = null, lifted: Lifted.Program, types: Type.Store, defs: std.ArrayList(Def), @@ -88,6 +90,11 @@ pub const Program = struct { self.defs.deinit(self.allocator); self.types.deinit(); self.lifted.deinit(); + if (self.analysis_backing) |arena| { + const owner = arena.child_allocator; + arena.deinit(); + owner.destroy(arena); + } } pub fn view(self: *const Program) ProgramView { diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index e347daa6202..ad0432126cb 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -49,6 +49,7 @@ const Type = @import("type.zig"); const Allocator = std.mem.Allocator; const static_dispatch = check.StaticDispatchRegistry; const names = check.CheckedNames; +const AnalysisCache = @import("analysis_cache.zig"); const UninhabitedAnswer = struct { epoch: u64, @@ -255,16 +256,43 @@ pub fn run( allocator: Allocator, lifted: Lifted.Program, ) Common.LowerError!Ast.Program { + return runWithCache(allocator, lifted, null); +} + +/// The optional cache records only completed analysis, under the exact lifted +/// input consumed by this solver. The current program remains the executable IR. +pub fn runWithCache(allocator: Allocator, lifted: Lifted.Program, cache: ?Common.InterfaceSummaryCache) Common.LowerError!Ast.Program { // `Ast.Program.init` cannot fail and takes ownership of `lifted`, so // `program.deinit()` is the only cleanup a later error needs. var program = Ast.Program.init(allocator, lifted); errdefer program.deinit(); + const key = if (cache != null) AnalysisCache.identity(program.lifted.view()) else null; + var family: [32]u8 = undefined; + baseHash("roc.lambda-analysis-cache.v1", &family); + if (cache) |provider| { + if (try provider.load(provider.context, allocator, family, key.?)) |bytes| { + defer allocator.free(bytes); + if (try AnalysisCache.restore(allocator, &program.lifted, key.?, bytes)) |restored| { + if (diagnostics.isSet(.lambda_solve_stats)) std.debug.print("lambda-analysis hit key={x} bytes={d}\n", .{ key.?, bytes.len }); + // The empty annotation stores allocated by init own no storage; + // restore consumed its lifted input and owns the returned arena. + return restored; + } + } + if (diagnostics.isSet(.lambda_solve_stats)) std.debug.print("lambda-analysis miss key={x}\n", .{key.?}); + } + var solver = try Solver.init(allocator, &program); defer solver.deinit(); var counters: WorkCounters = .{}; if (diagnostics.isSet(.lambda_solve_stats)) solver.counters = &counters; try solver.solve(); + if (cache) |provider| if (try AnalysisCache.encode(allocator, &program, key.?)) |bytes| { + defer allocator.free(bytes); + try provider.record(provider.context, family, key.?, bytes); + if (diagnostics.isSet(.lambda_solve_stats)) std.debug.print("lambda-analysis record key={x} bytes={d}\n", .{ key.?, bytes.len }); + }; if (diagnostics.isSet(.lambda_solve_stats)) { var buffer: [2048]u8 = undefined; var out = std.Io.Writer.fixed(&buffer); @@ -281,6 +309,12 @@ pub fn run( return program; } +fn baseHash(bytes: []const u8, out: *[32]u8) void { + var hash = @import("base").Sha256.init(.{}); + hash.update(bytes); + out.* = hash.finalResult(); +} + const Solver = struct { counters: ?*WorkCounters = null, erased_reachability: collections.DenseMapUnmanaged(Type.TypeVarId, void) = .empty, From 03a882464e372a1f10893e5d58fec456afda9459 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 00:23:32 -0400 Subject: [PATCH 55/97] Keep annotation restoration read-only over frozen input types --- .../lambda_solved/analysis_cache.zig | 1 + src/postcheck/monotype/interface_summary.zig | 23 +++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/postcheck/lambda_solved/analysis_cache.zig b/src/postcheck/lambda_solved/analysis_cache.zig index 410aaf4758e..db8f411bdf9 100644 --- a/src/postcheck/lambda_solved/analysis_cache.zig +++ b/src/postcheck/lambda_solved/analysis_cache.zig @@ -413,6 +413,7 @@ test "lambda analysis reuse keeps the current literal and reporting metadata" { var current_owned = true; defer if (current_owned) current.deinit(); current.current_loc = .{ .file = 0, .line = 88, .column = 3 }; + current.types.freeze(); try std.testing.expectEqual(key, identity(current.view())); var restored = (try restore(gpa, ¤t, key, bytes)).?; current_owned = false; diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index a235f8c33ab..63e6d14c504 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -199,6 +199,13 @@ pub fn decodeFrozen(comptime Payload: type, schema: [8]u8, arena: Allocator, typ if (reader.bytes.len != 0) return invalidSummary(@src().line); try context.validateFrozenPayload(payload); try validateTypeGraph(scratch.allocator(), 0, payload, nodes); + // An annotation-only record has no Monotype closure to import. Its input + // store may already be frozen; do not open a mutation transaction merely + // to allocate the independent returned payload. + if (nodes.len == 0) { + if (stats) |out| out.* = reader.stats; + return copyFrozen(Payload, arena, payload); + } const relocated = try scratch.allocator().alloc(Type.TypeId, nodes.len); const transaction = types.beginTransaction(); errdefer transaction.abort(types); @@ -206,16 +213,12 @@ pub fn decodeFrozen(comptime Payload: type, schema: [8]u8, arena: Allocator, typ const mapper = Relocation{ .types = types, .ids = relocated, .allocator = scratch.allocator() }; for (nodes, relocated) |node, slot| transaction.fill(types, slot, try mapper.content(node)); var result = try copyFrozen(Payload, arena, payload); - if (nodes.len == 0) { - transaction.abort(types); - } else { - var committed = try types.commitTransaction(ns, transaction, relocated[0]); - defer committed.deinit(); - // commitTransaction maps every reserved node, including roots not - // reachable from the first root. This walk allocates nothing. - for (relocated) |*slot| slot.* = committed.remapType(slot.*); - relocateFrozen(Payload, &result, relocated); - } + var committed = try types.commitTransaction(ns, transaction, relocated[0]); + defer committed.deinit(); + // commitTransaction maps every reserved node, including roots not + // reachable from the first root. This walk allocates nothing. + for (relocated) |*slot| slot.* = committed.remapType(slot.*); + relocateFrozen(Payload, &result, relocated); reader.stats.types = nodes.len; if (stats) |out| out.* = reader.stats; return result; From f6116efd6e5bb5b9f9875370b2a2ddf9cea236f5 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 00:42:30 -0400 Subject: [PATCH 56/97] Persist only reachable completed lambda analysis types --- design.md | 6 + .../lambda_solved/analysis_cache.zig | 176 +++++++++++++++++- src/postcheck/lambda_solved/solve.zig | 1 + 3 files changed, 178 insertions(+), 5 deletions(-) diff --git a/design.md b/design.md index e90781d6235..4b70052301b 100644 --- a/design.md +++ b/design.md @@ -27358,3 +27358,9 @@ ids in an analysis record belong to the precisely identified input tables, not to an assumed stable source position. A changed input-table identity invalidates the record. This whole-analysis boundary is independent of finer-grained specialization body caching. + +Completed Lambda Solved records contain only the type graph reachable from +output annotations. Serialization resolves redirects and assigns compact local +ids while preserving cycles and sharing. Discarded unification rows, unreachable +lazy Monotype leaves, and solver-private clone-context ids are not output and +must never be persisted. The current executable IR remains unchanged. diff --git a/src/postcheck/lambda_solved/analysis_cache.zig b/src/postcheck/lambda_solved/analysis_cache.zig index db8f411bdf9..9f3ed434b16 100644 --- a/src/postcheck/lambda_solved/analysis_cache.zig +++ b/src/postcheck/lambda_solved/analysis_cache.zig @@ -49,6 +49,30 @@ pub fn identity(input: Lifted.ProgramView) [32]u8 { return out.finish(); } +/// Developer trace of the exact input columns, for explaining invalidation. +pub fn traceIdentity(input: Lifted.ProgramView) void { + inline for (columns) |field| { + var out = Fingerprint{ .names = input.names }; + out.value(@TypeOf(@field(input, field)), @field(input, field)); + std.debug.print("lambda-input {s}={x}\n", .{ field, out.finish() }); + } + inline for (type_columns) |field| { + var out = Fingerprint{ .names = input.names }; + out.value(@TypeOf(@field(input.types, field)), @field(input.types, field)); + std.debug.print("lambda-input types.{s}={x}\n", .{ field, out.finish() }); + } + var fns = Fingerprint{ .names = input.names }; + for (input.fns) |f| { + fns.value(@TypeOf(f.symbol), f.symbol); + fns.value(@TypeOf(f.signature), f.signature); + fns.value(@TypeOf(f.args), f.args); + fns.value(@TypeOf(f.captures), f.captures); + fns.value(@TypeOf(f.body), f.body); + fns.value(@TypeOf(f.ret), f.ret); + } + std.debug.print("lambda-input fns={x}\n", .{fns.finish()}); +} + const Fingerprint = struct { names: *const names.NameStore, hash: base.Sha256 = .init(.{}), @@ -149,6 +173,118 @@ fn payloadOf(program: *const Ast.Program, key: [32]u8) Payload { }; } +/// Copy only the completed graph reachable from output annotations. Solver +/// scratch rows (including abandoned lazy leaves) have no persistent meaning. +/// Reserve a destination before visiting its children, preserving cycles and +/// sharing without recursive traversal of user types. +const CompletedClosure = struct { + allocator: Allocator, + input: Type.Store.View, + output: Type.Store, + ids: []Type.MaybeTypeVarId, + pending: std.ArrayList(Type.TypeVarId) = .empty, + spans: std.AutoHashMapUnmanaged(SpanKey, Type.Span) = .empty, + const Column = enum { spans, fields, tags, captures, fn_members, declared_fields }; + const SpanKey = struct { column: Column, span: Type.Span }; + + fn copy(allocator: Allocator, original: Payload) Allocator.Error!Payload { + const ids = try allocator.alloc(Type.MaybeTypeVarId, original.types.vars.len); + @memset(ids, .none); + var self = CompletedClosure{ .allocator = allocator, .input = original.types, .output = Type.Store.init(allocator), .ids = ids }; + var result: Payload = undefined; + inline for (@typeInfo(Payload).@"struct".field_names) |field| { + if (comptime !std.mem.eql(u8, field, "types")) @field(result, field) = try self.value(@TypeOf(@field(original, field)), @field(original, field)); + } + var index: usize = 0; + while (index < self.pending.items.len) : (index += 1) { + const source = self.pending.items[index]; + const node = try self.content(self.input.get(source)); + try self.output.set(@fromBackingInt(@as(u32, @intCast(index))), node); + } + result.types = self.output.view(); + return result; + } + + fn ref(self: *CompletedClosure, id: Type.TypeVarId) Allocator.Error!Type.TypeVarId { + const root = self.input.root(id); + if (self.ids[@backingInt(root)].get()) |existing| return existing; + const fresh = try self.output.add(.unbound); + self.ids[@backingInt(root)] = .of(fresh); + try self.pending.append(self.allocator, root); + return fresh; + } + + fn value(self: *CompletedClosure, comptime T: type, v: T) Allocator.Error!T { + if (T == Type.TypeVarId) return self.ref(v); + return switch (@typeInfo(T)) { + .@"struct" => |info| blk: { + var out: T = undefined; + inline for (info.field_names, info.field_types) |field, F| @field(out, field) = try self.value(F, @field(v, field)); + break :blk out; + }, + .@"union" => |info| blk: { + inline for (info.field_names, info.field_types) |field, F| if (std.meta.activeTag(v) == @field(info.tag_type.?, field)) break :blk @unionInit(T, field, try self.value(F, @field(v, field))); + unreachable; + }, + .optional => |info| if (v) |present| try self.value(info.child, present) else null, + .pointer => |info| blk: { + if (info.size != .slice) @compileError("completed annotations contain owned slices"); + const out = try self.allocator.alloc(info.child, v.len); + for (v, out) |source, *dest| dest.* = try self.value(info.child, source); + break :blk out; + }, + else => v, + }; + } + + fn span(self: *CompletedClosure, comptime column: Column, old: Type.Span) Allocator.Error!Type.Span { + if (old.len == 0) return .empty(); + const key = SpanKey{ .column = column, .span = old }; + if (self.spans.get(key)) |existing| return existing; + const input = @field(self.input, @tagName(column))[old.start..][0..old.len]; + const list = &@field(self.output, @tagName(column)); + const start = std.math.cast(u32, list.items.len) orelse return error.OutOfMemory; + const result = Type.Span{ .start = start, .len = old.len }; + // Reserve the whole span first; processing member captures or tag + // payloads only appends to different columns. + const slots = try list.addManyAsSlice(self.allocator, old.len); + for (input, slots) |item, *slot| { + slot.* = try self.value(@TypeOf(item), item); + if (comptime column == .tags) slot.payloads = try self.span(.spans, item.payloads); + if (comptime column == .fn_members) slot.captures = try self.span(.captures, item.captures); + } + try self.spans.put(self.allocator, key, result); + return result; + } + + fn content(self: *CompletedClosure, old: Type.Content) Allocator.Error!Type.Content { + return switch (old) { + .link, .mono => @import("../common.zig").invariant("completed lambda output reached private solver state"), + .unbound, .forall, .primitive, .zst, .hole => old, + .list => |id| .{ .list = try self.ref(id) }, + .box => |id| .{ .box = try self.ref(id) }, + .tuple => |items| .{ .tuple = try self.span(.spans, items) }, + .record => |items| .{ .record = try self.span(.fields, items) }, + .tag_union => |items| .{ .tag_union = try self.span(.tags, items) }, + .lambda_set => |items| .{ .lambda_set = try self.span(.fn_members, items) }, + .func => |f| .{ .func = .{ .args = try self.span(.spans, f.args), .callable = try self.ref(f.callable), .ret = try self.ref(f.ret) } }, + .named => |n| blk: { + var out = n; + out.args = try self.span(.spans, n.args); + out.declared_order = try self.span(.declared_fields, n.declared_order); + if (out.backing) |*backing| backing.ty = try self.ref(backing.ty); + break :blk .{ .named = out }; + }, + .erased => |e| blk: { + var out = e; + out.members = try self.span(.fn_members, e.members); + if (out.abi_fn) |ty| out.abi_fn = try self.ref(ty); + break :blk .{ .erased = out }; + }, + }; + } +}; + const Context = struct { allocator: Allocator, input: Lifted.ProgramView, @@ -310,7 +446,10 @@ fn validate(allocator: Allocator, input: Lifted.ProgramView, p: Payload) Codec.D pub fn encode(allocator: Allocator, program: *Ast.Program, key: [32]u8) Allocator.Error!?[]u8 { var context = try Context.init(allocator, program.lifted.view(), key); defer context.deinit(); - return Codec.encodeFrozen(Payload, "LSAN0001".*, allocator, &program.lifted.types, &program.lifted.names, payloadOf(program, key), &context, null) catch |err| switch (err) { + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + const payload = try CompletedClosure.copy(scratch.allocator(), payloadOf(program, key)); + return Codec.encodeFrozen(Payload, "LSAN0001".*, allocator, &program.lifted.types, &program.lifted.names, payload, &context, null) catch |err| switch (err) { error.NotRepresentable => return null, error.OutOfMemory => return error.OutOfMemory, }; @@ -420,8 +559,9 @@ test "lambda analysis reuse keeps the current literal and reporting metadata" { defer restored.deinit(); try std.testing.expectEqualStrings("after with different length", restored.lifted.view().string_literals[0].text()); try std.testing.expectEqual(@as(u32, 88), restored.lifted.current_loc.line); - try std.testing.expectEqualDeep(first.types.view(), restored.types.view()); - try std.testing.expectEqualDeep(first.fn_tys.items, restored.fn_tys.items); + const func = restored.types.rootContentCompressed(restored.fn_tys.items[0]).func; + try std.testing.expectEqual(Mono.Primitive.str, restored.types.rootContentCompressed(func.ret).primitive); + try std.testing.expectEqual(restored.types.rootCompressed(func.ret), restored.types.rootCompressed(restored.expr_tys.items[0])); } test "lambda analysis identity includes callable topology and rejects mismatched records" { @@ -484,8 +624,13 @@ test "lambda analysis preserves recursive callable relations and rejects redirec var restored = (try restore(gpa, ¤t, key, bytes)).?; owned = false; defer restored.deinit(); - try std.testing.expectEqualDeep(first.types.view(), restored.types.view()); - try std.testing.expectEqualDeep(first.local_tys.items, restored.local_tys.items); + const func = restored.types.rootContentCompressed(restored.fn_tys.items[0]).func; + const argument = restored.types.spanItem(func.args, 0); + const row = restored.types.rootContentCompressed(argument).record; + const next = restored.types.fieldItem(row, 0); + const callback = restored.types.rootContentCompressed(next.ty).func; + try std.testing.expectEqual(restored.types.rootCompressed(argument), restored.types.rootCompressed(callback.ret)); + try std.testing.expectEqual(restored.types.rootCompressed(argument), restored.types.rootCompressed(restored.local_tys.items[0])); var bad = payloadOf(&first, key); const vars = try gpa.dupe(Type.StoredNode, bad.types.vars); defer gpa.free(vars); @@ -515,3 +660,24 @@ test "lambda analysis identity distinguishes callbacks with the same signature" input.setExprData(closure, .{ .fn_ref = .{ .fn_id = second, .captures = .empty() } }); try std.testing.expect(!std.mem.eql(u8, &before, &identity(input.view()))); } + +test "lambda analysis persistence excludes abandoned lazy solver rows" { + const gpa = std.testing.allocator; + var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); + defer first.deinit(); + const key = identity(first.lifted.view()); + const clean = (try encode(gpa, &first, key)).?; + defer gpa.free(clean); + _ = try first.types.add(.{ .mono = .{ .id = first.lifted.view().fns[0].ret, .ctx = 1234 } }); + const with_scratch = (try encode(gpa, &first, key)).?; + defer gpa.free(with_scratch); + try std.testing.expectEqualSlices(u8, clean, with_scratch); + var current = try literalInput(gpa, "after"); + current.types.freeze(); + var owned = true; + defer if (owned) current.deinit(); + var restored = (try restore(gpa, ¤t, key, with_scratch)).?; + owned = false; + defer restored.deinit(); + for (restored.types.vars.items) |node| try std.testing.expect(node != .mono and node != .link); +} diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index ad0432126cb..0d0cd264e06 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -268,6 +268,7 @@ pub fn runWithCache(allocator: Allocator, lifted: Lifted.Program, cache: ?Common errdefer program.deinit(); const key = if (cache != null) AnalysisCache.identity(program.lifted.view()) else null; + if (diagnostics.isSet(.pack_trace)) AnalysisCache.traceIdentity(program.lifted.view()); var family: [32]u8 = undefined; baseHash("roc.lambda-analysis-cache.v1", &family); if (cache) |provider| { From 8d6c4ee2d427035722d84b7280884b7c8946ada9 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 01:11:55 -0400 Subject: [PATCH 57/97] Key lambda analysis by canonical reachable input types --- design.md | 15 +- .../lambda_solved/analysis_cache.zig | 300 ++++++++++++++---- src/postcheck/lambda_solved/solve.zig | 8 +- src/postcheck/monotype/interface_summary.zig | 3 + src/postcheck/monotype/lower.zig | 1 + 5 files changed, 265 insertions(+), 62 deletions(-) diff --git a/design.md b/design.md index 4b70052301b..1cc3d761825 100644 --- a/design.md +++ b/design.md @@ -27353,10 +27353,17 @@ name meanings, function signatures and bodies, expression and binding topology, capture identities, and ABI, layout, and runtime-schema boundaries. Literal byte backings and reporting locations are not solver inputs. The current lifted program always supplies executable expressions, literal contents, and reporting -metadata; an analysis cache never replaces it with an earlier program. Numeric -ids in an analysis record belong to the precisely identified input tables, not -to an assumed stable source position. A changed input-table identity invalidates -the record. This whole-analysis boundary is independent of finer-grained +metadata; an analysis cache never replaces it with an earlier program. Expression, local, and function ids in an analysis record belong to the +precisely identified current IR tables, not to an assumed stable source position. +Monotype type ids are canonically numbered by first reach from these tables; +only their reachable graph contributes to the identity. This preserves exact +sharing and cycles while excluding unused store rows. Checked occurrence +addresses are used for equality by the solver: the key records their equality +partition and exact nominal owners, and the record stores an explicit ordinal +into the matching input's occurrence table. Restore binds that ordinal to the +current checked occurrence. It never reuses the previous artifact's address. +All type structure, named definitions, backing authority, dispatch metadata, +and ABI boundaries remain in the identity. This whole-analysis boundary is independent of finer-grained specialization body caching. Completed Lambda Solved records contain only the type graph reachable from diff --git a/src/postcheck/lambda_solved/analysis_cache.zig b/src/postcheck/lambda_solved/analysis_cache.zig index 9f3ed434b16..d9377bd8d45 100644 --- a/src/postcheck/lambda_solved/analysis_cache.zig +++ b/src/postcheck/lambda_solved/analysis_cache.zig @@ -21,17 +21,38 @@ const columns = .{ }; const type_columns = .{ "types", "named_nodes", "erased_digests", "spans", "fields", "tags", "declared_fields" }; -/// Versioned identity of the complete solver input, including table order. -/// No source-position identity or inference from a prior result is involved. -pub fn identity(input: Lifted.ProgramView) [32]u8 { - var out = Fingerprint{ .names = input.names }; - out.bytes("roc.lambda-analysis-input.v1"); +/// Exact consumed input modulo renaming of Monotype table ids. Expression, +/// function and binding tables keep their current identities. Named occurrence +/// references are rebound through the matching current input, never an old +/// checked artifact. Their equality partition remains part of the key. +pub const Identity = struct { + key: [32]u8, + occurrences: []Mono.NamedType, + pub fn deinit(self: Identity, allocator: Allocator) void { + allocator.free(self.occurrences); + } +}; + +pub fn identity(allocator: Allocator, input: Lifted.ProgramView) Allocator.Error!Identity { + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + const gpa = scratch.allocator(); + const ids = try gpa.alloc(u32, input.types.types.len); + @memset(ids, std.math.maxInt(u32)); + var occurrences: std.AutoHashMapUnmanaged(Mono.NamedType, u32) = .empty; + try occurrences.ensureTotalCapacity(gpa, @intCast(input.types.named_nodes.len)); + var out = Fingerprint{ + .names = input.names, + .types = input.types, + .type_ids = ids, + .pending_types = try gpa.alloc(Mono.TypeId, ids.len), + .occurrence_ids = &occurrences, + .pending_occurrences = try gpa.alloc(Mono.NamedType, input.types.named_nodes.len), + }; + out.bytes("roc.lambda-analysis-input.v2"); inline for (columns) |field| { out.value(@TypeOf(@field(input, field)), @field(input, field)); } - inline for (type_columns) |field| { - out.value(@TypeOf(@field(input.types, field)), @field(input.types, field)); - } out.value(usize, input.fns.len); for (input.fns) |f| { out.value(@TypeOf(f.symbol), f.symbol); @@ -46,7 +67,11 @@ pub fn identity(input: Lifted.ProgramView) [32]u8 { out.value(@TypeOf(root.fn_id), root.fn_id); out.value(@TypeOf(root.request.abi), root.request.abi); } - return out.finish(); + out.bytes("type-graph"); + var index: usize = 0; + while (index < out.type_count) : (index += 1) out.typeContent(input.types.get(out.pending_types[index])); + out.value(usize, out.type_count); + return .{ .key = out.finish(), .occurrences = try allocator.dupe(Mono.NamedType, out.pending_occurrences[0..out.occurrence_count]) }; } /// Developer trace of the exact input columns, for explaining invalidation. @@ -75,6 +100,13 @@ pub fn traceIdentity(input: Lifted.ProgramView) void { const Fingerprint = struct { names: *const names.NameStore, + types: ?Mono.Store.View = null, + type_ids: []u32 = &.{}, + pending_types: []Mono.TypeId = &.{}, + type_count: u32 = 0, + occurrence_ids: ?*std.AutoHashMapUnmanaged(Mono.NamedType, u32) = null, + pending_occurrences: []Mono.NamedType = &.{}, + occurrence_count: u32 = 0, hash: base.Sha256 = .init(.{}), buffer: [4096]u8 = undefined, used: usize = 0, @@ -94,15 +126,72 @@ const Fingerprint = struct { self.hash.update(self.buffer[0..self.used]); return self.hash.finalResult(); } + fn typeSpan(self: *Fingerprint, comptime field: []const u8, span: Mono.Span) void { + const values = @field(self.types.?, field)[span.start..][0..span.len]; + self.value(@TypeOf(values), values); + } + fn typeContent(self: *Fingerprint, content: Mono.Content) void { + self.value(std.meta.Tag(Mono.Content), std.meta.activeTag(content)); + switch (content) { + .primitive => |v| self.value(@TypeOf(v), v), + .erased => |v| self.value(@TypeOf(v), v), + .zst, .hole => {}, + .list, .box => |v| self.value(Mono.TypeId, v), + .tuple => |v| self.typeSpan("spans", v), + .record => |v| self.typeSpan("fields", v), + .func => |v| { + self.typeSpan("spans", v.args); + self.value(Mono.TypeId, v.ret); + }, + .tag_union => |v| { + self.value(u32, v.len); + for (self.types.?.tags[v.start..][0..v.len]) |tag| { + self.value(names.TagNameId, tag.name); + self.value(names.TagNameId, tag.checked_name); + self.typeSpan("spans", tag.payloads); + } + }, + .named => |v| { + self.value(Mono.NamedType, v.named_type); + self.value(@TypeOf(v.def), v.def); + self.value(@TypeOf(v.kind), v.kind); + self.value(@TypeOf(v.builtin_owner), v.builtin_owner); + self.typeSpan("spans", v.args); + self.value(@TypeOf(v.backing), v.backing); + self.typeSpan("declared_fields", v.declared_order); + }, + } + } fn value(self: *Fingerprint, comptime T: type, v: T) void { - // Keep both the table id and its meaning: output annotations retain - // numeric references to this input, and name equality reads the text. + if (T == Mono.TypeId) if (self.types != null) { + const slot = &self.type_ids[@backingInt(v)]; + if (slot.* == std.math.maxInt(u32)) { + slot.* = self.type_count; + self.pending_types[self.type_count] = v; + self.type_count += 1; + } + return self.value(u32, slot.*); + }; + if (T == Mono.MaybeTypeId) if (self.types != null) return self.value(?Mono.TypeId, v.get()); + if (T == Mono.NamedType) if (self.occurrence_ids) |ids| { + const entry = ids.getOrPutAssumeCapacity(v); + if (!entry.found_existing) { + entry.value_ptr.* = self.occurrence_count; + self.pending_occurrences[self.occurrence_count] = v; + self.occurrence_count += 1; + } + // Nominal owner is semantic. The checked occurrence's raw address + // is only used for equality by this solver; preserve that relation + // and bind the annotation to its current occurrence on restore. + self.bytes(&v.module.bytes); + return self.value(u32, entry.value_ptr.*); + }; if (T == names.ModuleIdentityId) { - self.value(u32, @backingInt(v)); + if (self.types == null) self.value(u32, @backingInt(v)); return self.bytes(self.names.moduleIdentityBytes(v)); } if (T == names.RecordFieldNameId or T == names.TagNameId or T == names.TypeNameId or T == names.ExportNameId or T == names.ExternalSymbolNameId) { - self.value(u32, @backingInt(v)); + if (self.types == null) self.value(u32, @backingInt(v)); const text = if (T == names.RecordFieldNameId) self.names.recordFieldLabelText(v) else if (T == names.TagNameId) self.names.tagLabelText(v) else if (T == names.TypeNameId) self.names.typeNameText(v) else if (T == names.ExportNameId) self.names.exportNameText(v) else self.names.externalSymbolNameText(v); return self.value([]const u8, text); } @@ -289,15 +378,16 @@ const Context = struct { allocator: Allocator, input: Lifted.ProgramView, key: [32]u8, - named: std.AutoHashMapUnmanaged(Mono.NamedType, void) = .empty, + named: std.AutoHashMapUnmanaged(Mono.NamedType, u32) = .empty, + occurrences: []const Mono.NamedType, modules: std.AutoHashMapUnmanaged([32]u8, void) = .empty, defaults: std.AutoHashMapUnmanaged([36]u8, void) = .empty, - fn init(allocator: Allocator, input: Lifted.ProgramView, key: [32]u8) Allocator.Error!Context { - var self = Context{ .allocator = allocator, .input = input, .key = key }; + fn init(allocator: Allocator, input: Lifted.ProgramView, id: *const Identity) Allocator.Error!Context { + var self = Context{ .allocator = allocator, .input = input, .key = id.key, .occurrences = id.occurrences }; errdefer self.deinit(); + for (id.occurrences, 0..) |named, index| try self.named.put(allocator, named, @intCast(index)); for (input.types.named_nodes) |named| { - try self.named.put(allocator, named.named_type, {}); try self.modules.put(allocator, input.names.moduleIdentityBytes(named.def.module).*, {}); } for (input.types.fields) |field| if (field.default) |default| { @@ -328,13 +418,12 @@ const Context = struct { pub fn validModuleIdentity(self: *const Context, module: [32]u8) bool { return self.modules.contains(module); } - pub fn describeNamedType(_: *const Context, named: Mono.NamedType) Codec.NamedOccurrence { - return .{ .exact = .{ .owner = named.module.bytes, .occurrence = named.ty_module.bytes, .ty = @backingInt(named.ty) } }; + pub fn describeNamedType(self: *const Context, named: Mono.NamedType) Codec.NamedOccurrence { + return .{ .input = self.named.get(named) orelse @import("../common.zig").invariant("completed annotation named an occurrence outside its input") }; } pub fn resolveNamedType(self: *const Context, stored: Codec.NamedOccurrence) Allocator.Error!?Mono.NamedType { - if (stored != .exact) return null; - const named = Mono.NamedType{ .module = .{ .bytes = stored.exact.owner }, .ty_module = .{ .bytes = stored.exact.occurrence }, .ty = @fromBackingInt(stored.exact.ty) }; - return if (self.named.contains(named)) named else null; + if (stored != .input or stored.input >= self.occurrences.len) return null; + return self.occurrences[stored.input]; } pub fn validNamedType(self: *const Context, named: Mono.NamedType) bool { return self.named.contains(named); @@ -443,13 +532,13 @@ fn validate(allocator: Allocator, input: Lifted.ProgramView, p: Payload) Codec.D } /// Encode only annotations; no source or executable payload is persisted. -pub fn encode(allocator: Allocator, program: *Ast.Program, key: [32]u8) Allocator.Error!?[]u8 { - var context = try Context.init(allocator, program.lifted.view(), key); +pub fn encode(allocator: Allocator, program: *Ast.Program, id: *const Identity) Allocator.Error!?[]u8 { + var context = try Context.init(allocator, program.lifted.view(), id); defer context.deinit(); var scratch = std.heap.ArenaAllocator.init(allocator); defer scratch.deinit(); - const payload = try CompletedClosure.copy(scratch.allocator(), payloadOf(program, key)); - return Codec.encodeFrozen(Payload, "LSAN0001".*, allocator, &program.lifted.types, &program.lifted.names, payload, &context, null) catch |err| switch (err) { + const payload = try CompletedClosure.copy(scratch.allocator(), payloadOf(program, id.key)); + return Codec.encodeFrozen(Payload, "LSAN0002".*, allocator, &program.lifted.types, &program.lifted.names, payload, &context, null) catch |err| switch (err) { error.NotRepresentable => return null, error.OutOfMemory => return error.OutOfMemory, }; @@ -457,7 +546,7 @@ pub fn encode(allocator: Allocator, program: *Ast.Program, key: [32]u8) Allocato /// On success transfers the current lifted program into the restored result. /// Invalid optional data leaves the current program available for fresh solving. -pub fn restore(allocator: Allocator, lifted: *Lifted.Program, key: [32]u8, bytes: []const u8) Allocator.Error!?Ast.Program { +pub fn restore(allocator: Allocator, lifted: *Lifted.Program, id: *const Identity, bytes: []const u8) Allocator.Error!?Ast.Program { const arena = try allocator.create(std.heap.ArenaAllocator); arena.* = .init(allocator); var owned = true; @@ -465,9 +554,9 @@ pub fn restore(allocator: Allocator, lifted: *Lifted.Program, key: [32]u8, bytes arena.deinit(); allocator.destroy(arena); }; - var context = try Context.init(allocator, lifted.view(), key); + var context = try Context.init(allocator, lifted.view(), id); defer context.deinit(); - const payload = Codec.decodeFrozen(Payload, "LSAN0001".*, arena.allocator(), &lifted.types, &lifted.names, bytes, &context, null) catch |err| switch (err) { + const payload = Codec.decodeFrozen(Payload, "LSAN0002".*, arena.allocator(), &lifted.types, &lifted.names, bytes, &context, null) catch |err| switch (err) { error.InvalidSummary => return null, error.OutOfMemory => return error.OutOfMemory, }; @@ -545,16 +634,20 @@ test "lambda analysis reuse keeps the current literal and reporting metadata" { const gpa = std.testing.allocator; var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); defer first.deinit(); - const key = identity(first.lifted.view()); - const bytes = (try encode(gpa, &first, key)).?; + const id = try identity(gpa, first.lifted.view()); + defer id.deinit(gpa); + const key = id.key; + const bytes = (try encode(gpa, &first, &id)).?; defer gpa.free(bytes); var current = try literalInput(gpa, "after with different length"); var current_owned = true; defer if (current_owned) current.deinit(); current.current_loc = .{ .file = 0, .line = 88, .column = 3 }; current.types.freeze(); - try std.testing.expectEqual(key, identity(current.view())); - var restored = (try restore(gpa, ¤t, key, bytes)).?; + const current_id = try identity(gpa, current.view()); + defer current_id.deinit(gpa); + try std.testing.expectEqual(key, current_id.key); + var restored = (try restore(gpa, ¤t, &id, bytes)).?; current_owned = false; defer restored.deinit(); try std.testing.expectEqualStrings("after with different length", restored.lifted.view().string_literals[0].text()); @@ -568,28 +661,34 @@ test "lambda analysis identity includes callable topology and rejects mismatched const gpa = std.testing.allocator; var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); defer first.deinit(); - const key = identity(first.lifted.view()); - const bytes = (try encode(gpa, &first, key)).?; + const id = try identity(gpa, first.lifted.view()); + defer id.deinit(gpa); + const key = id.key; + const bytes = (try encode(gpa, &first, &id)).?; defer gpa.free(bytes); var current = try literalInput(gpa, "after"); defer current.deinit(); - _ = try current.types.internPrimitive(¤t.names, .u64); - const changed = identity(current.view()); - try std.testing.expect(!std.mem.eql(u8, &key, &changed)); - try std.testing.expectEqual(null, try restore(gpa, ¤t, changed, bytes)); - for (0..bytes.len) |end| try std.testing.expectEqual(null, try restore(gpa, ¤t, key, bytes[0..end])); + var changed_fn = current.getFnAt(0); + changed_fn.ret = try current.types.internPrimitive(¤t.names, .u64); + current.setFnAt(0, changed_fn); + const changed = try identity(gpa, current.view()); + defer changed.deinit(gpa); + try std.testing.expect(!std.mem.eql(u8, &key, &changed.key)); + try std.testing.expectEqual(null, try restore(gpa, ¤t, &changed, bytes)); + for (0..bytes.len) |end| try std.testing.expectEqual(null, try restore(gpa, ¤t, &id, bytes[0..end])); } fn analysisAllocationRoundTrip(gpa: Allocator) !void { var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); defer first.deinit(); - const key = identity(first.lifted.view()); - const bytes = (try encode(gpa, &first, key)).?; + const id = try identity(gpa, first.lifted.view()); + defer id.deinit(gpa); + const bytes = (try encode(gpa, &first, &id)).?; defer gpa.free(bytes); var current = try literalInput(gpa, "after"); var owned = true; defer if (owned) current.deinit(); - var restored = (try restore(gpa, ¤t, key, bytes)).?; + var restored = (try restore(gpa, ¤t, &id, bytes)).?; owned = false; defer restored.deinit(); } @@ -599,8 +698,12 @@ test "lambda analysis allocation failures preserve input ownership" { } fn recursiveInput(allocator: Allocator) !Lifted.Program { + return recursiveInputWithPadding(allocator, false); +} +fn recursiveInputWithPadding(allocator: Allocator, padding: bool) !Lifted.Program { var input = emptyInput(allocator); errdefer input.deinit(); + if (padding) _ = try input.types.internPrimitive(&input.names, .bool); const root = try input.types.beginRecursive(); const callback = try input.types.add(.{ .func = .{ .args = .empty(), .ret = root.ty } }); input.types.finishRecursive(root, .{ .record = try input.types.addFields(&.{.{ .name = try input.names.internRecordFieldLabel("next"), .ty = callback, .default = null }}) }); @@ -615,13 +718,15 @@ test "lambda analysis preserves recursive callable relations and rejects redirec const gpa = std.testing.allocator; var first = try @import("solve.zig").run(gpa, try recursiveInput(gpa)); defer first.deinit(); - const key = identity(first.lifted.view()); - const bytes = (try encode(gpa, &first, key)).?; + const id = try identity(gpa, first.lifted.view()); + defer id.deinit(gpa); + const key = id.key; + const bytes = (try encode(gpa, &first, &id)).?; defer gpa.free(bytes); var current = try recursiveInput(gpa); var owned = true; defer if (owned) current.deinit(); - var restored = (try restore(gpa, ¤t, key, bytes)).?; + var restored = (try restore(gpa, ¤t, &id, bytes)).?; owned = false; defer restored.deinit(); const func = restored.types.rootContentCompressed(restored.fn_tys.items[0]).func; @@ -636,13 +741,13 @@ test "lambda analysis preserves recursive callable relations and rejects redirec defer gpa.free(vars); vars[0] = .{ .link = @fromBackingInt(0) }; bad.types.vars = vars; - var context = try Context.init(gpa, first.lifted.view(), key); + var context = try Context.init(gpa, first.lifted.view(), &id); defer context.deinit(); - const corrupt = try Codec.encodeFrozen(Payload, "LSAN0001".*, gpa, &first.lifted.types, &first.lifted.names, bad, &context, null); + const corrupt = try Codec.encodeFrozen(Payload, "LSAN0002".*, gpa, &first.lifted.types, &first.lifted.names, bad, &context, null); defer gpa.free(corrupt); var rejected_input = try recursiveInput(gpa); defer rejected_input.deinit(); - try std.testing.expectEqual(null, try restore(gpa, &rejected_input, key, corrupt)); + try std.testing.expectEqual(null, try restore(gpa, &rejected_input, &id, corrupt)); } test "lambda analysis identity distinguishes callbacks with the same signature" { @@ -656,28 +761,113 @@ test "lambda analysis identity distinguishes callbacks with the same signature" const signature = try input.types.add(.{ .func = .{ .args = .empty(), .ret = first.ret } }); const closure = try input.addExpr(.{ .ty = signature, .data = .{ .fn_ref = .{ .fn_id = @fromBackingInt(0), .captures = .empty() } } }); _ = try input.addFn(.{ .symbol = @fromBackingInt(2), .args = .empty(), .captures = .empty(), .body = .{ .roc = closure }, .ret = signature }); - const before = identity(input.view()); + const before = try identity(gpa, input.view()); + defer before.deinit(gpa); input.setExprData(closure, .{ .fn_ref = .{ .fn_id = second, .captures = .empty() } }); - try std.testing.expect(!std.mem.eql(u8, &before, &identity(input.view()))); + const after = try identity(gpa, input.view()); + defer after.deinit(gpa); + try std.testing.expect(!std.mem.eql(u8, &before.key, &after.key)); } test "lambda analysis persistence excludes abandoned lazy solver rows" { const gpa = std.testing.allocator; var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); defer first.deinit(); - const key = identity(first.lifted.view()); - const clean = (try encode(gpa, &first, key)).?; + const id = try identity(gpa, first.lifted.view()); + defer id.deinit(gpa); + const clean = (try encode(gpa, &first, &id)).?; defer gpa.free(clean); _ = try first.types.add(.{ .mono = .{ .id = first.lifted.view().fns[0].ret, .ctx = 1234 } }); - const with_scratch = (try encode(gpa, &first, key)).?; + const with_scratch = (try encode(gpa, &first, &id)).?; defer gpa.free(with_scratch); try std.testing.expectEqualSlices(u8, clean, with_scratch); var current = try literalInput(gpa, "after"); current.types.freeze(); var owned = true; defer if (owned) current.deinit(); - var restored = (try restore(gpa, ¤t, key, with_scratch)).?; + var restored = (try restore(gpa, ¤t, &id, with_scratch)).?; owned = false; defer restored.deinit(); for (restored.types.vars.items) |node| try std.testing.expect(node != .mono and node != .link); } + +test "lambda analysis identity ignores type numbering and unreachable types" { + const gpa = std.testing.allocator; + var first = try recursiveInputWithPadding(gpa, false); + defer first.deinit(); + var second = try recursiveInputWithPadding(gpa, true); + defer second.deinit(); + const a = try identity(gpa, first.view()); + defer a.deinit(gpa); + const b = try identity(gpa, second.view()); + defer b.deinit(gpa); + try std.testing.expectEqual(a.key, b.key); + try std.testing.expect(first.getFnAt(0).ret != second.getFnAt(0).ret); +} + +fn occurrenceInput(allocator: Allocator, version: u8, shared: bool, owner: u8) !Lifted.Program { + var input = emptyInput(allocator); + errdefer input.deinit(); + if (version != 0) _ = try input.types.internPrimitive(&input.names, .bool); + const number = try input.types.internPrimitive(&input.names, .u64); + const module = try input.names.internModuleIdentity(&@as([32]u8, @splat(1))); + const label = try input.names.internTypeName("Value"); + var named: @FieldType(Mono.Content, "named") = .{ + .named_type = .{ .module = .{ .bytes = @splat(owner) }, .ty_module = .{ .bytes = @splat(version) }, .ty = @fromBackingInt(@as(u32, version) + 7) }, + .def = .{ .module = module, .type_name = label }, + .kind = .nominal, + .args = .empty(), + .backing = .{ .ty = number, .use = .inspectable }, + }; + const first = try input.types.add(.{ .named = named }); + if (!shared) named.named_type.ty = @fromBackingInt(@backingInt(named.named_type.ty) + 1); + const second = try input.types.add(.{ .named = named }); + const a = try input.addLocal(@fromBackingInt(0), first); + const b = try input.addLocal(@fromBackingInt(1), second); + const args = try input.addTypedLocalSpan(&.{ .{ .local = a, .ty = first }, .{ .local = b, .ty = second } }); + const body = try input.addExpr(.{ .ty = second, .data = .{ .local = b } }); + _ = try input.addFn(.{ .symbol = @fromBackingInt(2), .args = args, .captures = .empty(), .body = .{ .roc = body }, .ret = second }); + return input; +} + +test "lambda analysis rebinds checked occurrences to the current matching input" { + const gpa = std.testing.allocator; + var first = try @import("solve.zig").run(gpa, try occurrenceInput(gpa, 0, true, 9)); + defer first.deinit(); + const a = try identity(gpa, first.lifted.view()); + defer a.deinit(gpa); + const bytes = (try encode(gpa, &first, &a)).?; + defer gpa.free(bytes); + var current = try occurrenceInput(gpa, 2, true, 9); + var owned = true; + defer if (owned) current.deinit(); + const b = try identity(gpa, current.view()); + defer b.deinit(gpa); + try std.testing.expectEqual(a.key, b.key); + const expected = current.types.get(current.getFnAt(0).ret).named.named_type; + current.types.freeze(); + var restored = (try restore(gpa, ¤t, &b, bytes)).?; + owned = false; + defer restored.deinit(); + const func = restored.types.rootContentCompressed(restored.fn_tys.items[0]).func; + const actual = restored.types.rootContentCompressed(func.ret).named.named_type; + try std.testing.expectEqualDeep(expected, actual); +} + +test "lambda analysis preserves checked occurrence equality and nominal ownership" { + const gpa = std.testing.allocator; + var a_input = try occurrenceInput(gpa, 0, true, 9); + defer a_input.deinit(); + const a = try identity(gpa, a_input.view()); + defer a.deinit(gpa); + var b_input = try occurrenceInput(gpa, 0, false, 9); + defer b_input.deinit(); + const b = try identity(gpa, b_input.view()); + defer b.deinit(gpa); + var c_input = try occurrenceInput(gpa, 0, true, 10); + defer c_input.deinit(); + const c = try identity(gpa, c_input.view()); + defer c.deinit(gpa); + try std.testing.expect(!std.mem.eql(u8, &a.key, &b.key)); + try std.testing.expect(!std.mem.eql(u8, &a.key, &c.key)); +} diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index 0d0cd264e06..39e94e6ca6d 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -267,14 +267,16 @@ pub fn runWithCache(allocator: Allocator, lifted: Lifted.Program, cache: ?Common var program = Ast.Program.init(allocator, lifted); errdefer program.deinit(); - const key = if (cache != null) AnalysisCache.identity(program.lifted.view()) else null; + const analysis_id = if (cache != null) try AnalysisCache.identity(allocator, program.lifted.view()) else null; + defer if (analysis_id) |id| id.deinit(allocator); + const key = if (analysis_id) |id| id.key else null; if (diagnostics.isSet(.pack_trace)) AnalysisCache.traceIdentity(program.lifted.view()); var family: [32]u8 = undefined; baseHash("roc.lambda-analysis-cache.v1", &family); if (cache) |provider| { if (try provider.load(provider.context, allocator, family, key.?)) |bytes| { defer allocator.free(bytes); - if (try AnalysisCache.restore(allocator, &program.lifted, key.?, bytes)) |restored| { + if (try AnalysisCache.restore(allocator, &program.lifted, &analysis_id.?, bytes)) |restored| { if (diagnostics.isSet(.lambda_solve_stats)) std.debug.print("lambda-analysis hit key={x} bytes={d}\n", .{ key.?, bytes.len }); // The empty annotation stores allocated by init own no storage; // restore consumed its lifted input and owns the returned arena. @@ -289,7 +291,7 @@ pub fn runWithCache(allocator: Allocator, lifted: Lifted.Program, cache: ?Common var counters: WorkCounters = .{}; if (diagnostics.isSet(.lambda_solve_stats)) solver.counters = &counters; try solver.solve(); - if (cache) |provider| if (try AnalysisCache.encode(allocator, &program, key.?)) |bytes| { + if (cache) |provider| if (try AnalysisCache.encode(allocator, &program, &analysis_id.?)) |bytes| { defer allocator.free(bytes); try provider.record(provider.context, family, key.?, bytes); if (diagnostics.isSet(.lambda_solve_stats)) std.debug.print("lambda-analysis record key={x} bytes={d}\n", .{ key.?, bytes.len }); diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index 63e6d14c504..6e58d005f67 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -20,6 +20,8 @@ const frozen_magic = "RIFRZ001"; pub const NamedOccurrence = union(enum) { exact: struct { owner: [32]u8, occurrence: [32]u8, ty: u32 }, closed: struct { owner: [32]u8, key: [32]u8 }, + /// Context-defined ordinal in an exactly identified current input. + input: u32, }; /// Work performed by the compact codec, independent of wall-clock timing. @@ -785,6 +787,7 @@ const TestProvenance = struct { } pub fn resolveNamedType(self: @This(), occurrence: NamedOccurrence) Allocator.Error!?Type.NamedType { return switch (occurrence) { + .input => null, .exact => |exact| .{ .module = .{ .bytes = exact.owner }, .ty_module = .{ .bytes = exact.occurrence }, .ty = @fromBackingInt(exact.ty) }, .closed => |closed| blk: { const key = self.closed_key orelse break :blk null; diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 503427a2ff5..4914629793b 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -28091,6 +28091,7 @@ const BodyContext = struct { } pub fn resolveNamedType(self: PersistentProvenance, stored: PersistentInterface.NamedOccurrence) Allocator.Error!?Type.NamedType { return switch (stored) { + .input => null, .exact => |exact| .{ .module = .{ .bytes = exact.owner }, .ty_module = .{ .bytes = exact.occurrence }, .ty = @fromBackingInt(exact.ty) }, .closed => |closed| blk: { const entry = try self.builder.persistent_closed_occurrences.getOrPut(self.builder.allocator, closed.key); From 40c437fc7da58c5306e13c7428e57170416e5723 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 02:09:40 -0400 Subject: [PATCH 58/97] Preserve packed and field alignment when relocating frozen payloads --- src/postcheck/monotype/interface_summary.zig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index 6e58d005f67..4b037166f54 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -258,7 +258,7 @@ fn copyFrozen(comptime T: type, arena: Allocator, value: T) Allocator.Error!T { }; } -fn relocateFrozen(comptime T: type, value: *T, ids: []const Type.TypeId) void { +fn relocateFrozen(comptime T: type, value: anytype, ids: []const Type.TypeId) void { if (T == Type.TypeId) { value.* = ids[@backingInt(value.*)]; return; @@ -814,9 +814,10 @@ const FrozenTestPayload = struct { edges: []const u32, name: names.RecordFieldNameId, data: []const u8, - integer: i128, + integer: i128 align(8), float: f64, optional: Type.MaybeTypeId, + bits: packed struct(u64) { ty: Type.TypeId, enabled: bool, padding: u31 = 0 }, }; fn frozenTestBytes(allocator: Allocator) ![]u8 { @@ -839,6 +840,7 @@ fn frozenTestBytes(allocator: Allocator) ![]u8 { .integer = std.math.minInt(i128) + 17, .float = @bitCast(@as(u64, 0x7ff8000000000053)), .optional = .of(function), + .bits = .{ .ty = slot.ty, .enabled = true }, }, TestProvenance{}, null); } @@ -859,6 +861,8 @@ test "frozen specialization payload owns data and relocates cyclic shared type r const field = GuardedList.at(dest.fieldSpan(dest.get(result.types[0]).record), 0); try std.testing.expectEqual(result.types[0], dest.get(field.ty).func.ret); try std.testing.expectEqual(field.ty, result.optional.get().?); + try std.testing.expectEqual(result.types[0], result.bits.ty); + try std.testing.expect(result.bits.enabled); try std.testing.expectEqual(Type.Primitive.u64, dest.get(result.types[2]).primitive); try std.testing.expectEqualStrings("step", ns.recordFieldLabelText(result.name)); try std.testing.expectEqualStrings("owned bytes\x00\xff", result.data); From d24db2bf20ae7bae927329c4374374d8657b56e5 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 02:35:36 -0400 Subject: [PATCH 59/97] Keep whole-program lambda analysis caching opt-in while refining reuse boundaries --- design.md | 7 +++++++ src/base/dev_diagnostics.zig | 3 +++ src/lir/checked_pipeline.zig | 6 +++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/design.md b/design.md index 1cc3d761825..5a731d60b94 100644 --- a/design.md +++ b/design.md @@ -27366,6 +27366,13 @@ All type structure, named definitions, backing authority, dispatch metadata, and ABI boundaries remain in the identity. This whole-analysis boundary is independent of finer-grained specialization body caching. +The complete-analysis cache is currently an experiment enabled only by the +`lambda_analysis_cache` developer diagnostic. Native object reuse changes the +assembled lifted program between builds, so this coarse boundary has not +demonstrated useful reuse on the measured applications. Ordinary builds do not +pay for its input fingerprinting, encoding, or publication. Its immutable codec +and explicit validation remain available for finer-grained experiments. + Completed Lambda Solved records contain only the type graph reachable from output annotations. Serialization resolves redirects and assigns compact local ids while preserving cycles and sharing. Discarded unification rows, unreachable diff --git a/src/base/dev_diagnostics.zig b/src/base/dev_diagnostics.zig index 2fee189b206..7919ce3868f 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -24,6 +24,8 @@ pub const Var = enum { spec_census, /// Print deterministic Lambda Solved work once per required invocation. lambda_solve_stats, + /// Exercise the experimental complete Lambda Solved analysis cache. + lambda_analysis_cache, /// Start each top-level recursive type analysis with empty proof tables. fresh_type_analysis, /// Print every object cache lookup and offer. @@ -54,6 +56,7 @@ pub const Var = enum { .lir_dump => "ROC_LIR_DUMP", .spec_census => "ROC_SPEC_CENSUS", .lambda_solve_stats => "ROC_LAMBDA_SOLVE_STATS", + .lambda_analysis_cache => "ROC_LAMBDA_ANALYSIS_CACHE", .fresh_type_analysis => "ROC_FRESH_TYPE_ANALYSIS", .pack_trace => "ROC_PACK_TRACE", .pack_stats => "ROC_PACK_STATS", diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index f5ca7e02647..c387be4117c 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -1675,7 +1675,11 @@ pub fn prepareMonotypeToSolved(prepared: PreparedMonotype) Allocator.Error!Prepa const lifted_input = lifted; lifted_owned = false; lifted = undefined; - var solved = try postcheck.LambdaSolved.Solve.runWithCache(allocator, lifted_input, if (target.spec_cache) |cache| cache.interfaces else null); + const analysis_cache = if (base.dev_diagnostics.isSet(.lambda_analysis_cache)) + (if (target.spec_cache) |cache| cache.interfaces else null) + else + null; + var solved = try postcheck.LambdaSolved.Solve.runWithCache(allocator, lifted_input, analysis_cache); errdefer solved.deinit(); lambda_solve_timing_scope.end(); From 2a7ce8e682ca10038570f067b0f2f792b3b0bc4a Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 04:04:37 -0400 Subject: [PATCH 60/97] Prototype validated completed body replay with portable static-value paths Keep replay behind the completed_body_cache developer diagnostic. Preserve compile-time reads and coordinator frontier requests, rebind worker results to current reservations, and address stored constant children through explicit paths from a validated producer root. Record checked-type ownership at static-data request production. Validation: 21 focused ReleaseFast tests and full ReleaseFast semantic check. Initial restricted replay passed executable capture checks and byte-for-byte uncached equivalence, but did not improve Observatory timings. Expanded static-value coverage is still under measurement; this is an experimental backup checkpoint, not a claim of production readiness. --- design.md | 27 + src/base/dev_diagnostics.zig | 6 + src/check/const_store.zig | 3 +- src/postcheck/monotype/interface_summary.zig | 41 +- src/postcheck/monotype/lower.zig | 1002 +++++++++++++++++- 5 files changed, 1059 insertions(+), 20 deletions(-) diff --git a/design.md b/design.md index 5a731d60b94..a516d5a5ebd 100644 --- a/design.md +++ b/design.md @@ -27378,3 +27378,30 @@ output annotations. Serialization resolves redirects and assigns compact local ids while preserving cycles and sharing. Discarded unification rows, unreachable lazy Monotype leaves, and solver-private clone-context ids are not output and must never be persisted. The current executable IR remains unchanged. + + +Experimental completed-body replay consumes the exact queued request contract +and a completed immutable worker shard. Its record retains explicit frontier +requests, compile-time reads, type/name ownership, and source/module ownership. +The existing coordinator still accepts the result and resolves the frontier. +A hit does not bypass demand discovery or the observations of a consumed root. +Input identity covers source, incoming type/evidence/substitution, ordered +method context, lowering policy, and compile-time dependencies. Each recorded +root read must retain its current value identity and representation state. +All persisted references must be validated and rebound to current owners +before a shard reaches compiler invariants. Unsupported optional record +coverage is a miss; no compiler stage guesses absent producer information. + +Completed-body static-data references use explicit constructor/capture paths +from the checked constant template's stored root. The record includes that +root's producer value identity and the checked type's actual owning module. +The reader validates those owners and follows selectors through the current +stored value; it never dereferences a persisted ConstNodeId. Sharing and +cycles in the producer traversal are visited once. The path remains meaningful +when value nodes have been allocated in a different order. + +This completed-body cache remains an opt-in developer experiment under +`completed_body_cache`. Initial restricted coverage reused ordinary bodies but +missed costly bodies containing static-data requests. Static-value paths extend +that coverage; overall performance and complete production coverage still need +validation. Ordinary builds do not load or publish these body records. diff --git a/src/base/dev_diagnostics.zig b/src/base/dev_diagnostics.zig index 7919ce3868f..0c4253e10ab 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -18,6 +18,10 @@ pub const available = builtin.link_libc and build_options.dev_diagnostics; pub const Var = enum { /// Trace Boxy-only work; specialized compilation must produce no events. trace_boxy_work, + /// Measure serialization of completed specialization bodies. + sealed_body_profile, + /// Exercise validated completed specialization-body replay. + completed_body_cache, /// Print the final LIR of every procedure whose debug name contains the value. lir_dump, /// Print every closed specialization key. @@ -52,6 +56,8 @@ pub const Var = enum { /// The variable's name in the environment. pub fn envName(comptime self: Var) [:0]const u8 { return switch (self) { + .sealed_body_profile => "ROC_SEALED_BODY_PROFILE", + .completed_body_cache => "ROC_COMPLETED_BODY_CACHE", .trace_boxy_work => "ROC_TRACE_BOXY_WORK", .lir_dump => "ROC_LIR_DUMP", .spec_census => "ROC_SPEC_CENSUS", diff --git a/src/check/const_store.zig b/src/check/const_store.zig index 229cb4593c9..4e8393b117d 100644 --- a/src/check/const_store.zig +++ b/src/check/const_store.zig @@ -1466,7 +1466,8 @@ pub const ConstStore = struct { /// The end of a vector of `count` evidence trees starting at `start`, or /// null when a nested vector's declared subtree length disagrees with its /// entries. Open nested vectors are kept on an explicit stack. - fn evidenceVectorEnd(allocator: Allocator, evidence: ConstFnEvidenceVector, start: usize, count: u32) Allocator.Error!?usize { + /// Callers validate node target/structural indices before this topology walk. + pub fn evidenceVectorEnd(allocator: Allocator, evidence: ConstFnEvidenceVector, start: usize, count: u32) Allocator.Error!?usize { const nodes = evidence.nodes; const Open = struct { remaining: u32, diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index 4b037166f54..a1dd92a5442 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -480,6 +480,7 @@ fn Writer(comptime Context: type) type { names: *const names.NameStore, leaves: []const Type.TypeId, bytes: std.ArrayList(u8) = .empty, + checked_names: bool = false, fn indexLeaves(self: *Self) EncodeError!void { try self.leaf_next.resize(self.allocator, self.leaves.len); @@ -576,6 +577,14 @@ fn Writer(comptime Context: type) type { } fn write(self: *Self, comptime T: type, value: T) EncodeError!void { + const previous = self.checked_names; + if (T == check.ConstStore.ConstFnTargetEvidence or T == check.ConstStore.ConstFnStructuralEvidence) self.checked_names = true; + defer self.checked_names = previous; + return self.writeValue(T, value); + } + fn writeValue(self: *Self, comptime T: type, value: T) EncodeError!void { + if (T == names.ExportNameId) if (!self.checked_names) return self.write([]const u8, self.names.exportNameText(value)); + if (T == names.ExternalSymbolNameId) if (!self.checked_names) return self.write([]const u8, self.context.builder.program.names.externalSymbolNameText(value)); if (T == Type.NamedType) return self.write(NamedOccurrence, self.context.describeNamedType(value)); if (T == Type.TypeId) { self.stats.references += 1; @@ -585,10 +594,10 @@ fn Writer(comptime Context: type) type { if (value == .redirect or value == .leaf or (value == .unresolved and value.unresolved.origin == .placeholder)) invariant("settled interface contains unfinished solver state", .{}); } if (T == Type.MaybeTypeId) return self.write(?Type.TypeId, value.get()); - if (T == names.ModuleIdentityId) return self.write([32]u8, self.names.moduleIdentityBytes(value).*); - if (T == names.TypeNameId) return self.write([]const u8, self.names.typeNameText(value)); - if (T == names.RecordFieldNameId) return self.write([]const u8, self.names.recordFieldLabelText(value)); - if (T == names.TagNameId) return self.write([]const u8, self.names.tagLabelText(value)); + if (T == names.ModuleIdentityId) if (!self.checked_names) return self.write([32]u8, self.names.moduleIdentityBytes(value).*); + if (T == names.TypeNameId) if (!self.checked_names) return self.write([]const u8, self.names.typeNameText(value)); + if (T == names.RecordFieldNameId) if (!self.checked_names) return self.write([]const u8, self.names.recordFieldLabelText(value)); + if (T == names.TagNameId) if (!self.checked_names) return self.write([]const u8, self.names.tagLabelText(value)); if (T == Type.FieldDefault) { self.stats.defaults += 1; const identity = self.names.moduleIdentityBytes(value.module).*; @@ -650,8 +659,18 @@ fn Reader(comptime Context: type) type { stats: Stats = .{}, counts: [3]u32 = .{ 0, 0, 0 }, type_count: u32 = 0, + checked_names: bool = false, fn read(self: *Self, comptime T: type) DecodeError!T { + const previous = self.checked_names; + if (T == check.ConstStore.ConstFnTargetEvidence or T == check.ConstStore.ConstFnStructuralEvidence) self.checked_names = true; + defer self.checked_names = previous; + return self.readValue(T); + } + fn readValue(self: *Self, comptime T: type) DecodeError!T { + if (T == names.ExportNameId) if (!self.checked_names) return self.names.internExportName(try self.read([]const u8)); + if (T == names.ExternalSymbolNameId) if (!self.checked_names) return self.names.internExternalSymbolName(try self.read([]const u8)); + if (T == Type.NamedType) { const named = (try self.context.resolveNamedType(try self.read(NamedOccurrence))) orelse return invalidSummary(@src().line); if (!self.context.validNamedType(named)) return invalidSummary(@src().line); @@ -664,14 +683,14 @@ fn Reader(comptime Context: type) type { return @fromBackingInt(index); } if (T == Type.MaybeTypeId) return .of(try self.read(?Type.TypeId)); - if (T == names.ModuleIdentityId) { + if (T == names.ModuleIdentityId and !self.checked_names) { const digest = try self.read([32]u8); if (!self.context.validModuleIdentity(digest)) return invalidSummary(@src().line); return self.names.internModuleIdentity(&digest); } - if (T == names.TypeNameId) return self.names.internTypeName(try self.read([]const u8)); - if (T == names.RecordFieldNameId) return self.names.internRecordFieldLabel(try self.read([]const u8)); - if (T == names.TagNameId) return self.names.internTagLabel(try self.read([]const u8)); + if (T == names.TypeNameId) if (!self.checked_names) return self.names.internTypeName(try self.read([]const u8)); + if (T == names.RecordFieldNameId) if (!self.checked_names) return self.names.internRecordFieldLabel(try self.read([]const u8)); + if (T == names.TagNameId) if (!self.checked_names) return self.names.internTagLabel(try self.read([]const u8)); if (T == Type.FieldDefault) { self.stats.defaults += 1; const identity = try self.read([32]u8); @@ -814,10 +833,9 @@ const FrozenTestPayload = struct { edges: []const u32, name: names.RecordFieldNameId, data: []const u8, - integer: i128 align(8), + integer: i128, float: f64, optional: Type.MaybeTypeId, - bits: packed struct(u64) { ty: Type.TypeId, enabled: bool, padding: u31 = 0 }, }; fn frozenTestBytes(allocator: Allocator) ![]u8 { @@ -840,7 +858,6 @@ fn frozenTestBytes(allocator: Allocator) ![]u8 { .integer = std.math.minInt(i128) + 17, .float = @bitCast(@as(u64, 0x7ff8000000000053)), .optional = .of(function), - .bits = .{ .ty = slot.ty, .enabled = true }, }, TestProvenance{}, null); } @@ -861,8 +878,6 @@ test "frozen specialization payload owns data and relocates cyclic shared type r const field = GuardedList.at(dest.fieldSpan(dest.get(result.types[0]).record), 0); try std.testing.expectEqual(result.types[0], dest.get(field.ty).func.ret); try std.testing.expectEqual(field.ty, result.optional.get().?); - try std.testing.expectEqual(result.types[0], result.bits.ty); - try std.testing.expect(result.bits.enabled); try std.testing.expectEqual(Type.Primitive.u64, dest.get(result.types[2]).primitive); try std.testing.expectEqualStrings("step", ns.recordFieldLabelText(result.name)); try std.testing.expectEqualStrings("owned bytes\x00\xff", result.data); diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 4914629793b..7f3aad3bd11 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -4453,6 +4453,8 @@ const SharedSummaries = WorkerInputs.AppendIndex(InterfaceReplayAddress, Interfa /// sealed, and the reservation already owns its global ids, so the job /// outlives the requesting graph and draft. const PendingSpecJob = struct { + completed_body_address: ?CompletedBodyAddress = null, + profile_key: [32]u8 = @splat(0), /// Logical enqueue order across the whole build. The inline executor /// accepts queue entries strictly in this order. Representation-sensitive /// immediate claims are completed as inline work of the executing body; @@ -4872,6 +4874,8 @@ const SpecJobLaneState = struct { /// One immutable graph-free result handed from ordinary body lowering to /// ordered coordinator commit. const CompletedSpecJobShard = struct { + decoded_body_arena: ?*std.heap.ArenaAllocator = null, + frozen_probe_record: ?FrozenProbeRecord = null, worker_id: SpecJobWorkerId, commit_domain: *SpecJobCommitDomain, dispatch_index: u64, @@ -4887,7 +4891,12 @@ const CompletedSpecJobShard = struct { fn deinit(self: *CompletedSpecJobShard) void { const allocator = self.body_draft.allocator; + if (self.frozen_probe_record) |record| allocator.free(record.bytes); self.body_draft.deinit(); + if (self.decoded_body_arena) |arena| { + arena.deinit(); + allocator.destroy(arena); + } allocator.free(self.interface_summaries); if (self.diagnostics) |diagnostics| { allocator.destroy(diagnostics); @@ -9008,6 +9017,8 @@ const Builder = struct { Common.invariant("queued Monotype specialization request cannot carry a retained lexical topology"); } const job: PendingSpecJob = .{ + .completed_body_address = if (dev_diagnostics.isSet(.completed_body_cache)) try self.completedBodyAddress(spec_identity, view, method_scope, source_fn_key, subst, signature_relation, demand) else null, + .profile_key = if (dev_diagnostics.isSet(.sealed_body_profile)) Ast.specIdentityKey(spec_identity).bytes else @splat(0), .dispatch_index = undefined, .spec = reserved.spec, .reservation = reservation, @@ -10294,6 +10305,331 @@ const Builder = struct { /// Deferred preparation executes against the worker's private Builder and /// workspace. Representation-sensitive iterator callees lower eagerly into /// this same draft, so callbacks never mutate coordinator state. + fn completedBodyAddress( + self: *Builder, + identity: Ast.SpecIdentity, + view: ModuleView, + scope: ModuleView, + source_fn_key: names.TypeDigest, + subst: ?SealedSubstitution, + relation: Ast.SignatureRelation, + demand: Demand, + ) Allocator.Error!?CompletedBodyAddress { + if (self.interface_cache == null) return null; + const scope_context = scope.compile_time_root_identities.valueContext() orelse return null; + const program_context = (try self.programValueContext(false)) orelse return null; + const callee_context = (try self.interfaceCalleeContext(view)) orelse return null; + const top = (try self.literalTopModules()).?; + var family = TypeDigestHasher.init(); + family.update("roc.completed-body-family.v2"); + family.update(&view.key.bytes); + family.update(&(view.code_generation_key orelse view.key).bytes); + family.update(&scope_context); + family.update(&program_context); + family.update(&callee_context); + hashU32(&family, @intCast(scope.method_lookup_scope.len)); + for (scope.method_lookup_scope) |module| { + if (top.get(module.bytes)) |context| { + family.update(&.{1}); + family.update(&context); + } else { + family.update(&.{0}); + family.update(&module.bytes); + } + } + family.update(@tagName(self.inline_expects)); + family.update(@tagName(demand)); + family.update(&.{ @intFromBool(self.static_data_literals), @intFromBool(self.comptime_value_reads), @intFromBool(self.literal_roots), @intFromBool(self.proc_debug_names) }); + var request = TypeDigestHasher.init(); + request.update("roc.completed-body-request.v1"); + request.update(&Ast.specIdentityKey(identity).bytes); + request.update(&source_fn_key.bytes); + request.update(@tagName(relation)); + if (subst) |slots| { + request.update(&.{1}); + hashU32(&request, @intCast(slots.len)); + for (slots) |slot| switch (slot) { + .checked_error => request.update(&.{0}), + .ty => |ty| { + request.update(&.{1}); + request.update(&self.specializationTypeDigest(ty).bytes); + }, + }; + } else request.update(&.{0}); + return .{ .family = family.finalResult(), .key = request.finalResult() }; + } + + fn completedBodySourceKeys(self: *Builder, allocator: Allocator) Allocator.Error![]const [32]u8 { + var max: usize = 0; + var it = self.sourceFileIds().iterator(); + while (it.next()) |entry| max = @max(max, @as(usize, entry.value_ptr.*) + 1); + const keys = try allocator.alloc([32]u8, max); + @memset(keys, @splat(0)); + it = self.sourceFileIds().iterator(); + while (it.next()) |entry| keys[entry.value_ptr.*] = entry.key_ptr.*; + return keys; + } + + fn completedBodyModuleKeys(self: *Builder, allocator: Allocator) Allocator.Error![]const [32]u8 { + var max: usize = 0; + var it = self.loweringModuleIds().iterator(); + while (it.next()) |entry| max = @max(max, @as(usize, @backingInt(entry.value_ptr.*)) + 1); + const keys = try allocator.alloc([32]u8, max); + @memset(keys, @splat(0)); + it = self.loweringModuleIds().iterator(); + while (it.next()) |entry| keys[@backingInt(entry.value_ptr.*)] = entry.key_ptr.*; + return keys; + } + + fn completedBodyStaticDependencies(self: *Builder, allocator: Allocator, requests: []const DraftStaticDataRequest) Allocator.Error!?[]const CompletedBodyStaticDependency { + const dependencies = try allocator.alloc(CompletedBodyStaticDependency, requests.len); + const Owner = struct { module: [32]u8, root: checked.ConstNodeId }; + var paths: std.AutoHashMapUnmanaged(Owner, CompletedConstPaths) = .empty; + for (requests, dependencies) |request, *dependency| { + const view = self.moduleForId(request.module); + const template = view.const_templates.get(request.const_locator); + if (template.state != .stored_const) return null; + const stored = template.state.stored_const; + if (@backingInt(stored.root) >= view.compile_time_root_identities.rows.len) return null; + const entry = try paths.getOrPut(allocator, .{ .module = view.key.bytes, .root = stored.node }); + if (!entry.found_existing) entry.value_ptr.* = try CompletedConstPaths.init(allocator, view.const_store, stored.node); + dependency.* = .{ + .root = stored.root, + .identity = view.compile_time_root_identities.rows[@backingInt(stored.root)], + .checked_type_key = self.moduleForId(request.checked_type_module).types.rootKey(request.checked_type), + .path = (try entry.value_ptr.path(request.const_node)) orelse return null, + }; + } + return dependencies; + } + + fn completedBodyRootDependencies(self: *Builder, allocator: Allocator, reads: []const DraftRootValueRead) Allocator.Error!?[]const CompletedBodyRootDependency { + const dependencies = try allocator.alloc(CompletedBodyRootDependency, reads.len); + for (reads, dependencies) |read, *dependency| { + const view = self.moduleForId(read.root.module); + const raw = @backingInt(read.root.root); + if (raw >= view.compile_time_root_identities.rows.len) return null; + dependency.* = .{ + .root = read.root, + .identity = view.compile_time_root_identities.rows[raw], + .payload = std.meta.activeTag(view.compile_time_roots.root(read.root.root).payload), + .declared_read = self.comptimeValueReadDeclared(view, read.root.root), + }; + } + return dependencies; + } + + fn loadCompletedBody( + self: *Builder, + workspace: *SpecJobWorkspace, + job: PendingSpecJob, + ) Allocator.Error!?RestoredCompletedBody { + const address = job.completed_body_address orelse return null; + const provider = self.interface_cache.?; + const io = std.Io.Threaded.global_single_threaded.io(); + const start = timingNowNs(io); + const bytes = (try provider.load(provider.context, self.allocator, address.family, address.key)) orelse return null; + defer self.allocator.free(bytes); + const arena = try self.allocator.create(std.heap.ArenaAllocator); + arena.* = .init(self.allocator); + var owned = true; + defer if (owned) { + arena.deinit(); + self.allocator.destroy(arena); + }; + const validation = CompletedBodyValidation{ .builder = self, .job = job, .names = &workspace.name_store }; + var payload = PersistentInterface.decodeFrozen(FrozenBodyProbe, "BODY0002".*, arena.allocator(), &workspace.types, &workspace.name_store, bytes, BodyContext.PersistentProvenance{ .builder = self, .completed_body_validation = &validation }, null) catch |err| switch (err) { + error.InvalidSummary => return null, + error.OutOfMemory => return error.OutOfMemory, + }; + validation.rebind(&payload) catch |err| switch (err) { + error.InvalidSummary => return null, + error.OutOfMemory => return error.OutOfMemory, + }; + var draft = BodyDraftStore.init(self.allocator); + errdefer draft.deinit(); + inline for (completed_body_columns) |column| { + const values = @field(payload, column); + @field(draft, column) = .fromOwnedSlice(try self.allocator.dupe(@typeInfo(@TypeOf(values)).pointer.child, values)); + } + // Substitutions are individually owned by BodyDraftStore. All other + // immutable nested data stays in the shard's retained decoding arena. + for (payload.templates) |stored| { + var spec: SealedTemplateSpec = undefined; + inline for (@typeInfo(SealedTemplateSpec).@"struct".field_names) |field| { + if (comptime std.mem.eql(u8, field, "evidence")) { + spec.evidence = try FrozenEvidenceContext.restore(self, stored.evidence); + } else if (comptime std.mem.eql(u8, field, "subst")) { + spec.subst = try self.allocator.dupe(SealedSubstSlot, stored.subst); + } else if (comptime std.mem.eql(u8, field, "codec_contract")) { + spec.codec_contract = self.completedBodyCodec(stored.codec_contract); + } else @field(spec, field) = @field(stored, field); + } + errdefer self.allocator.free(spec.subst); + try draft.sealed_template_specs.append(self.allocator, spec); + } + for (payload.nested) |stored| { + var spec: SealedNestedSpec = undefined; + inline for (@typeInfo(SealedNestedSpec).@"struct".field_names) |field| { + if (comptime std.mem.eql(u8, field, "codec_contract")) { + spec.codec_contract = self.completedBodyCodec(stored.codec_contract); + } else @field(spec, field) = @field(stored, field); + } + try draft.sealed_nested_specs.append(self.allocator, spec); + } + draft.spec_job_workspace = workspace; + draft.mutable_graph_names = &workspace.name_store; + draft.demand = payload.demand; + draft.demand_owner = job.reservation.fn_id; + draft.upgrade_all = payload.upgrade_all; + draft.upgrade_owner = payload.upgrade_owner; + draft.sealWorkerLocalSymbols(payload.worker_local_symbol_count); + owned = false; + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("completed-body hit key={x} bytes={d} restore_ns={d}\n", .{ address.key, bytes.len, timingNowNs(io) - start }); + return .{ + .arena = arena, + .draft = draft, + .pending = .{ .reservation = job.reservation, .fn_template = job.fn_template, .signature_relation = job.signature_relation, .root_ty = payload.root_ty, .lowered = payload.lowered }, + }; + } + + fn completedBodyCodec(self: *Builder, stored: ?FrozenCodecProbe) ?SealedCodecContractContext { + const value = stored orelse return null; + return .{ .anchor = .{ .view = self.moduleForId(value.module), .derivation = value.derivation, .call_index = value.call_index, .kind = value.kind }, .constructor_ty = value.constructor_ty, .shape_ty = value.shape_ty }; + } + + fn profileFrozenBody(self: *Builder, workspace: *SpecJobWorkspace, draft: *const BodyDraftStore, pending: SealedPendingTemplateBody, job: PendingSpecJob, lower_ns: i64) Allocator.Error!?FrozenProbeRecord { + var scratch = std.heap.ArenaAllocator.init(self.allocator); + defer scratch.deinit(); + const profile_io = std.Io.Threaded.global_single_threaded.io(); + const start = timingNowNs(profile_io); + const templates = try scratch.allocator().alloc(FrozenTemplateProbe, draft.sealed_template_specs.items.len); + for (draft.sealed_template_specs.items, templates) |source, *out| { + inline for (@typeInfo(FrozenTemplateProbe).@"struct".field_names) |field| { + if (comptime std.mem.eql(u8, field, "evidence")) { + out.evidence = try self.constFnEvidence(rootEvidence(source.template_ref, source.evidence)); + } else if (comptime std.mem.eql(u8, field, "codec_contract")) { + out.codec_contract = FrozenCodecProbe.of(source.codec_contract); + } else @field(out, field) = @field(source, field); + } + } + const nested = try scratch.allocator().alloc(FrozenNestedProbe, draft.sealed_nested_specs.items.len); + for (draft.sealed_nested_specs.items, nested) |source, *out| { + inline for (@typeInfo(FrozenNestedProbe).@"struct".field_names) |field| { + if (comptime std.mem.eql(u8, field, "codec_contract")) { + out.codec_contract = FrozenCodecProbe.of(source.codec_contract); + } else @field(out, field) = @field(source, field); + } + } + const address = job.completed_body_address orelse CompletedBodyAddress{ .family = @splat(0), .key = @splat(0) }; + const dependencies = (try self.completedBodyRootDependencies(scratch.allocator(), draft.root_value_reads.items)) orelse return null; + const payload: FrozenBodyProbe = .{ + .address = address, + .source_file_keys = try self.completedBodySourceKeys(scratch.allocator()), + .lowering_module_keys = try self.completedBodyModuleKeys(scratch.allocator()), + .root_dependencies = dependencies, + .static_dependencies = (try self.completedBodyStaticDependencies(scratch.allocator(), draft.static_data_requests.items)) orelse return null, + .root_ty = pending.root_ty, + .lowered = pending.lowered, + .self_reservation = pending.reservation, + .worker_local_symbol_count = draft.worker_local_symbol_count, + .demand = draft.demand, + .upgrade_all = draft.upgrade_all, + .upgrade_owner = draft.upgrade_owner, + .templates = templates, + .nested = nested, + .fns = draft.fns.items, + .const_fn_evidence = draft.const_fn_evidence.items, + .const_fn_evidence_targets = draft.const_fn_evidence_targets.items, + .const_fn_evidence_structurals = draft.const_fn_evidence_structurals.items, + .const_fn_evidence_frames = draft.const_fn_evidence_frames.items, + .defs = draft.defs.items, + .def_owners = draft.def_owners.items, + .nested_defs = draft.nested_defs.items, + .exprs = draft.exprs.items, + .pats = draft.pats.items, + .stmts = draft.stmts.items, + .locals = draft.locals.items, + .expr_ids = draft.expr_ids.items, + .pat_ids = draft.pat_ids.items, + .typed_locals = draft.typed_locals.items, + .stmt_ids = draft.stmt_ids.items, + .field_exprs = draft.field_exprs.items, + .field_access_segments = draft.field_access_segments.items, + .fn_def_captures = draft.fn_def_captures.items, + .record_destructs = draft.record_destructs.items, + .str_pattern_steps = draft.str_pattern_steps.items, + .declared_fields = draft.declared_fields.items, + .branches = draft.branches.items, + .if_branches = draft.if_branches.items, + .string_literals = draft.string_literals.items, + .string_bytes = draft.string_bytes.items, + .proc_debug_names = draft.proc_debug_names.items, + .roots = draft.roots.items, + .layout_requests = draft.layout_requests.items, + .runtime_schema_requests = draft.runtime_schema_requests.items, + .comptime_sites = draft.comptime_sites.items, + .local_names = draft.local_names.items, + .source_text_bytes = draft.source_text_bytes.items, + .expr_locs = draft.expr_locs.items, + .expr_regions = draft.expr_regions.items, + .stmt_locs = draft.stmt_locs.items, + .stmt_regions = draft.stmt_regions.items, + .owner_runs = draft.owner_runs.items, + .static_data_requests = draft.static_data_requests.items, + .comptime_value_roots = draft.comptime_value_roots.items, + .root_value_reads = draft.root_value_reads.items, + .literal_roots = draft.literal_roots.items, + }; + var stats: PersistentInterface.Stats = .{}; + const replay = dev_diagnostics.isSet(.completed_body_cache); + if (replay and !CompletedBodyValidation.coverage(payload)) { + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("completed-body unsupported static={d} comptime={d} literals={d} layouts={d} roots={d} templates={d} nested={d} lower_ns={d}\n", .{ payload.static_data_requests.len, payload.comptime_value_roots.len, payload.literal_roots.len, payload.layout_requests.len, payload.roots.len, payload.templates.len, payload.nested.len, lower_ns }); + return null; + } + if (replay and job.completed_body_address == null) return null; + const bytes = PersistentInterface.encodeFrozen(FrozenBodyProbe, if (replay) "BODY0002".* else "BODYPR02".*, self.allocator, &workspace.types, &workspace.name_store, payload, BodyContext.PersistentProvenance{ .builder = self }, &stats) catch |err| switch (err) { + error.NotRepresentable => return null, + error.OutOfMemory => return error.OutOfMemory, + }; + errdefer self.allocator.free(bytes); + if (replay) { + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("completed-body write key={x} bytes={d} encode_ns={d} lower_ns={d}\n", .{ address.key, bytes.len, timingNowNs(profile_io) - start, lower_ns }); + return .{ .family = address.family, .key = address.key, .bytes = bytes }; + } + const elapsed = timingNowNs(profile_io) - start; + var hash = base.Sha256.init(.{}); + hash.update("diagnostic-completed-body-record.v2"); + hash.update(@tagName(self.inline_expects)); + hash.update(@tagName(job.demand)); + hash.update(&.{ @intFromBool(self.static_data_literals), @intFromBool(self.comptime_value_reads), @intFromBool(self.literal_roots) }); + const family = hash.finalResult(); + const prior = if (self.interface_cache) |provider| try provider.load(provider.context, self.allocator, family, job.profile_key) else null; + defer if (prior) |old| self.allocator.free(old); + const decoded_fresh = try self.probeFrozenRead(bytes); + const decoded_prior = if (prior) |old| try self.probeFrozenRead(old) else null; + var refs: FrozenReferenceProbe = .{ .self_fn = job.reservation.fn_id, .self_def = job.reservation.def, .builder = self }; + refs.scan(FrozenBodyProbe, payload); + std.debug.print("body-record key={x} prior={} fresh_decoded={} prior_decoded={} external_fn={d} external_def={d} lifted_fn={d} missing_checked_module={d}\n", .{ job.profile_key, prior != null, decoded_fresh, decoded_prior orelse false, refs.external_fn, refs.external_def, refs.lifted_fn, refs.missing_checked_module }); + std.debug.print("body-key key={x} source={x} demand={s} native={} lower_ns={d} comptime_roots={d} root_reads={d} literals={d}\n", .{ job.profile_key, job.source_fn_key.bytes, @tagName(job.demand), job.fn_template.spec_key != null, lower_ns, draft.comptime_value_roots.items.len, draft.root_value_reads.items.len, draft.literal_roots.items.len }); + std.debug.print("frozen-body bytes={d} types={d} refs={d} exprs={d} templates={d} nested={d} encode_ns={d}\n", .{ bytes.len, stats.types, stats.references, draft.exprs.items.len, templates.len, nested.len, elapsed }); + return .{ .family = family, .key = job.profile_key, .bytes = bytes }; + } + + // This probe validates the portable type closure only. Its result is never + // supplied to compilation: IR references have not yet been rebound. + fn probeFrozenRead(self: *Builder, bytes: []const u8) Allocator.Error!bool { + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + var ns = names.NameStore.init(arena.allocator()); + var types_ = Type.Store.init(arena.allocator()); + _ = PersistentInterface.decodeFrozen(FrozenBodyProbe, "BODYPR02".*, arena.allocator(), &types_, &ns, bytes, BodyContext.PersistentProvenance{ .builder = self }, null) catch |err| switch (err) { + error.InvalidSummary => return false, + error.OutOfMemory => return error.OutOfMemory, + }; + return true; + } + fn lowerPendingSpecJobToShard( self: *Builder, worker: *SpecJobWorkerState, @@ -10303,6 +10639,7 @@ const Builder = struct { method_scope: ModuleView, template: checked.CheckedProcedureTemplate, ) Allocator.Error!CompletedSpecJobShard { + const profile_start = if (dev_diagnostics.isSet(.sealed_body_profile) or dev_diagnostics.isSet(.completed_body_cache)) timingNowNs(std.Io.Threaded.global_single_threaded.io()) else 0; switch (template.target) { .roc, .intrinsic, @@ -10328,6 +10665,27 @@ const Builder = struct { } const epoch = workspace.beginEpoch(self.program); errdefer workspace.finishEpoch(epoch); + if (try self.loadCompletedBody(workspace, job)) |restored_| { + var restored = restored_; + errdefer { + restored.draft.deinit(); + restored.arena.deinit(); + self.allocator.destroy(restored.arena); + } + const store_epoch = try workspace.captureStoreEpoch(epoch); + workspace.finishEpoch(epoch); + return .{ + .decoded_body_arena = restored.arena, + .worker_id = worker.worker_id, + .commit_domain = commit_domain, + .dispatch_index = job.dispatch_index, + .worker_local_symbol_count = restored.draft.worker_local_symbol_count, + .store_epoch = store_epoch, + .body_draft = restored.draft, + .pending = restored.pending, + .diagnostics = null, + }; + } const program_types_before = self.program.types.epochBoundary(); const program_names_before = self.program.names.epochBoundary(); const program_loc_before = self.program.current_loc; @@ -10378,6 +10736,8 @@ const Builder = struct { try self.finalizeBodyDraftGraph(graph, &body_draft, final_guard, final_end); const sealed_pending = try self.sealOrdinarySpecJobDraft(graph, &body_draft, pending); body_draft.sealWorkerLocalSymbols(self.symbols.worker_local.next); + const frozen_probe_record = if (dev_diagnostics.isSet(.sealed_body_profile) or dev_diagnostics.isSet(.completed_body_cache)) try self.profileFrozenBody(workspace, &body_draft, sealed_pending, job, timingNowNs(std.Io.Threaded.global_single_threaded.io()) - profile_start) else null; + errdefer if (frozen_probe_record) |record| self.allocator.free(record.bytes); if (self.spec_job_parallel_callback) { const program_types_after = self.program.types.epochBoundary(); if (!std.meta.eql(program_types_before, program_types_after)) { @@ -10398,6 +10758,7 @@ const Builder = struct { self.countBodyDiagnostic("spec_job_shards_lowered"); workspace.finishEpoch(epoch); return .{ + .frozen_probe_record = frozen_probe_record, .worker_id = worker.worker_id, .commit_domain = commit_domain, .dispatch_index = job.dispatch_index, @@ -10469,6 +10830,9 @@ const Builder = struct { Common.compilerBug("Monotype specialization shard lost its coordinator counter sink"); addFlatCounters(SpecializationCounters, destination, delta); } + if (shard.frozen_probe_record) |record| if (self.interface_cache) |provider| { + try provider.record(provider.context, record.family, record.key, record.bytes); + }; shard.diagnostics_committed = true; self.countCoordinatorBodyDiagnostic("spec_job_shards_committed"); self.acceptSpecDispatch(shard.dispatch_index); @@ -16874,8 +17238,9 @@ const DraftLiteralRoot = struct { read: DraftComptimeValueRootId, }; -fn DraftSpan(comptime _: type) type { +fn DraftSpan(comptime ElementType: type) type { return extern struct { + pub const Element = ElementType; start: u32, len: u32, @@ -19169,6 +19534,7 @@ const StaticDataPartRoot = struct { node: checked.ConstNodeId, const_locator: checked.ConstLocator, checked_type: checked.CheckedTypeId, + checked_type_module: checked.ModuleId, }; /// Draft-local static-data identity. Graph types remain qualified by the body @@ -19177,6 +19543,7 @@ const DraftStaticDataRequestAddress = struct { module: checked.ModuleId, node: checked.ConstNodeId, checked_type: checked.CheckedTypeId, + checked_type_module: checked.ModuleId, ty: DraftTypeCell, }; @@ -19186,6 +19553,7 @@ const DraftStaticDataRequest = struct { module: checked.ModuleId, const_node: checked.ConstNodeId, checked_type: checked.CheckedTypeId, + checked_type_module: checked.ModuleId, ty: DraftTypeCell, }; @@ -22876,10 +23244,12 @@ const BodyContext = struct { const_locator: checked.ConstLocator, node: ?checked.ConstNodeId, checked_type: checked.CheckedTypeId, + checked_type_module: checked.ModuleId, ty: DraftTypeCell, ) Allocator.Error!DraftStaticDataId { const const_node = self.builder.constNode(const_locator, node); const address: DraftStaticDataRequestAddress = .{ + .checked_type_module = checked_type_module, .module = const_node.module.key, .node = const_node.id, .checked_type = checked_type, @@ -22894,6 +23264,7 @@ const BodyContext = struct { const id: DraftStaticDataId = @fromBackingInt(@intCast(@as(u32, @intCast(self.draft.static_data_requests.items.len)))); self.draft.static_data_requests.appendAssumeCapacity(.{ + .checked_type_module = checked_type_module, .const_locator = const_locator, .node = node, .module = const_node.module.key, @@ -28054,6 +28425,13 @@ const BodyContext = struct { const PersistentProvenance = struct { builder: *Builder, + completed_body_validation: ?*const CompletedBodyValidation = null, + pub fn validateFrozenPayload(self: PersistentProvenance, payload: FrozenBodyProbe) PersistentInterface.DecodeError!void { + // The optional context is present for actual replay; the separate + // diagnostic round-trip deliberately discards the decoded payload. + if (self.completed_body_validation) |validation| try validation.validate(payload); + } + pub fn defaultArtifact(self: PersistentProvenance, identity: [32]u8, expr: u32) ?[32]u8 { const declaring = self.builder.moduleForIdentityHash(&identity) orelse return null; _ = declaring.bodies.defaultExpr(expr) orelse return null; @@ -47262,6 +47640,7 @@ const BodyContext = struct { const_locator, node, checked_type, + type_view.key, DraftTypeCell.fromSealed(ty), ); const address: StaticDataCandidateAddress = .{ @@ -47274,7 +47653,7 @@ const BodyContext = struct { .{ .stored = try self.storedStaticList(store_view, type_view, shape, .{ .sealed = listElemType(self.typeStore(), ty) }, const_locator) } else runtime: { const previous_part_root = self.static_data_part_root; - self.static_data_part_root = staticDataPartRootFor(store_view, node, const_locator, checked_type); + self.static_data_part_root = staticDataPartRootFor(store_view, node, const_locator, checked_type, type_view.key); defer self.static_data_part_root = previous_part_root; break :runtime .{ .runtime = try runConstRestore(self, store_view, type_view, node, ty, const_locator) }; }; @@ -47311,7 +47690,7 @@ const BodyContext = struct { .{ .stored = try self.storedStaticList(store_view, type_view, shape, .{ .graph = try self.graph.listElementNode(request_node) }, const_locator) } else runtime: { const previous_part_root = self.static_data_part_root; - self.static_data_part_root = if (candidate) staticDataPartRootFor(store_view, node, const_locator, checked_type) else null; + self.static_data_part_root = if (candidate) staticDataPartRootFor(store_view, node, const_locator, checked_type, type_view.key) else null; defer self.static_data_part_root = previous_part_root; break :runtime .{ .runtime = try self.restoreConstNodeAtNodeWithStaticRoot( store_view, @@ -47326,6 +47705,7 @@ const BodyContext = struct { const_locator, node, checked_type, + type_view.key, DraftTypeCell.fromGraphNode(request_node), ); return try self.addExprWithTypeCell(DraftTypeCell.fromGraphNode(request_node), .{ .static_data_candidate = .{ @@ -47345,6 +47725,7 @@ const BodyContext = struct { node: checked.ConstNodeId, const_locator: checked.ConstLocator, checked_type: checked.CheckedTypeId, + checked_type_module: checked.ModuleId, ) ?StaticDataPartRoot { return switch (store_view.const_store.get(node)) { .tuple, .record, .tag, .nominal => .{ @@ -47352,6 +47733,7 @@ const BodyContext = struct { .node = node, .const_locator = const_locator, .checked_type = checked_type, + .checked_type_module = checked_type_module, }, .pending, .zst, .scalar, .str, .list, .box, .crash, .checked_error, .fn_value => null, }; @@ -47390,7 +47772,7 @@ const BodyContext = struct { ty: Type.TypeId, ) Allocator.Error!DraftExprId { const root = self.static_data_part_root.?; - const id = try self.draftStaticDataRequest(root.const_locator, node, root.checked_type, DraftTypeCell.fromSealed(ty)); + const id = try self.draftStaticDataRequest(root.const_locator, node, root.checked_type, root.checked_type_module, DraftTypeCell.fromSealed(ty)); const address: StaticDataCandidateAddress = .{ .static_data = id, .owner = self.draft.current_owner, @@ -47429,7 +47811,7 @@ const BodyContext = struct { break :value .{ .runtime = try self.restoreConstNodeAtNodeWithStaticRoot(store_view, store_view, node, request_node, root.const_locator) }; }; const cell = DraftTypeCell.fromGraphNode(request_node); - const id = try self.draftStaticDataRequest(root.const_locator, node, root.checked_type, cell); + const id = try self.draftStaticDataRequest(root.const_locator, node, root.checked_type, root.checked_type_module, cell); return try self.addExprWithTypeCell(cell, .{ .static_data_candidate = .{ .static_data = id, .storage = constStaticDataStorage(store_view, node), @@ -52178,7 +52560,7 @@ const BodyContext = struct { /// evidence, then its callable contracts. Nested evidence follows type /// nesting, so each nested vector is a frame rather than a native call. fn materializeConstFnEvidenceVector( - self: *BodyContext, + self: anytype, evidence: check.ConstStore.ConstFnEvidenceVector, cursor: *usize, count: u32, @@ -72956,3 +73338,611 @@ test "fresh closed named occurrences agree with persistent replay and preserve o try std.testing.expectEqual(open_ty, open.ty); try std.testing.expectEqualSlices(u8, &views[0].key.bytes, &open.ty_module.bytes); } + +// Experimental complete-result encoding, enabled only by a diagnostic variable. +// The separate completed_body_cache diagnostic enables validated replay. +const FrozenBodyProbe = struct { + address: CompletedBodyAddress, + source_file_keys: []const [32]u8, + lowering_module_keys: []const [32]u8, + root_dependencies: []const CompletedBodyRootDependency, + static_dependencies: []const CompletedBodyStaticDependency, + root_ty: Type.TypeId, + lowered: SealedLoweredTemplateBody, + self_reservation: TemplateReservation, + worker_local_symbol_count: u32, + demand: Demand, + upgrade_all: bool, + upgrade_owner: bool, + templates: []const FrozenTemplateProbe, + nested: []const FrozenNestedProbe, + fns: []const DraftFn, + const_fn_evidence: []const check.ConstStore.ConstFnEvidence, + const_fn_evidence_targets: []const check.ConstStore.ConstFnTargetEvidence, + const_fn_evidence_structurals: []const check.ConstStore.ConstFnStructuralEvidence, + const_fn_evidence_frames: []const check.ConstStore.ConstFnEvidenceFrame, + defs: []const DraftDef, + def_owners: []const DraftOwner, + nested_defs: []const DraftNestedDef, + exprs: []const DraftExpr, + pats: []const DraftPat, + stmts: []const DraftStmt, + locals: []const DraftLocal, + expr_ids: []const DraftExprId, + pat_ids: []const DraftPatId, + typed_locals: []const DraftTypedLocal, + stmt_ids: []const DraftStmtId, + field_exprs: []const DraftFieldExpr, + field_access_segments: []const DraftFieldAccessSegment, + fn_def_captures: []const DraftFnDefCapture, + record_destructs: []const DraftRecordDestruct, + str_pattern_steps: []const DraftStrPatternStep, + declared_fields: []const DraftDeclaredField, + branches: []const DraftBranch, + if_branches: []const DraftIfBranch, + string_literals: []const DraftStringLiteral, + string_bytes: []const u8, + proc_debug_names: []const Ast.ProcDebugName, + roots: []const DraftRoot, + layout_requests: []const DraftLayoutRequest, + runtime_schema_requests: []const DraftRuntimeSchemaRequest, + comptime_sites: []const DraftComptimeSite, + local_names: []const DraftSpan(u8), + source_text_bytes: []const u8, + expr_locs: []const base.SourceLoc, + expr_regions: []const base.Region, + stmt_locs: []const base.SourceLoc, + stmt_regions: []const base.Region, + owner_runs: []const DraftOwnerRun, + static_data_requests: []const DraftStaticDataRequest, + comptime_value_roots: []const Common.ComptimeValueRoot, + root_value_reads: []const DraftRootValueRead, + literal_roots: []const DraftLiteralRoot, +}; +const FrozenCodecProbe = struct { + module: checked.ModuleId, + derivation: static_dispatch.GeneratedCodecDerivationId, + call_index: u32, + kind: CodecKind, + constructor_ty: Type.TypeId, + shape_ty: Type.TypeId, + fn of(value: ?SealedCodecContractContext) ?FrozenCodecProbe { + const v = value orelse return null; + return .{ .module = v.anchor.view.key, .derivation = v.anchor.derivation, .call_index = v.anchor.call_index, .kind = v.anchor.kind, .constructor_ty = v.constructor_ty, .shape_ty = v.shape_ty }; + } +}; +const FrozenTemplateProbe = struct { + state: DraftSpecState, + template_ref: names.ProcTemplate, + method_scope: checked.ModuleId, + source_fn_ty: checked.CheckedTypeId, + source_fn_key: names.TypeDigest, + request_fn_ty: Type.TypeId, + evidence: StoredConstFnEvidence, + /// The request's substitution sealed in the shard's store epoch. + subst: SealedSubstitution, + codec_contract: ?FrozenCodecProbe, + requires_local: bool, + local_context_dependent: bool, + lexical_owner: ?DraftOwner, + fn_id: DraftFnId, + def_id: ?DraftDefId, + resolved_slot: ?Ast.FnSlot, + committed_request_fn_ty: ?Type.TypeId = null, + committed_codec_contract: ?Ast.CodecContractIdentity = null, + demand: Demand = .evaluation, + /// Whether the resolved specialization is this request shaped by its + /// template's demand, so every call to it is demand-shaped. + demand_shaped: bool = false, + /// Whether the draft body uses this request's function as a value rather + /// than only calling it. A value's type is the program's, so such a + /// request is never shaped. + value_used: bool = false, +}; +const FrozenNestedProbe = struct { + state: DraftSpecState, + nested: Ast.NestedFn, + method_scope: checked.ModuleId, + source_fn_key: names.TypeDigest, + request_fn_ty: Type.TypeId, + capture_abi_digest: names.TypeDigest, + codec_contract: ?FrozenCodecProbe, + sealed_codec_contract: ?Ast.CodecContractIdentity = null, + lexical_owner: DraftOwner, + requires_local: bool, + local_context_dependent: bool, + fn_id: DraftFnId, +}; + +const FrozenProbeRecord = struct { family: [32]u8, key: [32]u8, bytes: []u8 }; +const FrozenReferenceProbe = struct { + self_fn: Ast.FnId, + self_def: Ast.DefId, + builder: *Builder, + external_fn: u32 = 0, + external_def: u32 = 0, + lifted_fn: u32 = 0, + missing_checked_module: u32 = 0, + fn scan(self: *FrozenReferenceProbe, comptime T: type, value: T) void { + if (T == Ast.FnId) { + if (value != self.self_fn) self.external_fn += 1; + return; + } + if (T == Ast.DefId) { + if (value != self.self_def) self.external_def += 1; + return; + } + if (T == Ast.LiftedFnId) { + self.lifted_fn += 1; + return; + } + if (T == checked.ModuleId) { + if (self.builder.moduleForKeyBytes(value.bytes) == null) self.missing_checked_module += 1; + return; + } + switch (@typeInfo(T)) { + .@"struct" => |info| inline for (info.field_names, info.field_types) |field, F| self.scan(F, @field(value, field)), + .@"union" => |info| inline for (info.field_names, info.field_types) |field, F| { + if (std.meta.activeTag(value) == @field(info.tag_type.?, field)) { + self.scan(F, @field(value, field)); + return; + } + }, + .optional => |info| { + if (value) |v| self.scan(info.child, v); + }, + .pointer => |info| { + if (info.size == .slice) { + for (value) |v| self.scan(info.child, v); + } else @compileError("probe pointer is not a slice"); + }, + .array => |info| for (value) |v| self.scan(info.child, v), + else => {}, + } + } +}; + +const completed_body_columns = .{ "fns", "const_fn_evidence", "const_fn_evidence_targets", "const_fn_evidence_structurals", "const_fn_evidence_frames", "defs", "def_owners", "nested_defs", "exprs", "pats", "stmts", "locals", "expr_ids", "pat_ids", "typed_locals", "stmt_ids", "field_exprs", "field_access_segments", "fn_def_captures", "record_destructs", "str_pattern_steps", "declared_fields", "branches", "if_branches", "string_literals", "string_bytes", "proc_debug_names", "roots", "layout_requests", "runtime_schema_requests", "comptime_sites", "local_names", "source_text_bytes", "expr_locs", "expr_regions", "stmt_locs", "stmt_regions", "owner_runs", "static_data_requests", "comptime_value_roots", "root_value_reads", "literal_roots" }; +const CompletedBodyAddress = struct { family: [32]u8, key: [32]u8 }; +const CompletedBodyRootDependency = struct { + root: EntryRoot, + identity: checked.CompileTimeRootIdentity, + payload: std.meta.Tag(checked.CompileTimeRootPayload), + declared_read: bool, +}; +const RestoredCompletedBody = struct { + arena: *std.heap.ArenaAllocator, + draft: BodyDraftStore, + pending: SealedPendingTemplateBody, +}; + +const CompletedBodyValidation = struct { + builder: *Builder, + job: PendingSpecJob, + names: *const names.NameStore, + const Error = PersistentInterface.DecodeError; + + fn rejected(comptime line: u32) error{InvalidSummary} { + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("completed-body rejected line={d}\n", .{line}); + return error.InvalidSummary; + } + fn coverage(payload: FrozenBodyProbe) bool { + if (payload.comptime_value_roots.len != 0 or payload.literal_roots.len != 0 or payload.layout_requests.len != 0 or payload.roots.len != 0) return false; + for (payload.templates) |spec| if (spec.requires_local or spec.local_context_dependent or spec.state == .lowering) return false; + for (payload.nested) |spec| if (spec.requires_local or spec.local_context_dependent or spec.state == .lowering) return false; + return true; + } + fn staticNode(self: *const CompletedBodyValidation, request: DraftStaticDataRequest, dependency: CompletedBodyStaticDependency) Error!checked.ConstNodeId { + if (!std.mem.eql(u8, &request.module.bytes, &request.const_locator.artifact.bytes)) return rejected(@src().line); + const view = self.builder.moduleForKeyBytes(request.module.bytes) orelse return rejected(@src().line); + const raw = @backingInt(request.const_locator.template); + if (raw >= view.const_templates.templates.items.len) return rejected(@src().line); + const template = view.const_templates.templates.items[raw]; + if (!std.meta.eql(template.owner, request.const_locator.owner) or !std.meta.eql(template.source_scheme, request.const_locator.source_scheme) or template.state != .stored_const) return rejected(@src().line); + const stored = template.state.stored_const; + if (dependency.root != stored.root or @backingInt(stored.root) >= view.compile_time_root_identities.rows.len) return rejected(@src().line); + if (!std.meta.eql(dependency.identity, view.compile_time_root_identities.rows[@backingInt(stored.root)])) return rejected(@src().line); + const type_view = self.builder.moduleForKeyBytes(request.checked_type_module.bytes) orelse return rejected(@src().line); + if (@backingInt(request.checked_type) >= type_view.types.payloadCount() or !std.meta.eql(dependency.checked_type_key, type_view.types.rootKey(request.checked_type))) return rejected(@src().line); + if (request.node == null and dependency.path.len != 0) return rejected(@src().line); + return CompletedConstPaths.follow(view.const_store, stored.node, dependency.path) orelse rejected(@src().line); + } + + fn validate(self: *const CompletedBodyValidation, payload: FrozenBodyProbe) Error!void { + if (!std.meta.eql(payload.address, self.job.completed_body_address.?)) return rejected(@src().line); + if (payload.demand != self.job.demand) return rejected(@src().line); + // These columns need additional checked-value or declaration binding + // contracts. This format never admits them without those contracts. + if (payload.comptime_value_roots.len != 0 or payload.literal_roots.len != 0 or payload.layout_requests.len != 0 or payload.roots.len != 0) return rejected(@src().line); + if (payload.static_dependencies.len != payload.static_data_requests.len) return rejected(@src().line); + for (payload.static_data_requests, payload.static_dependencies) |request, dependency| _ = try self.staticNode(request, dependency); + if (payload.root_dependencies.len != payload.root_value_reads.len) return rejected(@src().line); + for (payload.root_dependencies, payload.root_value_reads) |dependency, read| { + if (!std.meta.eql(dependency.root, read.root)) return rejected(@src().line); + const view = self.builder.moduleForKeyBytes(read.root.module.bytes) orelse return rejected(@src().line); + const raw = @backingInt(read.root.root); + if (raw >= view.compile_time_roots.roots.len or raw >= view.compile_time_root_identities.rows.len) return rejected(@src().line); + if (!std.meta.eql(dependency.identity, view.compile_time_root_identities.rows[raw])) return rejected(@src().line); + if (dependency.payload != std.meta.activeTag(view.compile_time_roots.root(read.root.root).payload)) return rejected(@src().line); + if (dependency.declared_read != self.builder.comptimeValueReadDeclared(view, read.root.root)) return rejected(@src().line); + } + for (payload.local_names) |span| try bounds(span.start, span.len, payload.source_text_bytes.len); + for (payload.string_literals) |literal| { + const len = if (literal.const_blob) |blob| blk: { + const view = self.builder.moduleForKeyBytes(blob.module_bytes) orelse return rejected(@src().line); + if (@backingInt(blob.data) >= view.const_store.blob_views.items.len) return rejected(@src().line); + if (!std.mem.eql(u8, blob.bytes, view.const_store.blobData(blob.data))) return rejected(@src().line); + break :blk blob.bytes.len; + } else blk: { + try bounds(literal.backing.start, literal.backing.len, payload.string_bytes.len); + break :blk literal.backing.len; + }; + try bounds(literal.offset, literal.len, len); + } + for (payload.locals) |local| if (@backingInt(local.symbol) >= payload.worker_local_symbol_count) return rejected(@src().line); + for (payload.defs) |def| if (@backingInt(def.symbol) >= payload.worker_local_symbol_count) return rejected(@src().line); + for (payload.nested_defs) |def| if (@backingInt(def.symbol) >= payload.worker_local_symbol_count) return rejected(@src().line); + for (payload.proc_debug_names) |name| if (@backingInt(name.symbol) >= payload.worker_local_symbol_count) return rejected(@src().line); + for (payload.owner_runs) |owner_run| inline for (@typeInfo(DraftCoreKind).@"enum".field_names, 0..) |column, index| { + if (owner_run.starts[index] > owner_run.ends[index] or owner_run.ends[index] > @field(payload, column).len) return rejected(@src().line); + }; + for (payload.templates) |spec| { + if (spec.requires_local or spec.local_context_dependent or spec.state == .lowering) return rejected(@src().line); + try self.evidence(spec.evidence); + } + for (payload.nested) |spec| if (spec.requires_local or spec.local_context_dependent or spec.state == .lowering) return rejected(@src().line); + var walker = Walker{ .validation = self, .payload = &payload }; + var read_only = payload; + try walker.walk(FrozenBodyProbe, &read_only, false); + } + + fn bounds(start: u32, len: u32, limit: usize) Error!void { + if (start > limit or len > limit - start) return rejected(@src().line); + } + + fn procedure(self: *const CompletedBodyValidation, ref: names.ProcTemplate) Error!ModuleView { + const view = self.builder.moduleForKeyBytes(names.procTemplateModuleDigest(ref).bytes) orelse return rejected(@src().line); + if (@backingInt(ref.template) >= view.templates.templates.items.len) return rejected(@src().line); + const template = view.templates.get(ref.template); + if (template.proc_base != ref.proc_base) return rejected(@src().line); + return view; + } + + fn evidence(self: *const CompletedBodyValidation, stored: StoredConstFnEvidence) Error!void { + if (stored.frames.len != 1 or stored.head != 0 or stored.frames[0].scope() != .root or stored.frames[0].parent != null or stored.frames[0].roots_start != 0) return rejected(@src().line); + for (stored.entries.nodes) |node| switch (node) { + .target => |index| if (index >= stored.entries.targets.len) return rejected(@src().line), + .structural => |index| if (index >= stored.entries.structurals.len) return rejected(@src().line), + else => {}, + }; + const end = (try checked.ConstStore.evidenceVectorEnd(self.builder.allocator, stored.entries, 0, stored.frames[0].roots_len)) orelse return rejected(@src().line); + if (end != stored.entries.nodes.len) return rejected(@src().line); + if (!std.meta.eql(stored.digest, Ast.fnEvidenceDigest(stored.entries, stored.frames, stored.head))) return rejected(@src().line); + for (stored.entries.targets) |target| { + const view = self.builder.moduleForKeyBytes(target.view.bytes) orelse return rejected(@src().line); + if (target.method.kind == .local_proc or @backingInt(target.method.callable_ty) >= view.types.payloadCount()) return rejected(@src().line); + if (!std.meta.eql(target.method_callable_key, view.types.rootKey(target.method.callable_ty))) return rejected(@src().line); + if (target.instantiation) |instantiation| { + const owner = self.builder.moduleForKeyBytes(instantiation.view.bytes) orelse return rejected(@src().line); + if (@backingInt(instantiation.callable_ty) >= owner.types.payloadCount()) return rejected(@src().line); + if (!std.meta.eql(instantiation.callable_key, owner.types.rootKey(instantiation.callable_ty))) return rejected(@src().line); + } + } + for (stored.entries.structurals) |structural| if (structural.checked) |checked_| { + const view = self.builder.moduleForKeyBytes(checked_.view.bytes) orelse return rejected(@src().line); + if (@backingInt(checked_.dispatcher_ty) >= view.types.payloadCount() or @backingInt(checked_.callable_ty) >= view.types.payloadCount()) return rejected(@src().line); + if (!std.meta.eql(checked_.dispatcher_key, view.types.rootKey(checked_.dispatcher_ty)) or !std.meta.eql(checked_.callable_key, view.types.rootKey(checked_.callable_ty))) return rejected(@src().line); + if (checked_.generated_codec_derivation) |id| { + if (@backingInt(id) >= view.static_dispatch_plans.generated_codec_derivations.len) return rejected(@src().line); + if (!std.meta.eql(checked_.generated_codec_content, view.static_dispatch_plans.generated_codec_derivations[@backingInt(id)].content)) return rejected(@src().line); + } else if (checked_.generated_codec_content != null) return rejected(@src().line); + }; + } + + fn rebind(self: *const CompletedBodyValidation, payload: *FrozenBodyProbe) Error!void { + for (@constCast(payload.static_data_requests), payload.static_dependencies) |*request, dependency| { + const node = try self.staticNode(request.*, dependency); + if (request.node != null) request.node = node; + request.const_node = node; + } + var walker = Walker{ .validation = self, .payload = payload }; + try walker.walk(FrozenBodyProbe, payload, true); + } + + const Walker = struct { + validation: *const CompletedBodyValidation, + payload: *const FrozenBodyProbe, + fn walk(self: *Walker, comptime T: type, value: anytype, comptime rebind_: bool) Error!void { + const p = self.payload; + const v = self.validation; + if (T == TemplateReservation) { + if (rebind_) value.* = v.job.reservation; + return; + } + if (T == Ast.FnId) { + if (rebind_) value.* = v.job.reservation.fn_id else if (value.* != p.self_reservation.fn_id) return rejected(@src().line); + return; + } + if (T == Ast.DefId) { + if (rebind_) value.* = v.job.reservation.def else if (value.* != p.self_reservation.def) return rejected(@src().line); + return; + } + if (T == Ast.LiftedFnId) return rejected(@src().line); + if (T == base.SourceLoc) { + if (value.file == base.SourceLoc.no_file) return; + if (value.file >= p.source_file_keys.len) return rejected(@src().line); + const current = v.builder.sourceFileIds().get(p.source_file_keys[value.file]) orelse return rejected(@src().line); + if (rebind_) value.file = current; + return; + } + if (T == Common.LoweringModuleId) { + if (@backingInt(value.*) >= p.lowering_module_keys.len) return rejected(@src().line); + const current = v.builder.loweringModuleIds().get(p.lowering_module_keys[@backingInt(value.*)]) orelse return rejected(@src().line); + if (rebind_) value.* = current; + return; + } + if (T == checked.ModuleId) { + if (v.builder.moduleForKeyBytes(value.bytes) == null) return rejected(@src().line); + return; + } + if (T == names.ProcTemplate) { + _ = try v.procedure(value.*); + return; + } + if (T == Ast.HostedFn) { + _ = try v.procedure(value.template); + const entry = for (v.builder.hosted_catalog) |entry| { + if (names.procedureTemplateRefEql(entry.template, value.template)) break entry; + } else return rejected(@src().line); + if (entry.binding != .mapped) return rejected(@src().line); + if (!std.mem.eql(u8, v.names.externalSymbolNameText(value.external_symbol_name), v.builder.program.names.externalSymbolNameText(entry.external_symbol_name))) return rejected(@src().line); + if (rebind_) value.* = v.builder.hostedFn(value.template); + return; + } + if (T == Ast.NestedFn) { + const owner = try v.procedure(value.owner); + if (value.default_root != null) return rejected(@src().line); + if (@backingInt(value.site) >= owner.nested_proc_sites.sites.len) return rejected(@src().line); + return; + } + if (T == DraftTypeCell) { + if (value.* != .sealed) return rejected(@src().line); + return; + } + if (T == DraftProcCallee) if (value.* != .func) return rejected(@src().line); + if (T == FrozenCodecProbe) { + const owner = v.builder.moduleForKeyBytes(value.module.bytes) orelse return rejected(@src().line); + if (@backingInt(value.derivation) >= owner.static_dispatch_plans.generated_codec_derivations.len) return rejected(@src().line); + } + inline for (.{ .{ DraftExprId, "exprs" }, .{ DraftPatId, "pats" }, .{ DraftStmtId, "stmts" }, .{ DraftLocalId, "locals" }, .{ DraftFnId, "fns" }, .{ DraftDefId, "defs" }, .{ DraftNestedDefId, "nested_defs" }, .{ DraftStringLiteralId, "string_literals" }, .{ DraftComptimeSiteId, "comptime_sites" }, .{ DraftStaticDataId, "static_data_requests" }, .{ DraftComptimeValueRootId, "comptime_value_roots" } }) |column| { + if (T == column[0]) { + if (@backingInt(value.*) >= @field(p.*, column[1]).len) return rejected(@src().line); + return; + } + } + // u8 spans have two distinct owners and were checked by their + // owning fields above; no generic choice of backing is permitted. + if (T == DraftSpan(u8)) return; + inline for (.{ "expr_ids", "pat_ids", "stmt_ids", "typed_locals", "field_exprs", "field_access_segments", "fn_def_captures", "record_destructs", "str_pattern_steps", "declared_fields", "branches", "if_branches", "const_fn_evidence", "const_fn_evidence_targets", "const_fn_evidence_structurals", "const_fn_evidence_frames" }) |column| { + const Child = @typeInfo(@TypeOf(@field(p.*, column))).pointer.child; + if (T == DraftSpan(Child)) { + try CompletedBodyValidation.bounds(value.start, value.len, @field(p.*, column).len); + return; + } + } + switch (@typeInfo(T)) { + .@"struct" => |info| inline for (info.field_names, info.field_types) |field, F| try self.walk(F, &@field(value, field), rebind_), + .@"union" => |info| inline for (info.field_names, info.field_types) |field, F| { + if (std.meta.activeTag(value.*) == @field(info.tag_type.?, field)) return self.walk(F, &@field(value, field), rebind_); + }, + .optional => |info| if (value.*) |*present| try self.walk(info.child, present, rebind_), + .pointer => |info| { + if (info.size != .slice) @compileError("completed bodies contain bounded owned slices"); + for (@constCast(value.*)) |*item| try self.walk(info.child, item, rebind_); + }, + .array => |info| for (value) |*item| try self.walk(info.child, item, rebind_), + else => {}, + } + } + }; +}; + +const FrozenEvidenceContext = struct { + allocator: Allocator, + builder: *Builder, + fn restore(builder: *Builder, evidence: StoredConstFnEvidence) Allocator.Error![]const SpecEvidence { + var self = FrozenEvidenceContext{ .allocator = builder.allocator, .builder = builder }; + var cursor: usize = 0; + return BodyContext.materializeConstFnEvidenceVector(&self, evidence.entries, &cursor, evidence.frames[0].roots_len); + } + fn finishStoredEvidenceTarget(self: *FrozenEvidenceContext, target: *SpecEvidenceTarget, stored: check.ConstStore.ConstFnTargetEvidence, nested: NestedSpecEvidence) Allocator.Error!void { + if (stored.method.kind == .local_proc) Common.invariant("validated completed body retained a local procedure context"); + target.* = .{ + .view = self.builder.moduleForDigest(stored.view), + .target = stored.method, + .instantiation = if (stored.instantiation) |instance| .{ .view = self.builder.moduleForDigest(instance.view), .callable_ty = instance.callable_ty } else null, + .local_proc_context = null, + .nested = nested, + }; + } +}; + +test "completed body rejects foreign reservations and rebinds its own references" { + const a = std.testing.allocator; + var ns = names.NameStore.init(a); + defer ns.deinit(); + var builder: Builder = undefined; + var job: PendingSpecJob = undefined; + job.completed_body_address = .{ .family = @splat(2), .key = @splat(3) }; + job.demand = .evaluation; + job.reservation = .{ .def = @fromBackingInt(17), .fn_id = @fromBackingInt(18), .symbol = @fromBackingInt(19) }; + var payload = std.mem.zeroes(FrozenBodyProbe); + payload.address = job.completed_body_address.?; + payload.demand = .evaluation; + var exprs = [_]DraftExpr{.{ .ty = .{ .sealed = @fromBackingInt(0) }, .data = .{ .def_ref = .{ .final = @fromBackingInt(0) } } }}; + payload.exprs = &exprs; + const validation = CompletedBodyValidation{ .builder = &builder, .job = job, .names = &ns }; + try validation.validate(payload); + exprs[0].data.def_ref.final = @fromBackingInt(1); + try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); + exprs[0].data.def_ref.final = @fromBackingInt(0); + try validation.rebind(&payload); + try std.testing.expectEqual(job.reservation.def, exprs[0].data.def_ref.final); + try std.testing.expectEqualDeep(job.reservation, payload.self_reservation); +} + +test "completed body rejects incompatible demand missing columns and invalid draft references" { + const a = std.testing.allocator; + var ns = names.NameStore.init(a); + defer ns.deinit(); + var builder: Builder = undefined; + var job: PendingSpecJob = undefined; + job.completed_body_address = .{ .family = @splat(2), .key = @splat(3) }; + job.demand = .evaluation; + var payload = std.mem.zeroes(FrozenBodyProbe); + payload.address = job.completed_body_address.?; + payload.demand = .evaluation; + const exprs = [_]DraftExpr{.{ .ty = .{ .sealed = @fromBackingInt(0) }, .data = .unit }}; + payload.exprs = &exprs; + const validation = CompletedBodyValidation{ .builder = &builder, .job = job, .names = &ns }; + try validation.validate(payload); + payload.demand = .discovery; + try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); + payload.demand = .evaluation; + payload.lowered.body = @fromBackingInt(1); + try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); + payload.lowered.body = @fromBackingInt(0); + payload.lowered.args = .{ .start = 0, .len = 1 }; + try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); + payload.lowered.args = .empty(); + const layouts = [_]DraftLayoutRequest{undefined}; + payload.layout_requests = &layouts; + try std.testing.expect(!CompletedBodyValidation.coverage(payload)); + try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); +} + +/// A selector in the producer's stored value, independent of allocation ids. +const CompletedConstStep = struct { + kind: enum(u8) { box, nominal, list, tuple, record, tag, capture }, + index: u32 = 0, + capture: ?checked.CaptureId = null, +}; +const CompletedBodyStaticDependency = struct { + root: checked.ComptimeRootId, + identity: checked.CompileTimeRootIdentity, + checked_type_key: names.TypeDigest, + path: []const CompletedConstStep, +}; + +/// Explicit paths in one immutable stored value. Sharing and cycles terminate +/// at the first visited node; each retained edge is an actual constructor or +/// capture edge. No old node number is interpreted by the reader. +const CompletedConstPaths = struct { + const Entry = struct { node: checked.ConstNodeId, parent: ?u32, step: CompletedConstStep }; + entries: std.ArrayList(Entry) = .empty, + by_node: std.AutoHashMapUnmanaged(checked.ConstNodeId, u32) = .empty, + allocator: Allocator, + fn add(self: *CompletedConstPaths, node: checked.ConstNodeId, parent: ?u32, step: CompletedConstStep) Allocator.Error!void { + const entry = try self.by_node.getOrPut(self.allocator, node); + if (entry.found_existing) return; + entry.value_ptr.* = @intCast(self.entries.items.len); + try self.entries.append(self.allocator, .{ .node = node, .parent = parent, .step = step }); + } + fn init(allocator: Allocator, store: *const checked.ConstStore, root: checked.ConstNodeId) Allocator.Error!CompletedConstPaths { + var self = CompletedConstPaths{ .allocator = allocator }; + try self.add(root, null, .{ .kind = .box }); + var index: u32 = 0; + while (index < self.entries.items.len) : (index += 1) { + const value = store.get(self.entries.items[index].node); + switch (value) { + .box => |child| try self.add(child, index, .{ .kind = .box }), + .nominal => |nominal| try self.add(nominal.backing, index, .{ .kind = .nominal }), + .tuple => |children| for (children, 0..) |child, i| try self.add(child, index, .{ .kind = .tuple, .index = @intCast(i) }), + .record => |children| for (children, 0..) |child, i| try self.add(child, index, .{ .kind = .record, .index = @intCast(i) }), + .list => |list| switch (list) { + .nodes => |children| for (children, 0..) |child, i| try self.add(child, index, .{ .kind = .list, .index = @intCast(i) }), + .packed_bytes, .empty => {}, + }, + .tag => |tag| for (tag.payloads, 0..) |child, i| try self.add(child, index, .{ .kind = .tag, .index = @intCast(i) }), + .fn_value => |fn_id| for (store.getFn(fn_id).captures, 0..) |capture, i| try self.add(capture.value, index, .{ .kind = .capture, .index = @intCast(i), .capture = capture.id }), + .pending, .zst, .scalar, .str, .crash, .checked_error => {}, + } + } + return self; + } + fn path(self: *const CompletedConstPaths, node: checked.ConstNodeId) Allocator.Error!?[]const CompletedConstStep { + var index = self.by_node.get(node) orelse return null; + var reversed = std.ArrayList(CompletedConstStep).empty; + while (self.entries.items[index].parent) |parent| { + try reversed.append(self.allocator, self.entries.items[index].step); + index = parent; + } + std.mem.reverse(CompletedConstStep, reversed.items); + return reversed.items; + } + fn follow(store: *const checked.ConstStore, root: checked.ConstNodeId, steps: []const CompletedConstStep) ?checked.ConstNodeId { + var node = root; + for (steps) |step| { + const value = store.get(node); + node = switch (step.kind) { + .box => if (value == .box and step.index == 0) value.box else return null, + .nominal => if (value == .nominal and step.index == 0) value.nominal.backing else return null, + .list => if (value == .list and value.list == .nodes and step.index < value.list.nodes.len) value.list.nodes[step.index] else return null, + .tuple => if (value == .tuple and step.index < value.tuple.len) value.tuple[step.index] else return null, + .record => if (value == .record and step.index < value.record.len) value.record[step.index] else return null, + .tag => if (value == .tag and step.index < value.tag.payloads.len) value.tag.payloads[step.index] else return null, + .capture => blk: { + if (value != .fn_value) return null; + const captures = store.getFn(value.fn_value).captures; + if (step.index >= captures.len or step.capture == null or !std.meta.eql(captures[step.index].id, step.capture.?)) return null; + break :blk captures[step.index].value; + }, + }; + } + return node; + } +}; + +test "completed body constant paths survive node renumbering and sharing changes" { + const a = std.testing.allocator; + var scratch = std.heap.ArenaAllocator.init(a); + defer scratch.deinit(); + var first = checked.ConstStore.init(a); + defer first.deinit(); + var second = checked.ConstStore.init(a); + defer second.deinit(); + const old_leaf = try first.append(.{ .scalar = .{ .u64 = 42 } }); + const old_box = try first.append(.{ .box = old_leaf }); + const old_root = try first.append(.{ .record = &.{ old_box, old_box } }); + _ = try second.append(.zst); + const new_root = try second.reserve(); + const new_box = try second.reserve(); + const new_leaf = try second.append(.{ .scalar = .{ .u64 = 42 } }); + const second_leaf = try second.append(.{ .scalar = .{ .u64 = 42 } }); + const second_box = try second.append(.{ .box = second_leaf }); + second.fill(new_box, .{ .box = new_leaf }); + second.fill(new_root, .{ .record = &.{ new_box, second_box } }); + const paths = try CompletedConstPaths.init(scratch.allocator(), &first, old_root); + const path = (try paths.path(old_leaf)).?; + try std.testing.expectEqual(@as(usize, 2), path.len); + try std.testing.expectEqual(new_leaf, CompletedConstPaths.follow(&second, new_root, path).?); + try std.testing.expectEqual(@as(?checked.ConstNodeId, null), CompletedConstPaths.follow(&second, new_box, path)); + const invalid = [_]CompletedConstStep{.{ .kind = .record, .index = 2 }}; + try std.testing.expectEqual(@as(?checked.ConstNodeId, null), CompletedConstPaths.follow(&second, new_root, &invalid)); +} + +test "completed body constant path publication terminates on shared cyclic values" { + const a = std.testing.allocator; + var scratch = std.heap.ArenaAllocator.init(a); + defer scratch.deinit(); + var store = checked.ConstStore.init(a); + defer store.deinit(); + const root = try store.reserve(); + const child = try store.append(.{ .box = root }); + store.fill(root, .{ .tuple = &.{ child, child } }); + const paths = try CompletedConstPaths.init(scratch.allocator(), &store, root); + try std.testing.expectEqual(@as(usize, 2), paths.entries.items.len); + const path = (try paths.path(child)).?; + try std.testing.expectEqual(child, CompletedConstPaths.follow(&store, root, path).?); + try std.testing.expectEqual(@as(usize, 0), (try paths.path(root)).?.len); +} From 8a754795ef471c1dac80f89ac3b71f6d9be25cb1 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 04:46:41 -0400 Subject: [PATCH 61/97] Retain complete dispatch inputs and owned lexical bodies in replay prototype --- design.md | 32 +++- src/postcheck/monotype/lower.zig | 277 ++++++++++++++++++++++++++++--- 2 files changed, 283 insertions(+), 26 deletions(-) diff --git a/design.md b/design.md index a516d5a5ebd..a6e95f83427 100644 --- a/design.md +++ b/design.md @@ -27366,13 +27366,6 @@ All type structure, named definitions, backing authority, dispatch metadata, and ABI boundaries remain in the identity. This whole-analysis boundary is independent of finer-grained specialization body caching. -The complete-analysis cache is currently an experiment enabled only by the -`lambda_analysis_cache` developer diagnostic. Native object reuse changes the -assembled lifted program between builds, so this coarse boundary has not -demonstrated useful reuse on the measured applications. Ordinary builds do not -pay for its input fingerprinting, encoding, or publication. Its immutable codec -and explicit validation remain available for finer-grained experiments. - Completed Lambda Solved records contain only the type graph reachable from output annotations. Serialization resolves redirects and assigns compact local ids while preserving cycles and sharing. Discarded unification rows, unreachable @@ -27400,8 +27393,33 @@ stored value; it never dereferences a persisted ConstNodeId. Sharing and cycles in the producer traversal are visited once. The path remains meaningful when value nodes have been allocated in a different order. +Sealed lexically dependent template bodies are complete owned draft output. +Their resolved calls, captures and lexical parent relationships remain in that +output; they are never published as independent global specializations. The +sealed template producer explicitly omits transient dispatch evidence for this +completed-local state. Deferred/global requests retain evidence, and consumers +require it only in those states. Completed-body replay preserves the local +ownership flags and parent ordering; it never synthesizes a missing context. +Default-root nested sites validate against their explicit declaring module +identity, while their recorded owner template remains separately validated. + +The complete-analysis cache is currently an experiment enabled only by the +`lambda_analysis_cache` developer diagnostic. Native object reuse changes the +assembled lifted program between builds, so this coarse boundary has not +demonstrated useful reuse on the measured applications. Ordinary builds do not +pay for its input fingerprinting, encoding, or publication. Its immutable codec +and explicit validation remain available for finer-grained experiments. + + This completed-body cache remains an opt-in developer experiment under `completed_body_cache`. Initial restricted coverage reused ordinary bodies but missed costly bodies containing static-data requests. Static-value paths extend that coverage; overall performance and complete production coverage still need validation. Ordinary builds do not load or publish these body records. + +Completed-body frontier evidence retains checked target substitutions as +explicit module-owned checked types and alias identities, including nested +and callable-contract evidence. ConstStore's function-value evidence encoding +alone is insufficient: it omits edge-local substitutions still consumed when +a deferred target specializes. The body record validates and restores these +inputs, with storage that outlives the shard when global requests retain it. diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 7f3aad3bd11..4ef1732d9bc 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -10321,7 +10321,7 @@ const Builder = struct { const callee_context = (try self.interfaceCalleeContext(view)) orelse return null; const top = (try self.literalTopModules()).?; var family = TypeDigestHasher.init(); - family.update("roc.completed-body-family.v2"); + family.update("roc.completed-body-family.v4"); family.update(&view.key.bytes); family.update(&(view.code_generation_key orelse view.key).bytes); family.update(&scope_context); @@ -10438,7 +10438,7 @@ const Builder = struct { self.allocator.destroy(arena); }; const validation = CompletedBodyValidation{ .builder = self, .job = job, .names = &workspace.name_store }; - var payload = PersistentInterface.decodeFrozen(FrozenBodyProbe, "BODY0002".*, arena.allocator(), &workspace.types, &workspace.name_store, bytes, BodyContext.PersistentProvenance{ .builder = self, .completed_body_validation = &validation }, null) catch |err| switch (err) { + var payload = PersistentInterface.decodeFrozen(FrozenBodyProbe, "BODY0004".*, arena.allocator(), &workspace.types, &workspace.name_store, bytes, BodyContext.PersistentProvenance{ .builder = self, .completed_body_validation = &validation }, null) catch |err| switch (err) { error.InvalidSummary => return null, error.OutOfMemory => return error.OutOfMemory, }; @@ -10458,7 +10458,7 @@ const Builder = struct { var spec: SealedTemplateSpec = undefined; inline for (@typeInfo(SealedTemplateSpec).@"struct".field_names) |field| { if (comptime std.mem.eql(u8, field, "evidence")) { - spec.evidence = try FrozenEvidenceContext.restore(self, stored.evidence); + spec.evidence = if (stored.evidence) |evidence| try FrozenEvidenceContext.restore(self, evidence) else null; } else if (comptime std.mem.eql(u8, field, "subst")) { spec.subst = try self.allocator.dupe(SealedSubstSlot, stored.subst); } else if (comptime std.mem.eql(u8, field, "codec_contract")) { @@ -10507,7 +10507,7 @@ const Builder = struct { for (draft.sealed_template_specs.items, templates) |source, *out| { inline for (@typeInfo(FrozenTemplateProbe).@"struct".field_names) |field| { if (comptime std.mem.eql(u8, field, "evidence")) { - out.evidence = try self.constFnEvidence(rootEvidence(source.template_ref, source.evidence)); + out.evidence = if (source.evidence) |evidence| try FrozenDispatchEvidence.capture(self, scratch.allocator(), source.template_ref, evidence) else null; } else if (comptime std.mem.eql(u8, field, "codec_contract")) { out.codec_contract = FrozenCodecProbe.of(source.codec_contract); } else @field(out, field) = @field(source, field); @@ -10588,7 +10588,7 @@ const Builder = struct { return null; } if (replay and job.completed_body_address == null) return null; - const bytes = PersistentInterface.encodeFrozen(FrozenBodyProbe, if (replay) "BODY0002".* else "BODYPR02".*, self.allocator, &workspace.types, &workspace.name_store, payload, BodyContext.PersistentProvenance{ .builder = self }, &stats) catch |err| switch (err) { + const bytes = PersistentInterface.encodeFrozen(FrozenBodyProbe, if (replay) "BODY0004".* else "BODYPR02".*, self.allocator, &workspace.types, &workspace.name_store, payload, BodyContext.PersistentProvenance{ .builder = self }, &stats) catch |err| switch (err) { error.NotRepresentable => return null, error.OutOfMemory => return error.OutOfMemory, }; @@ -15261,6 +15261,10 @@ const Builder = struct { codec_contract: ?SealedCodecContractContext, demand: Demand, ) Allocator.Error!ResolvedTemplateSpec { + const evidence = if (@TypeOf(spec.evidence) == ?[]const SpecEvidence) + spec.evidence orelse Common.invariant("deferred request lost its dispatch evidence") + else + spec.evidence; const draft_fn = &body_draft.fns.items[@backingInt(spec.fn_id)]; draft_fn.source.mono_fn_ty = .{ .sealed = draft_fn_ty }; const signature_relation = draft_fn.signature_relation; @@ -15304,7 +15308,7 @@ const Builder = struct { spec.source_fn_ty, spec.source_fn_key, request_fn_ty, - spec.evidence, + evidence, request_subst, signature_relation, .already_counted, @@ -15968,7 +15972,7 @@ const Builder = struct { .source_fn_ty = spec.source_fn_ty, .source_fn_key = spec.source_fn_key, .request_fn_ty = request_ty, - .evidence = spec.evidence, + .evidence = if (spec.local_context_dependent and spec.state == .lowered) null else spec.evidence, .codec_contract = if (spec.codec_contract) |contract| .{ .anchor = contract.anchor, .constructor_ty = try sealer.sealNode(contract.constructor_node), @@ -16323,7 +16327,7 @@ const Builder = struct { try self.lowered_templates.put(fn_id, .{ .def = ids.def(draft_def), .spec = spec_id, - .evidence = spec.evidence, + .evidence = spec.evidence orelse Common.invariant("global template publication lost dispatch evidence"), .topology = try self.retainFnEvidence(evidence), }); try self.markTemplateReady(fn_id, solved_fn_ty); @@ -18356,7 +18360,9 @@ const SealedTemplateSpec = struct { source_fn_ty: checked.CheckedTypeId, source_fn_key: names.TypeDigest, request_fn_ty: Type.TypeId, - evidence: []const SpecEvidence, + /// A fully lowered lexical body carries its resolved calls in the draft. + /// Only deferred or globally publishable requests retain dispatch evidence. + evidence: ?[]const SpecEvidence, /// The request's substitution sealed in the shard's store epoch. subst: SealedSubstitution, codec_contract: ?SealedCodecContractContext, @@ -73418,7 +73424,7 @@ const FrozenTemplateProbe = struct { source_fn_ty: checked.CheckedTypeId, source_fn_key: names.TypeDigest, request_fn_ty: Type.TypeId, - evidence: StoredConstFnEvidence, + evidence: ?FrozenDispatchEvidence, /// The request's substitution sealed in the shard's store epoch. subst: SealedSubstitution, codec_contract: ?FrozenCodecProbe, @@ -73528,8 +73534,8 @@ const CompletedBodyValidation = struct { } fn coverage(payload: FrozenBodyProbe) bool { if (payload.comptime_value_roots.len != 0 or payload.literal_roots.len != 0 or payload.layout_requests.len != 0 or payload.roots.len != 0) return false; - for (payload.templates) |spec| if (spec.requires_local or spec.local_context_dependent or spec.state == .lowering) return false; - for (payload.nested) |spec| if (spec.requires_local or spec.local_context_dependent or spec.state == .lowering) return false; + for (payload.templates) |spec| if (spec.state == .lowering or ((spec.requires_local or spec.local_context_dependent) and spec.state != .lowered)) return false; + for (payload.nested) |spec| if (spec.state == .lowering or ((spec.requires_local or spec.local_context_dependent) and spec.state != .lowered)) return false; return true; } fn staticNode(self: *const CompletedBodyValidation, request: DraftStaticDataRequest, dependency: CompletedBodyStaticDependency) Error!checked.ConstNodeId { @@ -73586,11 +73592,21 @@ const CompletedBodyValidation = struct { for (payload.owner_runs) |owner_run| inline for (@typeInfo(DraftCoreKind).@"enum".field_names, 0..) |column, index| { if (owner_run.starts[index] > owner_run.ends[index] or owner_run.ends[index] > @field(payload, column).len) return rejected(@src().line); }; + for (payload.fns, 0..) |fn_, index| switch (fn_.parent_owner) { + .draft_fn => |parent| if (@backingInt(parent) >= index) return rejected(@src().line), + .root, .reserved_fn => {}, + }; for (payload.templates) |spec| { - if (spec.requires_local or spec.local_context_dependent or spec.state == .lowering) return rejected(@src().line); - try self.evidence(spec.evidence); + if (spec.state == .lowering or ((spec.requires_local or spec.local_context_dependent) and spec.state != .lowered)) return rejected(@src().line); + const closed_local = spec.local_context_dependent and spec.state == .lowered; + if ((spec.evidence == null) != closed_local) return rejected(@src().line); + if (closed_local and (spec.def_id != null or spec.lexical_owner == null)) return rejected(@src().line); + if (spec.evidence) |stored_evidence| { + try self.evidence(stored_evidence.stored); + try stored_evidence.validate(self); + } } - for (payload.nested) |spec| if (spec.requires_local or spec.local_context_dependent or spec.state == .lowering) return rejected(@src().line); + for (payload.nested) |spec| if (spec.state == .lowering or ((spec.requires_local or spec.local_context_dependent) and spec.state != .lowered)) return rejected(@src().line); var walker = Walker{ .validation = self, .payload = &payload }; var read_only = payload; try walker.walk(FrozenBodyProbe, &read_only, false); @@ -73701,8 +73717,15 @@ const CompletedBodyValidation = struct { } if (T == Ast.NestedFn) { const owner = try v.procedure(value.owner); - if (value.default_root != null) return rejected(@src().line); - if (@backingInt(value.site) >= owner.nested_proc_sites.sites.len) return rejected(@src().line); + const site_view = if (value.default_root) |identity| + v.builder.moduleForIdentityHash(&identity.bytes) orelse return rejected(@src().line) + else + owner; + if (@backingInt(value.site) >= site_view.nested_proc_sites.sites.len) return rejected(@src().line); + switch (site_view.nested_proc_sites.sites[@backingInt(value.site)].owner) { + .template => |site_owner| if (value.default_root != null or !names.procedureTemplateRefEql(site_owner, value.owner)) return rejected(@src().line), + .default_root => if (value.default_root == null) return rejected(@src().line), + } return; } if (T == DraftTypeCell) { @@ -73747,13 +73770,106 @@ const CompletedBodyValidation = struct { }; }; +/// ConstStore evidence intentionally omits edge-local substitutions. A queued +/// body frontier can still consume them, so completed bodies retain them in +/// addition to the value's dispatch identity. The shared walk gives these +/// entries the same explicit order during capture and restoration. +const FrozenDispatchEvidence = struct { + stored: StoredConstFnEvidence, + substitutions: []const ?Substitution, + + const Substitution = struct { + module: checked.ModuleId, + tys: []const checked.CheckedTypeId, + keys: []const names.TypeDigest, + alias: ?struct { module: checked.ModuleId, value: static_dispatch.MethodAlias }, + }; + + fn capture(builder: *Builder, allocator: Allocator, owner: names.ProcTemplate, vector: []const SpecEvidence) Allocator.Error!FrozenDispatchEvidence { + var walk = SpecEvidenceWalk{}; + defer walk.deinit(allocator); + var substitutions = std.ArrayList(?Substitution).empty; + var cursor = vector; + while (try walk.next(allocator, &cursor)) |entry| switch (entry) { + .target => |target| { + const stored: ?Substitution = if (target.substitution) |substitution| blk: { + const keys = try allocator.alloc(names.TypeDigest, substitution.tys.len); + for (substitution.tys, keys) |ty, *key| key.* = substitution.view.types.rootKey(ty); + break :blk .{ + .module = substitution.view.key, + .tys = substitution.tys, + .keys = keys, + .alias = if (substitution.alias) |alias| .{ .module = alias.view.key, .value = alias.alias } else null, + }; + } else null; + try substitutions.append(allocator, stored); + }, + else => {}, + }; + return .{ .stored = try builder.constFnEvidence(rootEvidence(owner, vector)), .substitutions = substitutions.items }; + } + + fn validate(self: FrozenDispatchEvidence, validation: *const CompletedBodyValidation) CompletedBodyValidation.Error!void { + var targets: usize = 0; + for (self.stored.entries.nodes) |entry| if (entry == .target) { + targets += 1; + }; + if (targets != self.substitutions.len) return CompletedBodyValidation.rejected(@src().line); + for (self.substitutions) |maybe| if (maybe) |substitution| { + const view = validation.builder.moduleForKeyBytes(substitution.module.bytes) orelse return CompletedBodyValidation.rejected(@src().line); + if (substitution.tys.len != substitution.keys.len) return CompletedBodyValidation.rejected(@src().line); + for (substitution.tys, substitution.keys) |ty, key| { + if (@backingInt(ty) >= view.types.payloadCount()) return CompletedBodyValidation.rejected(@src().line); + if (!std.meta.eql(key, view.types.rootKey(ty))) return CompletedBodyValidation.rejected(@src().line); + } + if (substitution.alias) |alias| { + const alias_view = validation.builder.moduleForKeyBytes(alias.module.bytes) orelse return CompletedBodyValidation.rejected(@src().line); + switch (alias.value) { + .top_level => |pattern| { + for (alias_view.callable_eval_templates.templates) |template| { + if (template.pattern == pattern) break; + } else return CompletedBodyValidation.rejected(@src().line); + }, + .local => |local| { + if (@backingInt(local.lookup) >= alias_view.bodies.stored_exprs.len) return CompletedBodyValidation.rejected(@src().line); + if (local.dispatch_scope) |scope| if (@backingInt(scope) >= alias_view.templates.dispatch_scopes.len) return CompletedBodyValidation.rejected(@src().line); + }, + } + } + }; + } + + fn restoreSubstitutions(self: FrozenDispatchEvidence, builder: *Builder, vector: []const SpecEvidence) Allocator.Error!void { + var walk = SpecEvidenceWalk{}; + defer walk.deinit(builder.allocator); + var cursor = vector; + var index: usize = 0; + while (try walk.next(builder.allocator, &cursor)) |entry| switch (entry) { + .target => |target| { + if (index >= self.substitutions.len) Common.invariant("validated body evidence lost a target substitution"); + @constCast(target).substitution = if (self.substitutions[index]) |substitution| .{ + .view = builder.moduleForId(substitution.module), + // Global specialization records may outlive the shard. + .tys = try builder.evidence_arena.allocator().dupe(checked.CheckedTypeId, substitution.tys), + .alias = if (substitution.alias) |alias| .{ .view = builder.moduleForId(alias.module), .alias = alias.value } else null, + } else null; + index += 1; + }, + else => {}, + }; + if (index != self.substitutions.len) Common.invariant("validated body evidence changed target count"); + } +}; + const FrozenEvidenceContext = struct { allocator: Allocator, builder: *Builder, - fn restore(builder: *Builder, evidence: StoredConstFnEvidence) Allocator.Error![]const SpecEvidence { + fn restore(builder: *Builder, evidence: FrozenDispatchEvidence) Allocator.Error![]const SpecEvidence { var self = FrozenEvidenceContext{ .allocator = builder.allocator, .builder = builder }; var cursor: usize = 0; - return BodyContext.materializeConstFnEvidenceVector(&self, evidence.entries, &cursor, evidence.frames[0].roots_len); + const vector = try BodyContext.materializeConstFnEvidenceVector(&self, evidence.stored.entries, &cursor, evidence.stored.frames[0].roots_len); + try evidence.restoreSubstitutions(builder, vector); + return vector; } fn finishStoredEvidenceTarget(self: *FrozenEvidenceContext, target: *SpecEvidenceTarget, stored: check.ConstStore.ConstFnTargetEvidence, nested: NestedSpecEvidence) Allocator.Error!void { if (stored.method.kind == .local_proc) Common.invariant("validated completed body retained a local procedure context"); @@ -73767,6 +73883,59 @@ const FrozenEvidenceContext = struct { } }; +test "completed body frontier retains nested target substitutions with owned checked types" { + const a = std.testing.allocator; + var ns = names.NameStore.init(a); + defer ns.deinit(); + var types = checked.CheckedTypeStore{}; + defer types.deinit(a); + const first = try types.reserveSyntheticTypeRoot(a, .{ .bytes = @splat(7) }, false); + try types.fillSyntheticTypeRoot(a, first, .empty_record); + const second = try types.reserveSyntheticTypeRoot(a, .{ .bytes = @splat(8) }, false); + try types.fillSyntheticTypeRoot(a, second, .empty_tag_union); + const owner: checked.ModuleId = .{ .bytes = @splat(11) }; + var views: [1]ModuleViewData = undefined; + views[0].key = owner; + views[0].types = types.view(); + var builder: Builder = undefined; + builder.allocator = a; + builder.borrowed_module_views = &views; + builder.module_index = @TypeOf(builder.module_index).init(a); + defer builder.module_index.deinit(); + try builder.module_index.put(owner.bytes, .root); + builder.evidence_arena = base.SingleThreadArena.init(a); + defer builder.evidence_arena.deinit(); + const validation = CompletedBodyValidation{ .builder = &builder, .job = undefined, .names = &ns }; + var ids = [_]checked.CheckedTypeId{ first, second }; + var keys = [_]names.TypeDigest{ views[0].types.rootKey(first), views[0].types.rootKey(second) }; + var substitutions = [_]?FrozenDispatchEvidence.Substitution{ + .{ .module = owner, .tys = ids[0..1], .keys = keys[0..1], .alias = null }, + .{ .module = owner, .tys = ids[1..2], .keys = keys[1..2], .alias = null }, + }; + var frozen = FrozenDispatchEvidence{ .stored = StoredConstFnEvidence.empty(), .substitutions = &substitutions }; + frozen.stored.entries.nodes = &.{ .{ .target = 0 }, .{ .target = 1 } }; + try frozen.validate(&validation); + var child: SpecEvidenceTarget = undefined; + child.nested = .synthesize; + child.callable_contracts = &.{}; + var parent: SpecEvidenceTarget = undefined; + parent.nested = .{ .resolved = &.{.{ .target = &child }} }; + parent.callable_contracts = &.{}; + try frozen.restoreSubstitutions(&builder, &.{.{ .target = &parent }}); + try std.testing.expectEqual(first, parent.substitution.?.tys[0]); + try std.testing.expectEqual(second, child.substitution.?.tys[0]); + try std.testing.expectEqual(&views[0], child.substitution.?.view); + ids[1] = first; + try std.testing.expectEqual(second, child.substitution.?.tys[0]); + try std.testing.expectError(error.InvalidSummary, frozen.validate(&validation)); + ids[1] = second; + substitutions[1].?.module.bytes[0] ^= 1; + try std.testing.expectError(error.InvalidSummary, frozen.validate(&validation)); + substitutions[1].?.module = owner; + frozen.substitutions = substitutions[0..1]; + try std.testing.expectError(error.InvalidSummary, frozen.validate(&validation)); +} + test "completed body rejects foreign reservations and rebinds its own references" { const a = std.testing.allocator; var ns = names.NameStore.init(a); @@ -73946,3 +74115,73 @@ test "completed body constant path publication terminates on shared cyclic value try std.testing.expectEqual(child, CompletedConstPaths.follow(&store, root, path).?); try std.testing.expectEqual(@as(usize, 0), (try paths.path(root)).?.len); } + +test "completed body static paths require current producer identity and checked type owner" { + const a = std.testing.allocator; + var ns = names.NameStore.init(a); + defer ns.deinit(); + var store = checked.ConstStore.init(a); + defer store.deinit(); + const leaf = try store.append(.zst); + const root = try store.append(.{ .box = leaf }); + var types = checked.CheckedTypeStore{}; + defer types.deinit(a); + const ty = try types.reserveSyntheticTypeRoot(a, .{ .bytes = @splat(7) }, false); + try types.fillSyntheticTypeRoot(a, ty, .empty_record); + const owner: checked.ModuleId = .{ .bytes = @splat(11) }; + const locator: checked.ConstLocator = .{ + .artifact = owner, + .owner = .{ .top_level_binding = .{ .module_idx = 0, .pattern = @fromBackingInt(0) } }, + .template = @fromBackingInt(0), + .source_scheme = .{ .bytes = @splat(12) }, + }; + var templates: checked.ConstTemplateTable = .{}; + defer templates.templates.deinit(a); + try templates.templates.append(a, .{ + .id = locator.template, + .owner = locator.owner, + .source_scheme = locator.source_scheme, + .state = .{ .stored_const = .{ .node = root, .root = @fromBackingInt(0), .root_type = @fromBackingInt(0) } }, + }); + var rows = [_]checked.CompileTimeRootIdentity{.{ .value = @splat(13), .site = @splat(14) }}; + const identities: checked.CompileTimeRootIdentityTable = .{ .rows = &rows }; + var views: [1]ModuleViewData = undefined; + views[0].key = owner; + views[0].const_templates = &templates; + views[0].const_store = &store; + views[0].types = types.view(); + views[0].compile_time_root_identities = &identities; + var builder: Builder = undefined; + builder.allocator = a; + builder.borrowed_module_views = &views; + builder.module_index = @TypeOf(builder.module_index).init(a); + defer builder.module_index.deinit(); + try builder.module_index.put(owner.bytes, .root); + const validation = CompletedBodyValidation{ .builder = &builder, .job = undefined, .names = &ns }; + var request: DraftStaticDataRequest = .{ + .const_locator = locator, + .module = owner, + // The old allocation ids deliberately do not exist in this store. + .node = @fromBackingInt(111), + .const_node = @fromBackingInt(111), + .checked_type = ty, + .checked_type_module = owner, + .ty = .{ .sealed = @fromBackingInt(0) }, + }; + const path = [_]CompletedConstStep{.{ .kind = .box }}; + const dependency: CompletedBodyStaticDependency = .{ + .root = @fromBackingInt(0), + .identity = rows[0], + .checked_type_key = views[0].types.rootKey(ty), + .path = &path, + }; + try std.testing.expectEqual(leaf, try validation.staticNode(request, dependency)); + rows[0].value[0] ^= 1; + try std.testing.expectError(error.InvalidSummary, validation.staticNode(request, dependency)); + rows[0] = dependency.identity; + request.checked_type_module.bytes[0] ^= 1; + try std.testing.expectError(error.InvalidSummary, validation.staticNode(request, dependency)); + request.checked_type_module = owner; + templates.templates.items[0].state = .reserved; + try std.testing.expectError(error.InvalidSummary, validation.staticNode(request, dependency)); +} From e03993e48ccfd9eae737a3879f08315d7953bdd2 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 05:15:37 -0400 Subject: [PATCH 62/97] Admit deferred context-free procedure values to completed body replay --- design.md | 6 ++++++ src/postcheck/monotype/lower.zig | 31 ++++++++++++++++++++++++------- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/design.md b/design.md index a6e95f83427..ed032738131 100644 --- a/design.md +++ b/design.md @@ -27403,6 +27403,12 @@ ownership flags and parent ordering; it never synthesizes a missing context. Default-root nested sites validate against their explicit declaring module identity, while their recorded owner template remains separately validated. +`requires_local` alone does not imply a lexical dependency: taking an ordinary +procedure as a value sets that flag on a deferred context-free request. Such a +request retains its full frontier evidence and sealed substitution and may be +replayed unchanged. Only `local_context_dependent` requires an already-lowered +owned body; neither kind may retain an unfinished `lowering` state in a record. + The complete-analysis cache is currently an experiment enabled only by the `lambda_analysis_cache` developer diagnostic. Native object reuse changes the assembled lifted program between builds, so this coarse boundary has not diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 4ef1732d9bc..bbd566f9a48 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -10321,7 +10321,7 @@ const Builder = struct { const callee_context = (try self.interfaceCalleeContext(view)) orelse return null; const top = (try self.literalTopModules()).?; var family = TypeDigestHasher.init(); - family.update("roc.completed-body-family.v4"); + family.update("roc.completed-body-family.v5"); family.update(&view.key.bytes); family.update(&(view.code_generation_key orelse view.key).bytes); family.update(&scope_context); @@ -10438,7 +10438,7 @@ const Builder = struct { self.allocator.destroy(arena); }; const validation = CompletedBodyValidation{ .builder = self, .job = job, .names = &workspace.name_store }; - var payload = PersistentInterface.decodeFrozen(FrozenBodyProbe, "BODY0004".*, arena.allocator(), &workspace.types, &workspace.name_store, bytes, BodyContext.PersistentProvenance{ .builder = self, .completed_body_validation = &validation }, null) catch |err| switch (err) { + var payload = PersistentInterface.decodeFrozen(FrozenBodyProbe, "BODY0005".*, arena.allocator(), &workspace.types, &workspace.name_store, bytes, BodyContext.PersistentProvenance{ .builder = self, .completed_body_validation = &validation }, null) catch |err| switch (err) { error.InvalidSummary => return null, error.OutOfMemory => return error.OutOfMemory, }; @@ -10588,7 +10588,7 @@ const Builder = struct { return null; } if (replay and job.completed_body_address == null) return null; - const bytes = PersistentInterface.encodeFrozen(FrozenBodyProbe, if (replay) "BODY0004".* else "BODYPR02".*, self.allocator, &workspace.types, &workspace.name_store, payload, BodyContext.PersistentProvenance{ .builder = self }, &stats) catch |err| switch (err) { + const bytes = PersistentInterface.encodeFrozen(FrozenBodyProbe, if (replay) "BODY0005".* else "BODYPR02".*, self.allocator, &workspace.types, &workspace.name_store, payload, BodyContext.PersistentProvenance{ .builder = self }, &stats) catch |err| switch (err) { error.NotRepresentable => return null, error.OutOfMemory => return error.OutOfMemory, }; @@ -73534,8 +73534,8 @@ const CompletedBodyValidation = struct { } fn coverage(payload: FrozenBodyProbe) bool { if (payload.comptime_value_roots.len != 0 or payload.literal_roots.len != 0 or payload.layout_requests.len != 0 or payload.roots.len != 0) return false; - for (payload.templates) |spec| if (spec.state == .lowering or ((spec.requires_local or spec.local_context_dependent) and spec.state != .lowered)) return false; - for (payload.nested) |spec| if (spec.state == .lowering or ((spec.requires_local or spec.local_context_dependent) and spec.state != .lowered)) return false; + for (payload.templates) |spec| if (spec.state == .lowering or (spec.local_context_dependent and spec.state != .lowered)) return false; + for (payload.nested) |spec| if (spec.state == .lowering or (spec.local_context_dependent and spec.state != .lowered)) return false; return true; } fn staticNode(self: *const CompletedBodyValidation, request: DraftStaticDataRequest, dependency: CompletedBodyStaticDependency) Error!checked.ConstNodeId { @@ -73597,7 +73597,7 @@ const CompletedBodyValidation = struct { .root, .reserved_fn => {}, }; for (payload.templates) |spec| { - if (spec.state == .lowering or ((spec.requires_local or spec.local_context_dependent) and spec.state != .lowered)) return rejected(@src().line); + if (spec.state == .lowering or (spec.local_context_dependent and spec.state != .lowered)) return rejected(@src().line); const closed_local = spec.local_context_dependent and spec.state == .lowered; if ((spec.evidence == null) != closed_local) return rejected(@src().line); if (closed_local and (spec.def_id != null or spec.lexical_owner == null)) return rejected(@src().line); @@ -73606,7 +73606,7 @@ const CompletedBodyValidation = struct { try stored_evidence.validate(self); } } - for (payload.nested) |spec| if (spec.state == .lowering or ((spec.requires_local or spec.local_context_dependent) and spec.state != .lowered)) return rejected(@src().line); + for (payload.nested) |spec| if (spec.state == .lowering or (spec.local_context_dependent and spec.state != .lowered)) return rejected(@src().line); var walker = Walker{ .validation = self, .payload = &payload }; var read_only = payload; try walker.walk(FrozenBodyProbe, &read_only, false); @@ -73990,6 +73990,23 @@ test "completed body rejects incompatible demand missing columns and invalid dra try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); } +test "completed body admits context-free procedure values but requires finished lexical bodies" { + var payload = std.mem.zeroes(FrozenBodyProbe); + var specs = [_]FrozenTemplateProbe{std.mem.zeroes(FrozenTemplateProbe)}; + payload.templates = &specs; + // Taking a context-free procedure as a value sets requires_local even + // while its body remains a deferred coordinator request. + specs[0].state = .deferred; + specs[0].requires_local = true; + try std.testing.expect(CompletedBodyValidation.coverage(payload)); + specs[0].local_context_dependent = true; + try std.testing.expect(!CompletedBodyValidation.coverage(payload)); + specs[0].state = .lowering; + try std.testing.expect(!CompletedBodyValidation.coverage(payload)); + specs[0].state = .lowered; + try std.testing.expect(CompletedBodyValidation.coverage(payload)); +} + /// A selector in the producer's stored value, independent of allocation ids. const CompletedConstStep = struct { kind: enum(u8) { box, nominal, list, tuple, record, tag, capture }, From 7b1abe6880df07db6282cbba70d5098f3a7ae489 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 07:39:03 -0400 Subject: [PATCH 63/97] Instrument specialization request resolution phases --- design.md | 6 ++++ src/base/dev_diagnostics.zig | 3 ++ src/postcheck/monotype/lower.zig | 61 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/design.md b/design.md index ed032738131..7d559977787 100644 --- a/design.md +++ b/design.md @@ -26539,6 +26539,12 @@ cases that set one of these variables skip against a compiler without them. Performance work that needs them on an optimized compiler builds it with `zig build roc -Doptimize=ReleaseFast -Ddev-diagnostics`. +`spec_resolution_profile` records exclusive coordinator request-resolution +phases. Nested resolution pauses its parent's phase, and resuming restores +that phase; these durations must not double-count recursive resolution. Each +builder owns its counters and prints them on destruction. This is diagnostic +measurement only and does not alter specialization or cache admission. + `ROC_TEST_COMMAND_WORK` prints deterministic test-command counters at exit. The specialization, evaluation, runtime LIR, ARC, static-data and native test execution producers increment their own counters. A complete result hit leaves diff --git a/src/base/dev_diagnostics.zig b/src/base/dev_diagnostics.zig index 0c4253e10ab..a54dcf7b1dc 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -22,6 +22,8 @@ pub const Var = enum { sealed_body_profile, /// Exercise validated completed specialization-body replay. completed_body_cache, + /// Separate coordinator request-resolution costs into exclusive phases. + spec_resolution_profile, /// Print the final LIR of every procedure whose debug name contains the value. lir_dump, /// Print every closed specialization key. @@ -58,6 +60,7 @@ pub const Var = enum { return switch (self) { .sealed_body_profile => "ROC_SEALED_BODY_PROFILE", .completed_body_cache => "ROC_COMPLETED_BODY_CACHE", + .spec_resolution_profile => "ROC_SPEC_RESOLUTION_PROFILE", .trace_boxy_work => "ROC_TRACE_BOXY_WORK", .lir_dump => "ROC_LIR_DUMP", .spec_census => "ROC_SPEC_CENSUS", diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index bbd566f9a48..8b7c0be4a28 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -1295,6 +1295,51 @@ const ParallelCoordinatorTimingScope = struct { } }; +const ResolutionProfile = struct { + const Phase = enum { evidence_completion, evidence_encoding, identity, local_lookup, local_reuse, native_lookup, evidence_copy, reservation, body_address, discovery_dispatch, immediate_body, demand_shape }; + const count = @typeInfo(Phase).@"enum".field_names.len; + ns: [count]u64 = @splat(0), + entries: [count]u64 = @splat(0), + active: ?Phase = null, + started: i64 = 0, + + fn transition(self: *ResolutionProfile, next: ?Phase) void { + const now = timingNowNs(std.Io.Threaded.global_single_threaded.io()); + if (self.active) |phase| self.ns[@backingInt(phase)] +%= @intCast(@max(0, now - self.started)); + self.started = now; + self.active = next; + } + + fn report(self: *const ResolutionProfile) void { + if (!dev_diagnostics.isSet(.spec_resolution_profile)) return; + inline for (@typeInfo(Phase).@"enum".field_names, 0..) |name, i| { + if (self.entries[i] != 0) std.debug.print("resolution-profile phase={s} ns={d} entries={d}\n", .{ name, self.ns[i], self.entries[i] }); + } + } + + const Scope = struct { + profile: ?*ResolutionProfile = null, + previous: ?Phase = null, + fn begin(profile: *ResolutionProfile, phase: Phase) Scope { + if (!dev_diagnostics.isSet(.spec_resolution_profile)) return .{}; + const previous = profile.active; + profile.transition(phase); + profile.entries[@backingInt(phase)] += 1; + return .{ .profile = profile, .previous = previous }; + } + fn enter(self: *Scope, phase: Phase) void { + const profile = self.profile orelse return; + profile.transition(phase); + profile.entries[@backingInt(phase)] += 1; + } + fn end(self: *Scope) void { + const profile = self.profile orelse return; + profile.transition(self.previous); + self.profile = null; + } + }; +}; + const BodyWorkTimingPhase = enum { type_graph, call_dispatch, @@ -5301,6 +5346,7 @@ const LoweringModuleIds = std.AutoHashMap([32]u8, Common.LoweringModuleId); const CheckedOccurrence = struct { module: checked.ModuleId, ty: checked.CheckedTypeId }; const Builder = struct { + resolution_profile: ResolutionProfile = .{}, /// `appModuleIdentity` of this lowering's modules. app_module_identity: ?[32]u8, allocator: Allocator, @@ -5943,6 +5989,7 @@ const Builder = struct { } fn deinit(self: *Builder) void { + self.resolution_profile.report(); self.allocator.free(self.program_module_buffer); deinitSpecJobLanes(self.allocator, self.spec_job_lanes); for (self.lower_frame_pool.items) |*frames| frames.deinit(self.allocator); @@ -8809,6 +8856,8 @@ const Builder = struct { ) Allocator.Error!Ast.DefId { var lookup_timing_scope = ProcedureTimingScope.begin(self.timing, .lookup_reservation); defer lookup_timing_scope.end(); + var resolution_profile = ResolutionProfile.Scope.begin(&self.resolution_profile, .evidence_completion); + defer resolution_profile.end(); if (request_accounting == .count) self.count("template_requests"); const lower_fn_ty = fn_ty; @@ -8826,9 +8875,11 @@ const Builder = struct { else rootEvidence(template_ref, spec_evidence); const identity_topology = source_topology orelse rootEvidence(template_ref, spec_evidence); + resolution_profile.enter(.evidence_encoding); const identity_evidence = try self.constFnEvidence(identity_topology); const evidence_digest = identity_evidence.digest; const stored_source_topology = if (source_topology != null) identity_evidence else null; + resolution_profile.enter(.identity); const request_digest = precomputed_request_digest orelse self.specializationTypeDigest(fn_ty); const spec_identity = self.templateSpecIdentity( template_ref, @@ -8838,7 +8889,9 @@ const Builder = struct { fn_ty, request_digest, ); + resolution_profile.enter(.local_lookup); if (try self.spec_store.findLocal(spec_identity, specializationEvidenceView(identity_evidence))) |hit| { + resolution_profile.enter(.local_reuse); if (request_accounting == .count) self.count("template_hits"); const existing = self.lowered_templates.get(hit.fn_id) orelse Common.invariant("Monotype specialization index found a local template missing from lowering state"); @@ -8903,6 +8956,7 @@ const Builder = struct { if (request_accounting == .count) self.count("template_misses"); } + resolution_profile.enter(.native_lookup); var fn_template = self.fnDefForTemplate(view, template_ref, source_fn_ty, source_fn_key, lower_fn_ty); fn_template.evidence_digest = evidence_digest; // Only a closed request names a specialization the object cache can @@ -8952,12 +9006,14 @@ const Builder = struct { } else if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("lookup monotype key={x} miss\n", .{key.bytes[0..8]}); }; } + resolution_profile.enter(.evidence_copy); if (stored_source_topology) |stored_evidence| { fn_template.const_evidence = try self.program.addConstFnEvidence(stored_evidence.entries); fn_template.const_evidence_frames = try self.program.addConstFnEvidenceFrames(stored_evidence.frames); fn_template.const_evidence_frame_head = stored_evidence.head; } + resolution_profile.enter(.reservation); const ReservedSpec = struct { reservation: TemplateReservation, spec: Ast.SpecId }; const reserved: ReservedSpec = blk: { // This reservation is appended directly to the durable Program even @@ -9016,6 +9072,7 @@ const Builder = struct { if (retained_topology != null) { Common.invariant("queued Monotype specialization request cannot carry a retained lexical topology"); } + resolution_profile.enter(.body_address); const job: PendingSpecJob = .{ .completed_body_address = if (dev_diagnostics.isSet(.completed_body_cache)) try self.completedBodyAddress(spec_identity, view, method_scope, source_fn_key, subst, signature_relation, demand) else null, .profile_key = if (dev_diagnostics.isSet(.sealed_body_profile)) Ast.specIdentityKey(spec_identity).bytes else @splat(0), @@ -9034,6 +9091,7 @@ const Builder = struct { .codec_contract = codec_contract, .demand = demand, }; + resolution_profile.enter(.discovery_dispatch); if (self.parking_root_job) { // A demand-driven root's body waits until the program // demands the root; its dispatch index is assigned when @@ -9077,6 +9135,7 @@ const Builder = struct { }, } + resolution_profile.enter(.immediate_body); if (self.spec_job_parallel_callback) { Common.compilerBug("parallel specialization worker attempted coordinator-owned immediate completion"); } @@ -15369,6 +15428,8 @@ const Builder = struct { fn_ty: Type.TypeId, subst: SealedSubstitution, ) Allocator.Error!?ShapedRequest { + var resolution_profile = ResolutionProfile.Scope.begin(&self.resolution_profile, .demand_shape); + defer resolution_profile.end(); const view = self.moduleForDigest(names.procTemplateModuleDigest(template_ref)); const template = view.templates.get(template_ref.template); const demands = view.templates.templateDemands(&template); From 2769678c8e2eeca87f4e1d67906c8d87d6da6ef7 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 08:00:43 -0400 Subject: [PATCH 64/97] Separate native cache acquisition from specialization classification --- design.md | 5 +++- src/cli/pack_store.zig | 48 ++++++++++++++++++++++++++++++++ src/postcheck/monotype/lower.zig | 5 ++-- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/design.md b/design.md index 7d559977787..b264cccf508 100644 --- a/design.md +++ b/design.md @@ -26543,7 +26543,10 @@ Performance work that needs them on an optimized compiler builds it with phases. Nested resolution pauses its parent's phase, and resuming restores that phase; these durations must not double-count recursive resolution. Each builder owns its counters and prints them on destruction. This is diagnostic -measurement only and does not alter specialization or cache admission. +measurement only and does not alter specialization or cache admission. Native +lookup separates function-type classification from provider queries; the +provider reports exclusive bucket, offer, metadata, graph, eligibility, payload, +and installation phases under the same diagnostic. `ROC_TEST_COMMAND_WORK` prints deterministic test-command counters at exit. The specialization, evaluation, runtime LIR, ARC, static-data and native test diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index f8850decedf..3e389ef6f43 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -947,6 +947,42 @@ pub fn encodeDiscovery(allocator: Allocator, summaries: []const lir.CheckedPipel /// Every pack in a directory, indexed by specialization key and by procedure /// identity. Files load in name order so the indexes are deterministic when /// two packs carry the same entry. +/// Exclusive phases within indexed native lookup. This diagnostic is owned by +/// the coordinator; interface workers use a different cache entry point. +const NativeLookupProfile = struct { + const Phase = enum { buckets, offers, decode, metadata, graph, eligibility, payloads, install }; + ns: [8]i128 = @splat(0), + const Scope = struct { + profile: ?*NativeLookupProfile = null, + phase: Phase = .buckets, + started: i128 = 0, + fn now() i128 { + return std.Io.Timestamp.now(std.Io.Threaded.global_single_threaded.io(), .awake).nanoseconds; + } + fn begin(profile: *NativeLookupProfile) Scope { + if (!base.dev_diagnostics.isSet(.spec_resolution_profile)) return .{}; + return .{ .profile = profile, .started = now() }; + } + fn enter(self: *Scope, phase: Phase) void { + const profile = self.profile orelse return; + const current = now(); + profile.ns[@backingInt(self.phase)] += current - self.started; + self.started = current; + self.phase = phase; + } + fn end(self: *Scope) void { + self.enter(self.phase); + self.profile = null; + } + }; + fn report(self: *const NativeLookupProfile) void { + if (!base.dev_diagnostics.isSet(.spec_resolution_profile)) return; + inline for (@typeInfo(Phase).@"enum".field_names, 0..) |name, i| { + if (self.ns[i] != 0) std.debug.print("native-lookup-profile phase={s} ns={d}\n", .{ name, self.ns[i] }); + } + } +}; + pub const LoadedPacks = struct { const LinkedOffer = struct { hit: Common.SpecCacheHit, @@ -1005,6 +1041,7 @@ pub const LoadedPacks = struct { retained_offers: std.AutoHashMapUnmanaged([32]u8, *const base.retained_cache.Record) = .empty, index_stats: IndexStats = .{}, + lookup_profile: NativeLookupProfile = .{}, /// A completed-output probe has priority over procedure discovery. Until /// it misses, compile-time demand uses source bodies and cached values. defer_code_lookup: bool = false, @@ -1387,6 +1424,8 @@ pub const LoadedPacks = struct { /// providers. Provider rank remains root-first, independent of disk order. fn findIndexed(self: *LoadedPacks, key: [32]u8, takes: Common.SpecCacheTakes, modules: Common.ProgramModules) Allocator.Error!IndexedResult { if (self.index_roots.items.len == 0) return .unavailable; + var profile = NativeLookupProfile.Scope.begin(&self.lookup_profile); + defer profile.end(); const allocator = self.allocator; const Candidate = struct { digest: [32]u8, @@ -1402,6 +1441,7 @@ pub const LoadedPacks = struct { defer candidates.deinit(allocator); var cacheable = true; for (self.index_roots.items) |root| { + profile.enter(.buckets); self.index_stats.bucket_opens += 1; const group = root.store.indexedIdentity(root.origin, key, @splat(0), .offer); const keys = (try self.retentionBatch(root.store)).groupKeys(allocator, group) catch |err| switch (err) { @@ -1413,6 +1453,7 @@ pub const LoadedPacks = struct { }; defer allocator.free(keys); if (keys.len == 0) self.index_stats.bucket_absent += 1; + profile.enter(.offers); for (keys) |catalog_key| { const owner = try self.recordOwner(root.store.roc_ctx.std_io); const record = root.store.acquireRetained(owner, try self.retentionBatch(root.store), catalog_key, .native_offer_v3) catch |err| switch (err) { @@ -1443,6 +1484,7 @@ pub const LoadedPacks = struct { } std.mem.sort(Candidate, candidates.items, {}, Candidate.less); for (candidates.items, 0..) |candidate, candidate_index| { + profile.enter(.decode); self.index_stats.candidates += 1; if (candidate_index != 0) self.index_stats.duplicates += 1; const offer_record = candidate.record; @@ -1459,6 +1501,7 @@ pub const LoadedPacks = struct { var adopted = false; defer if (!adopted) selection.pack.deinit(); if (takes == .test_dev_code and !selection.pack.specs[0].test_compatible) continue; + profile.enter(.metadata); const metadata = try self.loadMetadata(store, selection.component) orelse { cacheable = false; continue; @@ -1470,10 +1513,12 @@ pub const LoadedPacks = struct { cacheable = false; continue; } + profile.enter(.graph); if (!try self.ensureGraph(store, metadata)) { cacheable = false; continue; } + profile.enter(.eligibility); const pack = self.indexed_pack.?; var spec = selection.pack.specs[0]; spec.excerpt = metadata.start.? + selection.index; @@ -1491,10 +1536,12 @@ pub const LoadedPacks = struct { } } self.index_stats.requested_artifacts += metadata.pack.set.excerpts.len; + profile.enter(.payloads); if (!try self.materializeComponent(store, metadata)) { cacheable = false; continue; } + profile.enter(.install); _ = try self.adoptPack(selection.pack); adopted = true; try self.excerpts.ensureUnusedCapacity(1); @@ -1948,6 +1995,7 @@ pub const LoadedPacks = struct { } pub fn deinit(self: *LoadedPacks) void { + self.lookup_profile.report(); if (base.dev_diagnostics.isSet(.pack_stats)) { const stats = self.index_stats; if (self.record_owner) |owner| std.debug.print("pack acquisitions: {any}\n", .{owner.metrics}); diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 8b7c0be4a28..1078027a18e 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -1296,7 +1296,7 @@ const ParallelCoordinatorTimingScope = struct { }; const ResolutionProfile = struct { - const Phase = enum { evidence_completion, evidence_encoding, identity, local_lookup, local_reuse, native_lookup, evidence_copy, reservation, body_address, discovery_dispatch, immediate_body, demand_shape }; + const Phase = enum { evidence_completion, evidence_encoding, identity, local_lookup, local_reuse, native_classification, native_lookup, evidence_copy, reservation, body_address, discovery_dispatch, immediate_body, demand_shape }; const count = @typeInfo(Phase).@"enum".field_names.len; ns: [count]u64 = @splat(0), entries: [count]u64 = @splat(0), @@ -8956,7 +8956,7 @@ const Builder = struct { if (request_accounting == .count) self.count("template_misses"); } - resolution_profile.enter(.native_lookup); + resolution_profile.enter(.native_classification); var fn_template = self.fnDefForTemplate(view, template_ref, source_fn_ty, source_fn_key, lower_fn_ty); fn_template.evidence_digest = evidence_digest; // Only a closed request names a specialization the object cache can @@ -8993,6 +8993,7 @@ const Builder = struct { const name: []const u8 = if (proc_base.export_name) |e| view.names.exportNameText(e) else "?"; std.debug.print("CENSUS_KEY\t{s}\t{x}\tev={x}\tcodec={x}\treq={x}\tcallable={s}\n", .{ name, key.bytes[0..8], spec_identity.evidence_digest.bytes[0..6], spec_identity.codec_contract_digest.bytes[0..6], spec_identity.request_fn_ty_digest.bytes[0..6], @tagName(spec_identity.callable) }); } + resolution_profile.enter(.native_lookup); // A queued discovery request whose outcome an earlier compilation // settled is parked below with no body, so answering it consults // no cached code, and a program whose every request is settled From af077dc29dc4dac36f50c4d64d28261a4cb9ad2f Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 08:31:51 -0400 Subject: [PATCH 65/97] Rebind current capture provenance when reusing lambda analysis --- design.md | 11 +++ .../lambda_solved/analysis_cache.zig | 68 ++++++++++++++++++- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/design.md b/design.md index b264cccf508..01dc3ba8f07 100644 --- a/design.md +++ b/design.md @@ -27375,6 +27375,17 @@ All type structure, named definitions, backing authority, dispatch metadata, and ABI boundaries remain in the identity. This whole-analysis boundary is independent of finer-grained specialization body caching. +Local checked binder and capture provenance is carried through Lambda Solved, +but is not a constraint or digest input to that solver. Analysis identity keeps +the local table positions, symbols, and types; restoration rebinds each +validated capture's binder, post-check capture id, checked capture id, and +capture kind from that exact local in the current lifted program. In particular, +a recursive-binding capture retains its current checked module and root, not +the module identity from an earlier source version. Persisted +provenance never replaces the current producer's identities. This exclusion +does not apply to callable symbols or to the checked occurrence relationships +used by type solving. + Completed Lambda Solved records contain only the type graph reachable from output annotations. Serialization resolves redirects and assigns compact local ids while preserving cycles and sharing. Discarded unification rows, unreachable diff --git a/src/postcheck/lambda_solved/analysis_cache.zig b/src/postcheck/lambda_solved/analysis_cache.zig index d9377bd8d45..a7f93ca3ae1 100644 --- a/src/postcheck/lambda_solved/analysis_cache.zig +++ b/src/postcheck/lambda_solved/analysis_cache.zig @@ -49,7 +49,7 @@ pub fn identity(allocator: Allocator, input: Lifted.ProgramView) Allocator.Error .occurrence_ids = &occurrences, .pending_occurrences = try gpa.alloc(Mono.NamedType, input.types.named_nodes.len), }; - out.bytes("roc.lambda-analysis-input.v2"); + out.bytes("roc.lambda-analysis-input.v3"); inline for (columns) |field| { out.value(@TypeOf(@field(input, field)), @field(input, field)); } @@ -86,6 +86,11 @@ pub fn traceIdentity(input: Lifted.ProgramView) void { out.value(@TypeOf(@field(input.types, field)), @field(input.types, field)); std.debug.print("lambda-input types.{s}={x}\n", .{ field, out.finish() }); } + inline for (@typeInfo(Lifted.Local).@"struct".field_names) |field| { + var out = Fingerprint{ .names = input.names }; + for (input.locals) |local| out.value(@TypeOf(@field(local, field)), @field(local, field)); + std.debug.print("lambda-input locals.{s}={x}\n", .{ field, out.finish() }); + } var fns = Fingerprint{ .names = input.names }; for (input.fns) |f| { fns.value(@TypeOf(f.symbol), f.symbol); @@ -163,6 +168,14 @@ const Fingerprint = struct { } } fn value(self: *Fingerprint, comptime T: type, v: T) void { + if (T == Lifted.Local) { + // Checked capture provenance is copied to output, not consumed by + // solving. Restore obtains it from the matching current local. + self.value(@TypeOf(v.id), v.id); + self.value(@TypeOf(v.symbol), v.symbol); + self.value(@TypeOf(v.ty), v.ty); + return; + } if (T == Mono.TypeId) if (self.types != null) { const slot = &self.type_ids[@backingInt(v)]; if (slot.* == std.math.maxInt(u32)) { @@ -560,6 +573,15 @@ pub fn restore(allocator: Allocator, lifted: *Lifted.Program, id: *const Identit error.InvalidSummary => return null, error.OutOfMemory => return error.OutOfMemory, }; + // The validated input binds every capture to the same current local. Its + // checked provenance belongs to this build, even when solving is reused. + for (@constCast(payload.types.captures)) |*capture| { + const local = lifted.getLocal(capture.local); + capture.binder = local.identity.binder(); + capture.capture_id = local.identity.captureId(); + capture.checked_capture_id = local.identity.checkedCaptureId(); + capture.capture_kind = local.capture_kind; + } // This producer's completed graph has no private solver state. Ownership // flags are meaningful only while solving and start empty on immutable reuse. const flags = try arena.allocator().alloc(bool, payload.types.vars.len); @@ -871,3 +893,47 @@ test "lambda analysis preserves checked occurrence equality and nominal ownershi try std.testing.expect(!std.mem.eql(u8, &a.key, &b.key)); try std.testing.expect(!std.mem.eql(u8, &a.key, &c.key)); } + +fn capturedInput(allocator: Allocator, binder: ?u32) !Lifted.Program { + var input = emptyInput(allocator); + errdefer input.deinit(); + const ty = try input.types.internPrimitive(&input.names, .str); + const local = try input.addLocalWithBinder(@fromBackingInt(1), ty, if (binder) |id| @fromBackingInt(id) else null); + var local_data = input.getLocal(local); + local_data.capture_kind = if (binder) |id| .{ .recursive_binding = .{ .module = .{ .bytes = @splat(@intCast(id)) }, .root = @fromBackingInt(id) } } else .lexical; + input.locals.set(@backingInt(local), local_data); + const captures = try input.addTypedLocalSpan(&.{.{ .local = local, .ty = ty }}); + const body = try input.addExpr(.{ .ty = ty, .data = .{ .local = local } }); + _ = try input.addFn(.{ .symbol = @fromBackingInt(0), .args = .empty(), .captures = captures, .body = .{ .roc = body }, .ret = ty }); + return input; +} + +test "lambda analysis restores current checked capture provenance" { + const gpa = std.testing.allocator; + var first = try @import("solve.zig").run(gpa, try capturedInput(gpa, 3)); + defer first.deinit(); + const old_id = try identity(gpa, first.lifted.view()); + defer old_id.deinit(gpa); + const bytes = (try encode(gpa, &first, &old_id)).?; + defer gpa.free(bytes); + for ([_]?u32{ 9, null }) |binder| { + var current = try capturedInput(gpa, binder); + var owned = true; + defer if (owned) current.deinit(); + const current_id = try identity(gpa, current.view()); + defer current_id.deinit(gpa); + try std.testing.expectEqual(old_id.key, current_id.key); + current.types.freeze(); + var result = (try restore(gpa, ¤t, ¤t_id, bytes)).?; + owned = false; + defer result.deinit(); + try std.testing.expect(result.types.captures.items.len != 0); + for (result.types.captures.items) |capture| { + const local = result.lifted.getLocal(capture.local); + try std.testing.expectEqual(local.identity.binder(), capture.binder); + try std.testing.expectEqual(local.identity.captureId(), capture.capture_id); + try std.testing.expectEqual(local.identity.checkedCaptureId(), capture.checked_capture_id); + try std.testing.expectEqual(local.capture_kind, capture.capture_kind); + } + } +} From ffc346fd4947328dc94b8dece677ed6df2b1c5c3 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 08:47:38 -0400 Subject: [PATCH 66/97] Skip unadvertised native cache namespaces within each snapshot --- design.md | 12 +++- src/base/retained_cache.zig | 2 +- src/cli/pack_store.zig | 106 ++++++++++++++++++++++++++++++++++-- 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/design.md b/design.md index 01dc3ba8f07..9fe64977a68 100644 --- a/design.md +++ b/design.md @@ -1052,7 +1052,17 @@ record carries the explicit provider identity followed by a `ROFFER03` selection record naming an immutable component digest and root ordinal, exact procedure identity, specialization key, compile-time reads and ARC interface. Provider visibility and rank come from the checked program, root first. Lookup probes -one key bucket per origin, filters by provider and sorts by rank and digest. +one key bucket per advertised origin, filters by provider and sorts by rank +and digest. Before publishing offers, a producer publishes and pins one +immutable advertisement for that procedure domain and origin. A reader +validates and retains it once per cache snapshot; an unadvertised namespace +is an optional-cache miss without per-key catalog probes. Explicit index +refresh invalidates missing advertisements after publication. An accepted +advertisement remains valid through its retained generation lease, including +when publication reuses it without rereading it. It establishes no claim +about an individual offer: all offer, dependency, payload, source-eligibility, +and corruption checks remain required. Missing or corrupt advertisements +only remove optional reuse, including when maintenance has retired one. Concurrent writers publish independent immutable records. The exact native splice graph is partitioned into strongly connected diff --git a/src/base/retained_cache.zig b/src/base/retained_cache.zig index 29b4983225d..a1b15daaaf1 100644 --- a/src/base/retained_cache.zig +++ b/src/base/retained_cache.zig @@ -4,7 +4,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; /// The persisted format boundary validated by the owning native loader. -pub const Kind = enum { native_payload_v1, native_component_v1, native_offer_v3 }; +pub const Kind = enum { native_payload_v1, native_component_v1, native_offer_v3, native_offer_domain_v1 }; /// Validation work and retained storage, separate from publication work. pub const Metrics = struct { diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 3e389ef6f43..3870b856c43 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -102,7 +102,13 @@ pub const Store = struct { return result; } - const IndexedKind = enum { payload, metadata, offer, completed, interface }; + const IndexedKind = enum { payload, metadata, offer, offer_domain, completed, interface }; + + const offer_domain_bytes = "ROCNOFF1"; + + fn offerDomainIdentity(self: *const Store, origin: compile.BuildEnv.PackOrigin) compile.artifacts.Identity { + return self.indexedIdentity(origin, @splat(0), Index.digest(offer_domain_bytes), .offer_domain); + } fn indexedIdentity(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, key: [32]u8, kind: IndexedKind) compile.artifacts.Identity { var id = self.packIdentity(origin, module, key); @@ -258,6 +264,18 @@ pub const Store = struct { for (pins.items) |*artifact| artifact.deinit(); pins.deinit(allocator); } + // Advertise before publishing any offer, and retain that advertisement + // throughout publication. Concurrent producers write identical bytes. + var advertised: [@typeInfo(compile.BuildEnv.PackOrigin).@"enum".field_names.len]bool = @splat(false); + for (specs, 0..) |_, i| { + const provider_origin = if (providers) |locations| locations[i].origin else origin; + const slot = @backingInt(provider_origin); + if (advertised[slot]) continue; + if (retained == null or !retained.?.retainsOfferAdvertisement(self, provider_origin)) { + _ = try self.publishIndexedHeld(self.offerDomainIdentity(provider_origin), offer_domain_bytes, stats, &pins); + } + advertised[slot] = true; + } var published = std.AutoHashMap([32]u8, void).init(allocator); defer published.deinit(); for (union_indices) |index| { @@ -1016,7 +1034,7 @@ pub const LoadedPacks = struct { records: std.AutoArrayHashMapUnmanaged([32]u8, *InterfaceRecord) = .empty, }; const Provider = struct { store: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8 }; - const IndexRoot = struct { store: *const Store, origin: compile.BuildEnv.PackOrigin }; + const IndexRoot = struct { store: *const Store, origin: compile.BuildEnv.PackOrigin, advertised: ?bool = null, advertisement: ?*const base.retained_cache.Record = null }; interface_mutex: std.Io.Mutex = .init, /// Worker reads update these atomically; reported after joining. interface_bytes_loaded: u64 = 0, @@ -1190,12 +1208,35 @@ pub const LoadedPacks = struct { self.refreshIndex(); } + fn loadOfferAdvertisement(self: *LoadedPacks, root: *IndexRoot) Allocator.Error!bool { + const store = root.store; + const owner = try self.recordOwner(store.roc_ctx.std_io); + const record = store.acquireRetained(owner, try self.retentionBatch(store), store.offerDomainIdentity(root.origin).indexedKey(), .native_offer_domain_v1) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Unavailable, error.Corrupt => return false, + }; + if (!std.mem.eql(u8, record.bytes, Store.offer_domain_bytes)) return false; + root.advertisement = record; + return true; + } + + fn retainsOfferAdvertisement(self: *const LoadedPacks, store: *const Store, origin: compile.BuildEnv.PackOrigin) bool { + const storage = store.retainedIdentity(store.offerDomainIdentity(origin).indexedKey()); + for (self.index_roots.items) |root| if (root.advertisement) |record| { + if (std.mem.eql(u8, &record.storage_identity, &storage)) return true; + }; + return false; + } + /// Start a fresh optional-cache snapshot after publication. This also /// invalidates misses when an existing provider acquired new offers. /// Other processes' later publications become visible at the next refresh /// or in the next compilation; a miss never promises code availability. pub fn refreshIndex(self: *LoadedPacks) void { self.negative.clearRetainingCapacity(); + for (self.index_roots.items) |*root| if (root.advertisement == null) { + root.advertised = null; + }; var families = self.interface_families.valueIterator(); while (families.next()) |family| { for (family.*.records.values()) |record| if (record.payload == null) { @@ -1440,8 +1481,10 @@ pub const LoadedPacks = struct { var candidates = std.ArrayList(Candidate).empty; defer candidates.deinit(allocator); var cacheable = true; - for (self.index_roots.items) |root| { + for (self.index_roots.items) |*root| { profile.enter(.buckets); + if (root.advertised == null) root.advertised = try self.loadOfferAdvertisement(root); + if (!root.advertised.?) continue; self.index_stats.bucket_opens += 1; const group = root.store.indexedIdentity(root.origin, key, @splat(0), .offer); const keys = (try self.retentionBatch(root.store)).groupKeys(allocator, group) catch |err| switch (err) { @@ -2982,7 +3025,7 @@ test "indexed object cache defers discovery until a completed-output probe misse try std.testing.expectEqual(@as(u64, 1), packs.index_stats.payload_opens); } -test "indexed object cache misses probe keys once across many providers and refresh on publication" { +test "unadvertised object namespaces skip bucket probes and refresh on publication" { const F = IndexFixture; var fixture: F = undefined; try fixture.init(); @@ -2991,14 +3034,14 @@ test "indexed object cache misses probe keys once across many providers and refr defer packs.deinit(); for (1..128) |i| try fixture.store.loadIdentity(std.testing.io, &packs, .local, @splat(@intCast(i))); for (0..100) |_| try std.testing.expect(packs.specCacheLookup().lookup(@splat(250), .dev_code, &.{}) == null); - try std.testing.expectEqual(@as(u64, 1), packs.index_stats.bucket_opens); + try std.testing.expectEqual(@as(u64, 0), packs.index_stats.bucket_opens); try std.testing.expectEqual(@as(u64, 99), packs.index_stats.negative_hits); try fixture.publish(&.{F.excerpt(1, "new code")}, &.{F.spec(250, 0)}, &.{}); // An existing provider's new offer invalidates the snapshot, even though // the provider set itself did not change. try packs.offerIndexed(&fixture.store, .local, F.identity); _ = packs.specCacheLookup().lookup(@splat(250), .dev_code, &.{}) orelse return error.TestUnexpectedResult; - try std.testing.expectEqual(@as(u64, 2), packs.index_stats.bucket_opens); + try std.testing.expectEqual(@as(u64, 1), packs.index_stats.bucket_opens); } test "indexed object cache shares payloads and verifies immutable records before skipping writes" { @@ -3417,6 +3460,7 @@ test "native publication interruption at each catalog boundary exposes only comp fixture.store.indexedIdentity(.local, @splat(0), components[0], .metadata), fixture.store.indexedIdentity(.local, specs[0].key, Index.digest(offer), .offer), }; + _ = try fixture.store.publishIndexed(fixture.store.offerDomainIdentity(.local), Store.offer_domain_bytes); for (identities, [_][]const u8{ payload, metadata, offer }, 0..) |id, bytes, index| { _ = try fixture.store.publishIndexed(id, bytes); var packs = try fixture.loader(); @@ -3615,3 +3659,53 @@ test "interface requests share same-key reads while independent workers publish // Every request is read from storage once, even when all lanes ask for it. try std.testing.expectEqual(@as(u64, (8 * 32 + 1) * (LoadedPacks.interface_header_size + 32)), reader.interface_bytes_loaded); } + +test "native offer advertisements reject corruption and preserve snapshot refresh" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + try fixture.publish(&.{F.excerpt(1, "code")}, &.{F.spec(1, 0)}, &.{}); + { + var marker = (try fixture.store.native.acquire(fixture.store.offerDomainIdentity(.local), fixture.store.read_timestamp)).?; + defer marker.deinit(); + try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = marker.path, .data = "invalid advertisement" }); + } + var packs = try fixture.loader(); + defer packs.deinit(); + try std.testing.expect(packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 0), packs.index_stats.bucket_opens); + try fixture.publish(&.{F.excerpt(1, "code")}, &.{F.spec(1, 0)}, &.{}); + try packs.offerIndexed(&fixture.store, .local, F.identity); + _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u64, 1), packs.index_stats.bucket_opens); + // The accepted advertisement's lease remains valid even if the name is + // unlinked externally. It is retained together with the accepted offers. + { + var marker = (try fixture.store.native.acquire(fixture.store.offerDomainIdentity(.local), fixture.store.read_timestamp)).?; + defer marker.deinit(); + try std.Io.Dir.cwd().deleteFile(std.testing.io, marker.path); + } + packs.refreshIndex(); + try std.testing.expect(packs.retainsOfferAdvertisement(&fixture.store, .local)); + _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; +} + +test "empty package namespace avoids per-key searches beside populated local offers" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + try fixture.publish(&.{F.excerpt(1, "local")}, &.{F.spec(1, 0)}, &.{}); + var packs = try fixture.loader(); + defer packs.deinit(); + const provider: compile.BuildEnv.PackPlacement = .{ .origin = .pkg, .identity = @splat(99) }; + try fixture.store.loadIdentity(std.testing.io, &packs, provider.origin, provider.identity); + _ = packs.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + for (2..11) |key| try std.testing.expect(packs.specCacheLookup().lookup(@splat(@intCast(key)), .dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 10), packs.index_stats.bucket_opens); + try fixture.store.writeIndexedForProviders(.local, F.identity, &.{ .arena = undefined, .excerpts = &.{F.excerpt(2, "package")} }, &.{F.spec(2, 0)}, &.{provider}); + try packs.offerIndexed(&fixture.store, provider.origin, provider.identity); + _ = packs.specCacheLookup().lookup(@splat(2), .dev_code, &.{}) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u64, 12), packs.index_stats.bucket_opens); +} From 60830bd93b9e7b19bb69494645a933d1cd923ca8 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 08:59:55 -0400 Subject: [PATCH 67/97] Decode retained catalog records in place and reuse shard paths --- design.md | 4 ++++ src/compile/artifact_store.zig | 4 +++- src/compile/cache_index.zig | 19 +++++++++++++------ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/design.md b/design.md index 9fe64977a68..941665e825a 100644 --- a/design.md +++ b/design.md @@ -27325,6 +27325,10 @@ external commits synchronize retained mapping contents through the I/O provider; a failed refresh discards the mapping. Record bounds and checksums remain mandatory. The mapping never substitutes for shard ownership or generation leases. Counters distinguish mapped node reads from positional reads. +Mapped node decoding borrows the checked record bytes while shard ownership +holds; no pointer into that mapping escapes decoding. Payload acquisition opens +the file relative to the already owned shard directory, with the same generation +lease and size validation as absolute-path acquisition. Lambda Solved retains completed erased-callable reachability markings only for diff --git a/src/compile/artifact_store.zig b/src/compile/artifact_store.zig index 424a84152e9..050023bf1b4 100644 --- a/src/compile/artifact_store.zig +++ b/src/compile/artifact_store.zig @@ -322,7 +322,9 @@ pub const Store = struct { else => return err, }; errdefer lease.close(self.io); - const file = Dir.cwd().openFile(self.io, path, .{}) catch |err| { + var data_buffer: [140]u8 = undefined; + const relative_data = std.fmt.bufPrint(&data_buffer, "{s}.data", .{stem}) catch return error.PathTooLong; + const file = shard.dir.openFile(self.io, relative_data, .{}) catch |err| { return switch (err) { error.FileNotFound => blk: { self.allocator.free(path); diff --git a/src/compile/cache_index.zig b/src/compile/cache_index.zig index 0aaec9821bb..5e7d1836b66 100644 --- a/src/compile/cache_index.zig +++ b/src/compile/cache_index.zig @@ -182,6 +182,13 @@ pub const Index = struct { } fn readRecord(self: *Index, id: RecordId) Error!Record { + var storage: Record = undefined; + return (try self.readRecordRef(id, &storage)).*; + } + + /// Borrow mapped bytes only while the caller owns the shard. Decoding + /// validates those bytes directly, without first copying the whole record. + fn readRecordRef(self: *Index, id: RecordId, storage: *Record) Error!*const Record { if (id > std.math.maxInt(u64) / record_size) return error.CorruptCatalog; if (id != 0 and self.read_cache != null) { if (self.mapped) |mapped| { @@ -195,14 +202,13 @@ pub const Index = struct { const offset = std.math.cast(usize, id * record_size) orelse return error.CorruptCatalog; if (offset > memory.len or memory.len - offset < record_size) return error.CorruptCatalog; self.counters.mapped_reads += 1; - return memory[offset..][0..record_size].*; + return memory[offset..][0..record_size]; } - var bytes: Record = undefined; self.counters.reads += 1; self.counters.read_bytes += record_size; - const len = self.file.readPositionalAll(self.io, &bytes, id * record_size) catch return error.IoFailure; - if (len != bytes.len) return error.CorruptCatalog; - return bytes; + const len = self.file.readPositionalAll(self.io, storage, id * record_size) catch return error.IoFailure; + if (len != storage.len) return error.CorruptCatalog; + return storage; } fn writeRecord(self: *Index, id: RecordId, bytes: Record) Error!void { @@ -225,7 +231,8 @@ pub const Index = struct { if (self.read_cache) |cache| { const slot = id % cache.ids.len; if (cache.ids[slot] != id) { - cache.nodes[slot] = try Node.decode(&try self.readRecord(id), self.header.next); + var bytes: Record = undefined; + cache.nodes[slot] = try Node.decode(try self.readRecordRef(id, &bytes), self.header.next); cache.ids[slot] = id; } return &cache.nodes[slot]; From b0f992f1c8ffa1dc884ffa10baa1525c5f18ba7d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 09:19:58 -0400 Subject: [PATCH 68/97] Acquire native dependency records in bounded shard groups --- design.md | 12 ++++ src/cli/pack_store.zig | 123 ++++++++++++++++++++++++++++++++- src/compile/artifact_store.zig | 93 +++++++++++++++++++++++++ 3 files changed, 226 insertions(+), 2 deletions(-) diff --git a/design.md b/design.md index 941665e825a..c7e0bb8c8b4 100644 --- a/design.md +++ b/design.md @@ -27330,6 +27330,18 @@ holds; no pointer into that mapping escapes decoding. Payload acquisition opens the file relative to the already owned shard directory, with the same generation lease and size validation as absolute-path acquisition. +Native component acquisition may group the exact content addresses supplied by +validated dependency records. It groups acquisitions by physical shard while +retaining the input-to-result mapping. Each shard ownership interval acquires at +most one retention transaction's existing record bound; file reading, hashing, +and decoding happen after releasing shard ownership. Every acquired artifact +still retains its individual generation lease and validated content identity. +Metadata acquisition follows the explicit dependency frontier. Topological and +source-eligibility validation still precede payload consumption. Payload groups +contain only dependencies of the selected component, never unrelated historical +offers. Missing or corrupt members reject the optional offer; allocation failure +propagates and releases every acquisition not transferred to a record owner. + Lambda Solved retains completed erased-callable reachability markings only for the exact graph mutation epoch in which a walk made no changes. A later query diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 3870b856c43..b516e97231f 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -385,10 +385,17 @@ pub const Store = struct { }; fn acquireRetained(self: *const Store, owner: *base.retained_cache.Owner, batch: *compile.artifacts.RetentionBatch, key: [64]u8, kind: base.retained_cache.Kind) (Allocator.Error || error{ Unavailable, Corrupt })!*const base.retained_cache.Record { - var acquired = (batch.acquireKey(key, self.read_timestamp) catch |err| switch (err) { + const acquired = (batch.acquireKey(key, self.read_timestamp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, else => return error.Unavailable, }) orelse return error.Unavailable; + return self.retainAcquired(owner, acquired, key, kind); + } + + /// Consumes an acquisition on success or failure, preserving its exact + /// generation lease until the validated record owner releases it. + fn retainAcquired(self: *const Store, owner: *base.retained_cache.Owner, value: compile.artifacts.Artifact, key: [64]u8, kind: base.retained_cache.Kind) (Allocator.Error || error{ Unavailable, Corrupt })!*const base.retained_cache.Record { + var acquired = value; errdefer acquired.deinit(); const bytes = acquired.read(owner.allocator) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, @@ -1269,7 +1276,6 @@ pub const LoadedPacks = struct { fn loadMetadata(self: *LoadedPacks, store: *const Store, hash: [32]u8) Allocator.Error!?*IndexedMetadata { if (self.metadata.get(hash)) |existing| return existing; - const allocator = self.allocator; self.index_stats.metadata_opens += 1; const owner = try self.recordOwner(store.roc_ctx.std_io); const record = store.acquireRetained(owner, try self.retentionBatch(store), store.indexedIdentity(.local, @splat(0), hash, .metadata).indexedKey(), .native_component_v1) catch |err| switch (err) { @@ -1283,6 +1289,11 @@ pub const LoadedPacks = struct { return null; }, }; + return self.adoptMetadata(owner, hash, record); + } + + fn adoptMetadata(self: *LoadedPacks, owner: *base.retained_cache.Owner, hash: [32]u8, record: *const base.retained_cache.Record) Allocator.Error!?*IndexedMetadata { + const allocator = self.allocator; self.bytes_loaded +|= record.bytes.len; self.index_stats.index_bytes += record.bytes.len; self.index_stats.metadata_bytes += record.bytes.len; @@ -1325,6 +1336,82 @@ pub const LoadedPacks = struct { return entry; } + /// The graph producer supplies exact content addresses. Grouping changes + /// only catalog acquisition; every payload keeps its own hash validation, + /// storage generation, and lifetime. + fn acquireRecords(self: *LoadedPacks, store: *const Store, hashes: []const [32]u8, comptime kind: Store.IndexedKind) Allocator.Error!?[]?*const base.retained_cache.Record { + const allocator = self.allocator; + const keys = try allocator.alloc([64]u8, hashes.len); + defer allocator.free(keys); + for (hashes, keys) |hash, *key| key.* = store.indexedIdentity(.local, @splat(0), hash, kind).indexedKey(); + const batch = try self.retentionBatch(store); + const acquired = batch.acquireKeys(allocator, keys, store.read_timestamp) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }; + defer allocator.free(acquired); + defer for (acquired) |*item| if (item.*) |*artifact| artifact.deinit(); + const records = try allocator.alloc(?*const base.retained_cache.Record, hashes.len); + errdefer allocator.free(records); + @memset(records, null); + const owner = try self.recordOwner(store.roc_ctx.std_io); + for (acquired, keys, records) |*item, key, *record| { + if (kind == .metadata) self.index_stats.metadata_opens += 1 else self.index_stats.payload_opens += 1; + const value = item.* orelse { + self.index_stats.missing += 1; + continue; + }; + item.* = null; + record.* = store.retainAcquired(owner, value, key, if (kind == .metadata) .native_component_v1 else .native_payload_v1) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Corrupt => blk: { + self.index_stats.corrupt += 1; + break :blk null; + }, + error.Unavailable => blk: { + self.index_stats.missing += 1; + break :blk null; + }, + }; + } + return records; + } + + fn acquireGraphMetadata(self: *LoadedPacks, store: *const Store, root: *IndexedMetadata) Allocator.Error!bool { + var entries: std.ArrayList(*IndexedMetadata) = .empty; + defer entries.deinit(self.allocator); + var hashes: std.ArrayList([32]u8) = .empty; + defer hashes.deinit(self.allocator); + var seen: std.AutoHashMapUnmanaged([32]u8, void) = .empty; + defer seen.deinit(self.allocator); + try entries.append(self.allocator, root); + try seen.put(self.allocator, root.record.digest, {}); + var next: usize = 0; + while (next < entries.items.len) { + hashes.clearRetainingCapacity(); + while (next < entries.items.len) : (next += 1) { + const entry = entries.items[next]; + if (entry.start != null) continue; + for (entry.edges) |edge| { + const hash = edge.target.component orelse continue; + if ((try seen.getOrPut(self.allocator, hash)).found_existing) continue; + if (self.metadata.get(hash)) |known| { + try entries.append(self.allocator, known); + } else try hashes.append(self.allocator, hash); + } + } + if (hashes.items.len == 0) break; + const records = try self.acquireRecords(store, hashes.items, .metadata) orelse return false; + defer self.allocator.free(records); + const owner = try self.recordOwner(store.roc_ctx.std_io); + for (hashes.items, records) |hash, record| { + const entry = try self.adoptMetadata(owner, hash, record orelse return false) orelse return false; + try entries.append(self.allocator, entry); + } + } + return true; + } + /// Append each immutable component once to a coordinator-owned view. Existing /// indices never change. Symbolic bindings remain scoped to their producing /// component, so unrelated historical definitions cannot capture references. @@ -1389,6 +1476,7 @@ pub const LoadedPacks = struct { fn ensureGraph(self: *LoadedPacks, store: *const Store, root: *IndexedMetadata) Allocator.Error!bool { if (root.start != null) return true; + if (!try self.acquireGraphMetadata(store, root)) return false; const Frame = struct { entry: *IndexedMetadata, next: usize = 0 }; var stack = std.ArrayList(Frame).empty; defer stack.deinit(self.allocator); @@ -1426,6 +1514,7 @@ pub const LoadedPacks = struct { fn materializeComponent(self: *LoadedPacks, store: *const Store, root: *IndexedMetadata) Allocator.Error!bool { if (root.materialized) return true; + if (!try self.acquireGraphPayloads(store, root)) return false; const Frame = struct { entry: *IndexedMetadata, next: usize = 0 }; var stack = std.ArrayList(Frame).empty; defer stack.deinit(self.allocator); @@ -1461,6 +1550,36 @@ pub const LoadedPacks = struct { return true; } + fn acquireGraphPayloads(self: *LoadedPacks, store: *const Store, root: *IndexedMetadata) Allocator.Error!bool { + var entries: std.ArrayList(*IndexedMetadata) = .empty; + defer entries.deinit(self.allocator); + var seen_entries: std.AutoHashMapUnmanaged(*IndexedMetadata, void) = .empty; + defer seen_entries.deinit(self.allocator); + var hashes: std.AutoArrayHashMapUnmanaged([32]u8, void) = .empty; + defer hashes.deinit(self.allocator); + try entries.append(self.allocator, root); + while (entries.pop()) |entry| { + if (entry.materialized or (try seen_entries.getOrPut(self.allocator, entry)).found_existing) continue; + for (entry.hashes) |hash| if (!self.payloads.contains(hash)) { + try hashes.put(self.allocator, hash, {}); + }; + for (entry.edges) |edge| if (edge.target.component) |hash| { + try entries.append(self.allocator, self.metadata.get(hash).?); + }; + } + if (hashes.count() == 0) return true; + const records = try self.acquireRecords(store, hashes.keys(), .payload) orelse return false; + defer self.allocator.free(records); + for (hashes.keys(), records) |hash, maybe_record| { + const record = maybe_record orelse return false; + try self.payloads.put(self.allocator, hash, record); + self.bytes_loaded +|= record.bytes.len; + self.index_stats.payload_bytes += record.bytes.len; + self.index_stats.payload_files += 1; + } + return true; + } + /// Probe direct key buckets, then only candidates belonging to visible /// providers. Provider rank remains root-first, independent of disk order. fn findIndexed(self: *LoadedPacks, key: [32]u8, takes: Common.SpecCacheTakes, modules: Common.ProgramModules) Allocator.Error!IndexedResult { diff --git a/src/compile/artifact_store.zig b/src/compile/artifact_store.zig index 050023bf1b4..0938b710915 100644 --- a/src/compile/artifact_store.zig +++ b/src/compile/artifact_store.zig @@ -451,6 +451,54 @@ pub const RetentionBatch = struct { return artifact; } + /// Acquire a producer-supplied group under bounded shard ownership. Each + /// result still owns its individual generation lease and open payload. + /// The output has the same order as keys; absence remains per-record. + pub fn acquireKeys(self: *RetentionBatch, allocator: Allocator, keys: []const catalog.Key, timestamp: i64) Error![]?Artifact { + const result = try allocator.alloc(?Artifact, keys.len); + errdefer allocator.free(result); + @memset(result, null); + errdefer for (result) |*item| if (item.*) |*artifact| artifact.deinit(); + const order = try allocator.alloc(usize, keys.len); + defer allocator.free(order); + var counts: [shard_count]usize = @splat(0); + for (keys) |key| counts[Store.shardFor(key)] += 1; + var starts: [shard_count + 1]usize = @splat(0); + for (counts, 0..) |count, i| starts[i + 1] = starts[i] + count; + var cursors = starts[0..shard_count].*; + for (keys, 0..) |key, i| { + const number = Store.shardFor(key); + order[cursors[number]] = i; + cursors[number] += 1; + } + for (0..shard_count) |raw_number| { + const number: u4 = @intCast(raw_number); + var next = starts[number]; + const end = starts[raw_number + 1]; + while (next < end) { + if (self.counts[number] == catalog.max_changes) try self.flushShard(number); + const shard = self.acquireShard(number) catch |err| switch (err) { + error.Missing => break, + else => return err, + }; + defer shard.lock.unlock(self.store.io); + // Reuse the retention transaction's existing bound, also when + // every item is absent or already has a current timestamp. + const limit = next + @min(end - next, catalog.max_changes - self.counts[number]); + while (next < limit) : (next += 1) { + const index = order[next]; + var observation: ?Observation = null; + result[index] = try self.store.acquireFromShard(keys[index], timestamp, &observation, shard); + if (observation) |value| { + self.pending[number][self.counts[number]] = value; + self.counts[number] += 1; + } + } + } + } + return result; + } + fn acquireShard(self: *RetentionBatch, number: u4) Error!*Shard { if (self.shards[number]) |*shard| { shard.lock.lock(self.store.io, .exclusive) catch return error.IoFailure; @@ -1326,3 +1374,48 @@ test "retention batches discard observations of a retired generation" { try std.testing.expectEqual(20, (try currentMetadata(&tx, key, ptr.value)).used); } } + +test "grouped acquisitions preserve order absence duplicates and generation leases" { + const a = std.testing.allocator; + const io = std.testing.io; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const path = try tmp.dir.realPathFileAlloc(io, ".", a); + defer a.free(path); + var store = try Store.init(a, io, path); + defer store.deinit(); + var identities: [80]Identity = undefined; + for (&identities, 0..) |*id, i| { + id.* = .{ .namespace = @splat(7), .family = .local_pack, .group = @splat(if (i < 64) 1 else @intCast(i)), .key = @splat(@intCast(i)) }; + try store.write(id.*, &.{@intCast(i)}, 1); + } + var keys: [83]catalog.Key = undefined; + for (identities, 0..) |id, i| keys[79 - i] = id.indexedKey(); + keys[80] = identities[0].indexedKey(); + var missing = identities[0]; + missing.key = @splat(255); + keys[81] = missing.indexedKey(); + keys[82] = identities[79].indexedKey(); + var batch = try RetentionBatch.init(&store); + defer batch.deinit(); + const held = try batch.acquireKeys(a, &keys, 100); + defer a.free(held); + defer for (held) |*item| if (item.*) |*artifact| artifact.deinit(); + try std.testing.expect(held[81] == null); + for (held, 0..) |item, i| { + if (i == 81) continue; + const bytes = try item.?.read(a); + defer a.free(bytes); + const expected: u8 = if (i == 80) 0 else if (i == 82) 79 else @intCast(79 - i); + try std.testing.expectEqualSlices(u8, &.{expected}, bytes); + } + try batch.flush(); + try store.write(identities[0], "replacement", 200); + const old = try held[80].?.read(a); + defer a.free(old); + try std.testing.expectEqualSlices(u8, &.{0}, old); + const current = (try store.read(a, identities[0], 300)).?; + defer a.free(current); + try std.testing.expectEqualStrings("replacement", current); + try std.testing.expectError(error.Busy, store.invalidate(identities[79])); +} From a78b1906a0604e9e01dc997abe60d7deb38127ff Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 15:25:47 -0400 Subject: [PATCH 69/97] Satisfy the static checks: exhaustive switches, explicit error sets, audited vocabulary, and real test ids --- design.md | 480 +++++++++--------- src/backend/dev/CodeExcerpt.zig | 2 +- src/check/const_store.zig | 4 +- src/check/inference_output.zig | 4 +- src/check/value_identity.zig | 66 ++- src/cli/main.zig | 4 +- src/cli/pack_index.zig | 2 +- src/cli/pack_store.zig | 13 +- src/compile/coordinator.zig | 8 +- src/compile/discovery_roots.zig | 2 + src/compile/test/demand_driven_ctfe_test.zig | 7 +- src/lir/pack_program.zig | 2 +- src/postcheck/inhabitation.zig | 8 +- .../lambda_solved/analysis_cache.zig | 89 +++- src/postcheck/lambda_solved/solve.zig | 74 +-- src/postcheck/monotype/interface_summary.zig | 103 +++- src/postcheck/monotype/lower.zig | 160 ++++-- src/postcheck/monotype/solve.zig | 8 +- src/postcheck/monotype/type.zig | 2 +- src/postcheck/monotype_lifted/lift.zig | 11 +- src/postcheck/proc_identity.zig | 6 +- src/postcheck/proofs.zig | 82 +-- 22 files changed, 682 insertions(+), 455 deletions(-) diff --git a/design.md b/design.md index ab9e5ccdd17..b4c1a9a21fa 100644 --- a/design.md +++ b/design.md @@ -637,7 +637,7 @@ one identity across apps. Procedure type identity depends on the infinite type, never its allocation sharing or strongly connected component boundaries. The identity reducer retains -completed canonical node descriptions (non-edge content and ordered child +completed normalized node descriptions (non-edge content and ordered child identities). Before naming a new recursive component, it checks every matching head against those descriptions with a complete bisimulation proof. A partial recursive copy pointing both into itself and into an earlier component takes @@ -677,9 +677,9 @@ inputs and cannot serve CTFE. Source-location, transitive code/data closure, compile-time reads and demand/observation eligibility checks still apply. Dev procedure packs and discovery records use `proc--dev` as their -logical group scope in the indexed artifact store. Program outputs use +logical group scope in the indexed entry store. Program outputs use `-dev`, and optimized packs retain their exact target and optimization -level. Both publication and lookup use the procedure scope. A compatible runtime build +level. Both commit and lookup use the procedure scope. A compatible runtime build owns its target-specific Store while borrowing CTFE's heap-owned LoadedPacks for both specialization lookup and splicing; that owner outlives lowering and emission. Shared body elision still requires the exact same provider capability. @@ -693,7 +693,7 @@ layout ownership, CPU, static-data and runtime-context checks are unchanged. The earliest reusable result of an open specialization request is its settled interface relation, before the callee's body reservation and machine-code -lookup. Closed interfaces already have a complete checked root and publish no +lookup. Closed interfaces already have a complete checked root and commit no persistent record. Checking still supplies the template, dispatch relations and evidence; the consumer still instantiates its own request and substitution cells. A producer records the relation only after its recursive component has reached @@ -705,14 +705,14 @@ An interface summary is either an explicit unchanged result or immutable constraints over locally numbered nodes and field-kind cells. A settled relation may still contain checked variables, row extensions, recursive links, field-kind cells, and provisional field views: these are relation data, not completed runtime -types. Compiler placeholders, redirects and lazy solver leaves are not publishable. +types. Compiler placeholders, redirects and lazy solver leaves are not committable. The capture producer supplies all open-node witnesses, related groups, generated iterator provenance, and field-presence evidence explicitly. Build and test runtime lowering share the checked session's interface provider with compile-time evaluation. Test roots retain source-body code provision; interface replay does not grant machine-code splice capability. Successful -compile-time publication and test lowering flush the provider's batched records. +compile-time commit and test lowering flush the provider's batched records. Payload version 2 addresses immutable types through captured request leaves or a summary-owned node table. The table covers primitives, lists, boxes, tuples, @@ -721,16 +721,16 @@ zero-sized types and demand holes. Spans become counted sequences; names become content. Named nodes retain declaration and occurrence owners, checked occurrence, arguments, generated identity, iterator topology/tier, backing visibility and authority, and declared field order. Fields retain slot and value types, provisional -state and default identity; tags retain both semantic and checked labels. Table +state and default identity; tags retain both checked and checked labels. Table indices are assigned in request traversal order, with a work queue and memoized references preserving sharing and recursion without recursive graph expansion. -Leaf lookup uses representation digest buckets and exact equality; semantic +Leaf lookup uses representation digest buckets and exact equality; checked equality alone never erases an alias or generated backing. A field default's durable address is its declaring module content identity, exact -checked artifact key, and producer-authored CIR expression ordinal. The producer +checked entry key, and producer-authored CIR expression ordinal. The producer resolves this address through the checked body's archived default-expression map. -The consumer requires that same artifact and mapping; it neither evaluates the +The consumer requires that same entry and mapping; it neither evaluates the default nor searches by spelling, source text or shape. Changing or removing that dependency invalidates the summary. These addresses apply equally to immutable record fields and open field-kind constraints. @@ -741,11 +741,11 @@ transaction, fill all nodes, intern the complete group, and relocate the returne constraints. Allocation failure aborts the transaction; no request relation sees partial types. Each replay instantiates fresh mutable solver nodes. Names are monotonically interned in the receiving store, and no producer arena is borrowed. -Replayed entries retain their publication origin across worker relocation and are -not re-encoded for publication. Only counts exceeding the format's u32 address +Replayed entries retain their commit origin across worker relocation and are +not re-encoded for commit. Only counts exceeding the format's u32 address capacity remain unrepresentable; no type shape is excluded. Payload and family envelope versions change together; old, missing, incompatible -or corrupt optional records are misses. Encoding and publication retain the +or corrupt optional records are misses. Encoding and commit retain the existing batched, atomic family store. Counters distinguish misses, serialization failures, records and bytes, replay, invalid records and actual relation expansion. @@ -767,17 +767,17 @@ interface key. A scope without that explicit context has no persistent offer. Paired platform templates keep their code-generation key, which includes the application bindings. The request key includes the procedure/template ordinals, the full captured input and -canonical evidence identity. Evidence views of top modules use the checker's +normalized evidence identity. Evidence views of top modules use the checker's explicit value context, which describes their type/method world independently of unrelated expression bodies. Procedure targets retain their exact module and template identities; local-procedure evidence has no persistent offer because its view also owns its body address. Checked callable-type ordinals are -provenance, not evidence identity; their producer-issued canonical keys remain +provenance, not evidence identity; their producer-issued normalized keys remain part of that digest, as in `Ast.fnEvidenceDigest`. Compiler, target and optimization namespaces remain those of the object store. This boundary is a type relation, not a code or demand certificate. It neither -evaluates roots nor publishes their observations, changes demand, or promises +evaluates roots nor commits their observations, changes demand, or promises machine code. Exact callable specialization, compile-time-read demand, ownership interfaces, native-only code-provider admission and source/debug validation still happen at the existing body/code boundary. Request leaf @@ -796,14 +796,14 @@ relation. Thus a fresh value and a restored value enter the same normalization path, and unused failing roots remain undemanded. Persistent records are batched by checked module and context in -interface records in the indexed artifact catalog under the object store's procedure domain. +interface records in the indexed entry catalog under the object store's procedure domain. Different procedures remain separate request keys. Compatible native procedure domains share them; specialization strategies and incompatible targets stay separate. The binary container validates its provider, lengths and payload digest before exposing any request. A build reads each provider once, including absent providers; explicit index refresh invalidates that snapshot. Accepted coordinator results update the resident provider immediately, -so local publication invalidates absence. Publication writes each changed +so local commit invalidates absence. Commit writes each changed provider once in the reported runtime-cache phase and leaves identical records alone. Concurrent atomic replacements can discard optional variants but cannot expose partial records or change correctness. No historical providers are scanned. @@ -812,9 +812,9 @@ switch, read solely through `base.dev_diagnostics`. ### Indexed cache storage and maintenance -Cache storage and cache maintenance share one publication protocol. The native +Cache storage and cache maintenance share one commit protocol. The native store uses a new storage-format namespace; it neither imports nor scans legacy -cache directories. Compiler compatibility is part of artifact identity, not +cache directories. Compiler compatibility is part of entry identity, not maintenance ownership: one nonblocking, kernel-managed lock at the shared cache root elects the maintenance owner across all compiler compatibility namespaces. The coordination file is permanent and is never replaced or unlinked. Its @@ -822,33 +822,33 @@ existence does not mean that a process owns the lock. Process death releases kernel ownership; no PID timeout or persistent lock marker establishes liveness. The store has sixteen shard files with 512-byte metadata records and fixed-width -crit-bit indexes for logical artifacts, allocation generations, and recency. -The first 256 key bits identify an artifact group; that group digest selects +crit-bit indexes for logical entries, allocation generations, and recency. +The first 256 key bits identify an entry group; that group digest selects one shard for every member. A group enumeration opens only that catalog and walks its ordered key range under one metadata transaction. The group identity domain is versioned when this placement contract changes; older records remain available to bounded maintenance, never to current lookups through old routing. A lookup visits at most 512 branch records regardless of cache size. Lookups and updates use positional I/O, not whole-catalog reads or rewrites. Transactions -have at most 24 changed records in one checksummed redo record, published before +have at most 24 changed records in one checksummed redo record, committed before changing catalog records. Recovery finishes that transaction before admitting readers. There is no accumulating log to replay at startup. Payload generation and streaming happen outside -catalog locks. Foreground lookup, reservation, publication and enumeration +catalog locks. Foreground lookup, reservation, commit and enumeration wait for these short metadata transactions: a competing compiler worker is not a cache miss and must not cause a completed write to be dropped. Maintenance uses a separate nonblocking acquisition and skips busy shards. Generation leases remain nonblocking during retirement, so maintenance never waits for a -live artifact reader. A corrupt record or an I/O failure is an ordinary cache failure, +live entry reader. A corrupt record or an I/O failure is an ordinary cache failure, never a compiler invariant. Corrupt catalogs disable that shard rather than trusting its paths or scanning all payloads during a foreground operation. These are process-interruption guarantees, not a promise of power-loss durability. Freed metadata slots are reused; catalog capacity and stable lease files remain at their historical high-water mark, so no unbounded compaction is hidden in GC. -Every staging artifact is registered before its payload is created. Stable -slot IDs and reuse generations identify reserved, published, and retiring -artifacts. Atomic publication exposes complete payloads only. Retirement is +Every staging entry is registered before its payload is created. Stable +slot IDs and reuse generations identify reserved, committed, and retiring +entries. Atomic commit exposes complete payloads only. Retirement is idempotent: an interrupted deletion can be repeated, and a generation check prevents a stale checkpoint from deleting a replacement. Scratch ownership is an OS-backed lease, not an inference from age. Native child consumers inherit scratch leases. POSIX uses open-file-description @@ -856,23 +856,23 @@ locks; Windows uses file-sharing denial, which survives handle inheritance unlike process-owned byte-range locks. Closing the parent's handle does not release a lease still owned by a child. -Artifact acquisition opens storage and a shared lease under the shard lock, +Entry acquisition opens storage and a shared lease under the shard lock, then releases the shard lock before reading payloads. It returns owned bytes or retained storage. A pathname that another consumer will open later must be pinned for that consumer's lifetime. -An artifact disappearing before acquisition is a cache miss. Existing cache +An entry disappearing before acquisition is a cache miss. Existing cache header, key, layout, and dependency-closure validation remains authoritative; maintenance metadata never makes an otherwise invalid cache hit acceptable. An invocation-owned native pack reader records its opening timestamp as the retention observation for every acquisition in that reader. Repeated reads do not rewrite recency metadata. A later invocation advances the observation; -an older concurrent reader cannot move it backward. Publication records its +an older concurrent reader cannot move it backward. Commit records its own current timestamp, and generation leases still protect live acquisitions regardless of retention age. Watch invocations open new readers for each build. -The generation's owner leaf is authoritative for retention. The logical artifact -index supplies its immutable generation and physical slot; a reader validates +The generation's owner leaf is authoritative for retention. The logical entry +index supplies its immutable generation and storage slot; a reader validates that slot's stored generation and key before using it. A hit advances the exact use timestamp with one owner-leaf update under the normal redo protocol, without rewriting the logical index or moving the recency index. The recency position @@ -880,7 +880,7 @@ records a separate lower bound on use time. Before size eviction, maintenance repairs one stale position as a bounded unit and reselects the oldest candidate; it never evicts using that stale position. Age expiration reads the authoritative use time directly. Replacement and retirement remove the owner's indexed -position. This protocol uses the artifacts-v2 storage namespace. Catalog +position. This protocol uses the entries-v2 storage namespace. Catalog transactions memoize a bounded set of decoded reads while holding the shard lock, consulting pending writes first; the memo never crosses a transaction. @@ -893,7 +893,7 @@ enumeration, index maintenance, and deletion work. Namespace retirement, recovery, and reclamation obey the same units; none contains recursive tree deletion, whole-store inventory, sorting, or compaction. Accounting and eviction indexes are persisted and reconciled incrementally with missing payloads and -interrupted publications. A scratch step examines at most one directory entry +interrupted commits. A scratch step examines at most one directory entry and deletes at most one leaf or empty directory. Its checksummed relative-path cursor has a platform-path-length bound; every ancestor is opened without following symlinks. Restarting after a lost cursor is safe because all remaining @@ -919,18 +919,18 @@ window need not do work; continued invocations spanning subsequent windows advance a finite pass. Persisted pressure raises each grant to eight units. Retention keeps the five-minute abandoned-scratch, one-day local-pack/program, -and thirty-day persistent/package-pack windows. Artifact family and eligibility +and thirty-day persistent/package-pack windows. Entry family and eligibility are explicit metadata. Cache use is recorded on acquisition, independently of -filesystem access-time policy. Published, reserved, and retiring payload bytes -are accounted separately from scratch. Publishers update shard counters in the +filesystem access-time policy. Committed, reserved, and retiring payload bytes +are accounted separately from scratch. Writers update shard counters in the same transaction as their records. Each maintenance unit refreshes one shard's observed total, and age traversal reconciles external payload removal. Pressure is based on this persisted, incrementally refreshed observation, not a new inventory. Eviction merges one persisted recency candidate per shard; candidates are revalidated under shard ownership, and live leases are skipped for this pass. -The limit is eventual for a finite, unpinned workload: publication can outpace +The limit is eventual for a finite, unpinned workload: commit can outpace maintenance indefinitely, and pinned files, metadata, scratch, external changes, -and delayed reclamation also consume storage. Optional cache publication skips +and delayed reclamation also consume storage. Optional cache commit skips busy shards instead of waiting; required scratch reservation waits for its short metadata transaction. OS failures stop the current attempt rather than create retry loops. Continued successful invocations and filesystem operations @@ -949,14 +949,14 @@ The native target and CPU floor, specialized calling convention, shared-thread ARC signatures, hot-reload capture prefix, dev inlining policy, dictionary seed, and readonly constant representation are the ordinary native dev contracts. The final runtime continuation supplies normalized stored compile-time values; -evaluation-stage code is never published as the test runtime's code. +evaluation-stage code is never committed as the test runtime's code. Test observation eligibility is an additional explicit capability. A pack offer records whether its complete LIR call closure contains an expect or an opaque external body. Only a closure proven free of both can serve a test consumer. An ordinary dev expect reports failure but does not count success; a test expect uses a command-local site index. Neither is interchangeable with the other. -Test-instrumented closures are not published, and their bodies remain available +Test-instrumented closures are not committed, and their bodies remain available for the current plan's counters. Generic `dbg` calls use the invocation's host hooks and remain shareable. Shared specialization intersects this capability with CTFE's provision and still requires the identical provider on both sides. @@ -968,10 +968,10 @@ explicit Boxy runtime contract may initialize that runtime or construct its function table; the specialized runtime contract never does so. The test runner retains admitted pack bytes, carried immutable data, generated -code, source maps and executable memory until all workers finish. Every call +code, source maps and machine-code memory until all workers finish. Every call owns its observation state. Cache admission happens before body elision; missing, corrupt or incompatible optional entries are misses, never a reason to compile -a missing body after admission. Publication uses only the code actually emitted +a missing body after admission. Commit uses only the code actually emitted for required tests, and complete result hits never enter this runtime consumer. A proof invalidated by a graph write detaches its node subscriptions immediately, @@ -980,48 +980,48 @@ cannot notify that dirty proof again. The owner drains proof-to-proof propagatio and releases certificates at its next query boundary; no active query loses the storage it borrows. -### Native publication analysis ownership +### Native commit analysis ownership Offer collection owns one analysis of the frozen LIR store for each program. It records direct call edges, literal rejection sites and copied site code in one statement walk per procedure. Linked-entry reachability propagates over all direct call edges; copied site code propagates only across the existing unkeyed-procedure boundary. Worklists mark each procedure at most once per -property, including cycles. These facts expire with that LIR graph; they are +property, including cycles. These results expire with that LIR graph; they are never reused across a mutation or inferred from native instructions. Native offer admission similarly propagates program-local, literal-conversion, and evaluation-owned-value restrictions over the exact splice graph once. A compile-time producer marks every procedure reading a static value or failure slot owned by evaluation, using the LIR producer's slot metadata. Its captured -bytes may precede initialization and cannot be published as immutable constants. +bytes may precede initialization and cannot be committed as immutable constants. The mark follows native dependencies, including higher-order callees missing from the pre-closure Monotype call graph. Runtime producers use their finalized constants and do not have this evaluation-only restriction. Observation -eligibility propagates over the shared LIR call graph once as well. Publication gathers the union +eligibility propagates over the shared LIR call graph once as well. Commit gathers the union of selected roots in a single traversal. Neither boolean query materializes a transitive closure per candidate. Explicit closures needed by persisted records are accounted separately from eligibility analysis. Native payload provenance is an owned reference to validated immutable record -bytes, not a remembered digest. Acquisitions retain the shared artifact catalog generation lease, and +bytes, not a remembered digest. Acquisitions retain the shared entry catalog generation lease, and cache maintenance respects that lease before removing these records. A -compilation and its captured artifact sets share the acquisition owner; the +compilation and its captured entry sets share the acquisition owner; the last owner releases its locks and bytes. A retained record can be referenced without another read only inside the same procedure namespace. Optional -storage failures remain publication failures or lookup misses. +storage failures remain commit failures or lookup misses. Splicing records the immutable payload on the assembled code region. Extraction -of that region consumes the producer's canonical code and ordered carried data; +of that region consumes the producer's normalized code and ordered carried data; placement patches only the separate relocation representation. Source-file and set-local reference rebinding change metadata, not the payload. Newly generated -regions have no persisted identity. Deep copies and canonical assembly retain +regions have no persisted identity. Deep copies and normalized assembly retain the acquisition owner explicitly. This contract never identifies code by inspecting instructions or equating paths, lengths or timestamps. ### Object Cache Store -The store uses explicit logical scopes in the indexed artifact catalog. +The store uses explicit logical scopes in the indexed entry catalog. Every dev procedure record (offers, shared metadata, payloads, completion markers, discovery summaries and interface summaries) uses the build's `NativeProcDomain` (see "Native dev procedure compatibility"). A musl and a @@ -1041,8 +1041,8 @@ same provider capability (`CodeProvision`). A served procedure also carries the producer's iterator-normalization scope. Closure lifting restores that scope from the cache before computing lifted source identity. An elided body cannot acquire its producer's scope by running -normalization again. Native pack publication, retention, linked entries and -spliced entries preserve the same explicit scope fact; omitting it changes +normalization again. Native pack commit, retention, linked entries and +spliced entries preserve the same explicit scope result; omitting it changes procedure identity even when the native bytes and request key are unchanged. Within the procedure domain, catalog variants separate offers, metadata, @@ -1053,23 +1053,23 @@ record naming an immutable component digest and root ordinal, exact procedure identity, specialization key, compile-time reads and ARC interface. Provider visibility and rank come from the checked program, root first. Lookup probes one key bucket per advertised origin, filters by provider and sorts by rank -and digest. Before publishing offers, a producer publishes and pins one +and digest. Before committing offers, a producer commits and pins one immutable advertisement for that procedure domain and origin. A reader validates and retains it once per cache snapshot; an unadvertised namespace is an optional-cache miss without per-key catalog probes. Explicit index -refresh invalidates missing advertisements after publication. An accepted +refresh invalidates missing advertisements after commit. An accepted advertisement remains valid through its retained generation lease, including -when publication reuses it without rereading it. It establishes no claim +when commit reuses it without rereading it. It establishes no claim about an individual offer: all offer, dependency, payload, source-eligibility, and corruption checks remain required. Missing or corrupt advertisements only remove optional reuse, including when maintenance has retired one. -Concurrent writers publish independent immutable records. +Concurrent writers commit independent immutable records. The exact native splice graph is partitioned into strongly connected -components once per publication. Each `ROFCOMP1` metadata record holds one +components once per commit. Each `ROFCOMP1` metadata record holds one component's payload digests and payload-free `PackFile` metadata. Internal edges name component-local ordinals; external edges name a dependency's -content digest and ordinal. Dependencies publish first. No recursive hashing +content digest and ordinal. Dependencies commit first. No recursive hashing is needed for cycles, and shared dependency metadata is emitted once. An unrelated offer's insertion, removal or ordering cannot change a component. Acquired components retain their producer's member order; new components use @@ -1081,12 +1081,12 @@ A loader appends each acquired component once to its coordinator-owned native graph. Existing indices and dependency bindings remain immutable; explicit symbolic bindings prevent historical definitions from capturing each other's references. Only the coordinator extends this graph, before assembly consumes -it. Source eligibility and linked-entry facts extend over the appended suffix, -using settled dependency facts. Decoded component owners and validated record +it. Source eligibility and linked-entry results extend over the appended suffix, +using settled dependency results. Decoded component owners and validated record leases outlive every captured set that refers to their provenance. Source eligibility retains exact program membership per appended source-file -identity. Locations consume those file facts instead of searching the program's +identity. Locations consume those file results instead of searching the program's module list for each line. With no marked seed anywhere in a splice graph, reachability propagation has no work; a marked seed still receives the complete dependency propagation. Allocation failure preserves only completed prefixes. @@ -1095,35 +1095,35 @@ Payloads and metadata share content-addressed catalog storage under the local zero-provider identity within the procedure domain. They are independent of the producing module and obey the local native-cache retention window. Offers still route to their explicit local or package provider, preserving provider precedence. `RPAYLD01` holds -length-prefixed canonical code and ordered carried data. A newly generated -payload is encoded and hashed once in a publication. A retained immutable +length-prefixed normalized code and ordered carried data. A newly generated +payload is encoded and hashed once in a commit. A retained immutable payload is referenced directly, without encoding, hashing or rereading it; -publication to another namespace uses its validated bytes and establishes +commit to another namespace uses its validated bytes and establishes availability there independently. -For an unchanged offer, publication checks the already admitted spec's exact +For an unchanged offer, commit checks the already admitted spec's exact ARC/read interface, provider and component reference, then references its owned offer without encoding or rereading it. New content retains the existing -compare-and-repair catalog publication protocol, including corruption handling. +compare-and-repair catalog commit protocol, including corruption handling. Payloads become available before metadata, metadata before offers, and the `ROCINDEX01` producer completion marker comes last. It records successful -publication, not immunity from later optional-cache eviction. Held generations +commit, not immunity from later optional-cache eviction. Held generations cannot be evicted by cleanup. Catalog locks cover only bounded metadata transactions; encoding and hashing run outside those locks. -Publication counters distinguish changed/reused offers, encoded payloads and -bytes, hashed bytes, encoded metadata, validation reads, published records and +Commit counters distinguish changed/reused offers, encoded payloads and +bytes, hashed bytes, encoded metadata, validation reads, committed records and bytes, reused validated references, graph nodes/edges, closure queries and peak encoded scratch bytes. Acquisition remains separately accounted by loader counters. Catalog leases protect acquired generations until the last captured set owner -releases them. Cross-store publication uses the explicit rooted catalog identity, +releases them. Cross-store commit uses the explicit rooted catalog identity, never equality of code domains alone, to establish availability. `LoadedPacks` memoizes misses under the specialization key, code-provision policy, and exact program-module digest. Its index snapshot lasts until `refreshIndex`, a new visible provider, or a new compilation. Local producers -explicitly refresh readers after publication, including publication to an -already registered provider. Concurrent external publications become visible +explicitly refresh readers after commit, including commit to an +already registered provider. Concurrent external commits become visible on the next refresh or compilation. Missing/corrupt selected records, transient I/O failures, allocation failures, and lookups deferred for a completed-output probe are not memoized as absence. Provider registration does not read history. @@ -1137,12 +1137,12 @@ current program do not invalidate their caller merely because their historical source version changed. Linked-object precedence remains intact. The loader traverses the producer's explicit dependency graph and pins every required payload before promising a body-elision hit. It memoizes materialization per -artifact and payload bytes by digest. Eviction cannot invalidate a served hit; +entry and payload bytes by digest. Eviction cannot invalidate a served hit; missing or corrupt optional storage is a miss. Available symbolic definitions are retained because the consumer's final procedure set is not yet known; splicing still gives the current program ownership of its symbolic callees. -Runtime builds publish artifacts from their actual compilation, without +Runtime builds commit entries from their actual compilation, without compiling extra module-export programs to populate the cache. Direct LIR stamps `cache_provider_module` from a direct template's checked owner only when `fnReachesAppRecord` says it is app-independent. This is producer metadata, not @@ -1151,13 +1151,13 @@ and app-dependent procedures remain program-scoped; Builtin instantiations also belong to the producing program. A reusable offer is routed under its checked provider while referencing the producer's shared storage. The existing specialization keys, ownership interfaces, source compatibility, runtime-table -restrictions and compile-time demand rules still govern admission. Publication +restrictions and compile-time demand rules still govern admission. Commit of a runtime subset is not a claim that every export of a module was compiled. The completed-result probe defers procedure and discovery lookups until its result is known. A miss enables indexed reuse; a hit uses source demand and cached compile-time values to preserve diagnostics without procedure discovery. -Unrelated history does not enter its key. Retaining historical artifacts is +Unrelated history does not enter its key. Retaining historical entries is independent of eagerly reading them. A pack-format change selects new namespaces without a migration scan. @@ -1176,18 +1176,18 @@ their bytes. Unused historical payloads remain available until eviction. reports direct bucket opens and absences, negative-cache hits, shared metadata opens/bytes, candidate/duplicate counts, payload open attempts, unique successfully retained payload records, payload bytes, decoded records, -requests/hits and missing/corrupt/source/linked rejection counts. Publication +requests/hits and missing/corrupt/source/linked rejection counts. Commit also reports metadata/payload bytes and elapsed wall-clock nanoseconds. `test/cli/pack_index_bench.py` supplies the focused synthetic-history benchmark. Normal builds print none of these diagnostics. Performance comparisons use a ReleaseFast compiler with developer diagnostics enabled, `roc build --opt=dev`, wall time -and peak RSS; cold publication is measured separately from lookup. -`test/cli/pack_publication_bench.py` measures build and test publication +and peak RSS; cold commit is measured separately from lookup. +The native pack benchmark script in `test/cli/` measures build and test commit independently on many callers of shared code, bypassing only the temporary fixture's completed-command records. It reports cold, unchanged and one-caller -edit costs, including acquisition and publication bytes and peak process RSS. -Runtime tests that publish no offers are reported separately and provide no -evidence of publication improvement. +edit costs, including acquisition and commit bytes and peak process RSS. +Runtime tests that commit no offers are reported separately and provide no +evidence of commit improvement. A pack entry's code is a function of the procedure alone: every reference from it into code placed with it, and every reference by identity to code @@ -1288,10 +1288,10 @@ explicit visible dependency closure carries a platform/app relation, so a paired platform entrypoint cannot reach into the application for cache warming. Normal runtime builds and runs do not perform this extra compilation. Existing independent package packs remain usable. Checks with errors and `--no-cache` -prepare none. The check reporter separates cache preparation/publication from -frontend time; runtime builds report compile-time and runtime cache publication -as explicit phases. Publication diagnostics count written/reused records and -actual bytes written separately from elapsed publication time. +prepare none. The check reporter separates cache preparation/commit from +frontend time; runtime builds report compile-time and runtime cache commit +as explicit phases. Commit diagnostics count written/reused records and +actual bytes written separately from elapsed commit time. The wasm32 dev backend cannot splice dev code, so a wasm32 dev build reads and writes no dev packs and takes only object packs' entries @@ -1969,7 +1969,7 @@ checker recovery. The recovery rules: Dispatch At The Checked Boundary, "Evidence params") is retired, with every other definition its boundary generalizes: each right-hand side becomes the runtime error and each binder binds nothing, so every use is erroneous at - the use, in this module and, through the checked artifact's `checked_error` + the use, in this module and, through the checked entry's `checked_error` procedure bindings, in every module that imports it. Poisoning the receiver alone would leave a lambda whose parameter type is erroneous, which post-check lowering instantiates. @@ -2898,27 +2898,27 @@ parked, unparked, and upgraded requests, and never-run stubs. #### Discovery Summaries Closed test roots also retain a discovery certificate under the checked -producer's semantic dependency identity (`test_rows`), native compile-time +producer's checked dependency identity (`test_rows`), native compile-time target and platform/app relation. That identity covers all branches, referenced implementations, types, literal conversions and dispatch, independently of source positions and unrelated tests. After finalization reports no errors and no compile-time observations (fresh or replayed), every test root whose discovery was requested or whose runtime body was specialized receives a certificate. -An existing certificate settles discovery of the same semantic root without +An existing certificate settles discovery of the same checked root without lowering its signature or body. It does not authorize executing or omitting a test: test execution still follows its separate outcome cache. Pending module compile-time roots and their diagnostics remain ordinary finalization work. Certificates are compact, checksummed manifests per source placement, with one -semantic key per settled root, in the indexed discovery artifact family and +checked key per settled root, in the indexed discovery entry family and compiler compatibility namespace. A changed dependency, conversion or relation misses naturally. Missing, corrupt or incompatible manifests settle nothing. Literal-root messages emitted directly, without a checked root event, also mark the finalization as observing and prevent both root certificates and -specialization discovery summaries from being published. -An unsuccessful or observing finalization publishes no certificates; therefore +specialization discovery summaries from being committed. +An unsuccessful or observing finalization commits no certificates; therefore omitting discovery never loses an error or compile-time message. An application -build and a test run may both produce these facts, since both perform literal +build and a test run may both produce these results, since both perform literal discovery for the same closed test bodies. @@ -2950,7 +2950,7 @@ request is settled reads no code pack. `--timings` counts these under Test plans supply an explicit checked root and the complete ordered checked module list to the cache provider before finalization. Package tests use that -root for discovery and compile-time pack publication; they do not require an +root for discovery and compile-time pack commit; they do not require an application package. The explicit provider list remains present when every runtime test outcome is cached and only discovery requests remain. @@ -3304,31 +3304,31 @@ module finalized. It performs no shared demand specialization, CTFE, post-CTFE specialization, runtime lowering or object emission, and loads no procedure packs to reconstruct the result. A miss takes the ordinary pipeline, which can still find a retained exact completed result after an edit is reverted. Only a -compilation with no semantic errors publishes; warning-only successes are +compilation with no checked errors commits; warning-only successes are eligible. Boxy builds remain separate, and the record never admits Boxy runtime dependencies into LSS. The length-prefixed binary record validates its manifest independently before -reading the completed outcome; the payload has its own integrity digest. Publication uses +reading the completed outcome; the payload has its own integrity digest. Commit uses a temporary file and atomic rename. Incompatible, truncated, corrupt or missing -records miss, and the build pointer is published only after the complete record. +records miss, and the build pointer is committed only after the complete record. All final objects are retained in immutable content-addressed storage; the record names their digests and lengths rather than scratch paths or duplicate object bytes. -Every object is pinned and validated before admitting either an early or later hit. Publication failure leaves the live compilation intact. Every +Every object is pinned and validated before admitting either an early or later hit. Commit failure leaves the live compilation intact. Every invocation validates current platform link inputs and links the retained -artifacts, even if an executable already exists. Timings distinguish early hits, +entries, even if a linked program already exists. Timings distinguish early hits, checked-module loads and compilation, input validation time/bytes, manifest reads, -publication, and skipped producer work. +commit, and skipped producer work. #### Native link preparation -A recorded program is a checksummed binary manifest of link facts and the +A recorded program is a checksummed binary manifest of link results and the content digest and length of its separately addressable object, not an envelope containing another copy of that object. Native objects, including compiler-owned builtins, startup and support objects, live beside these manifests in the same compiler/format/actual-target/optimization namespace. Their names are BLAKE3 -content digests. A cold build writes the backend's bytes once and publishes a -directory entry atomically. No writer truncates a published inode. +content digests. A cold build writes the backend's bytes once and commits a +directory entry atomically. No writer truncates a committed inode. Embedded runtime-object digests and the fixed Windows stack-probe object are computed when the compiler is built, rather than regenerated for each link. @@ -3345,10 +3345,10 @@ eviction. On Windows a pin is a second NTFS name added through sharing mode, so pinning never conflicts with readers or replaces a name. The invocation holds `link.lock` in that scratch directory; scratch cleanup skips a held lock, and an abandoned directory ages out after the OS releases its owner's lock. Missing, corrupt or incompatible objects are cache -misses. Manifests publish atomically after the object. The executable is always +misses. Manifests commit atomically after the object. The linked program is always linked anew, even when it already exists. -Local platform inputs have no content identity supplied by their publisher. +Local platform inputs have no content identity supplied by their writer. They are inspected on every build; path, size and timestamps never key symbol metadata. Inspection reads bounded object headers and symbol/string tables with positional reads, retaining their bytes only for the current archive member. @@ -3359,11 +3359,11 @@ every member, sharing the same retained ranges with verification. Unsupported formats leave missing-symbol results non-authoritative, as before; the linker still validates the actual inputs. -Platform publishers must atomically replace complete inputs. A build checks +Platform writers must atomically replace complete inputs. A build checks file identity, size and change/modification state before inspection, before LLD, and after linking; a change fails the build. These checks detect concurrent -publication, not authorize cross-build reuse. In-place mutation while LLD is -reading an input is outside the supported publication contract. Link order, +commit, not authorize cross-build reuse. In-place mutation while LLD is +reading an input is outside the supported commit contract. Link order, roots, exports, lazy archives, ABI and LLD parallelism are unchanged. Requested timing output separates platform input validation, object @@ -3389,7 +3389,7 @@ body it stands in for; each demands the root. The set is computed over the specialized program itself, so it is a function of the checked program and the build's roots, never of what any cache holds. -A completed-compilation hit reuses these semantic decisions and replays their +A completed-compilation hit reuses these checked decisions and replays their observations without recomputing demand. This is equivalent to the live path, not check-style evaluation of every root. @@ -3422,9 +3422,9 @@ the program's entries, and from the definitions of demanded roots whose bodies that drain lowered, over the definitions the drain committed; every parked root a reachable definition reads moves into the queue, and the next drain lowers it. A discovery request can expose a parked procedure declaration before a later -round's evaluation request completes its body. Body publication explicitly +round's evaluation request completes its body. Body commit explicitly invalidates any earlier demand summary for that declaration, and a declaration -already reached joins the next demand round at its newly published body. No +already reached joins the next demand round at its newly committed body. No caller graph is rescanned: unchanged summaries and reached edges remain valid. The rounds end when nothing reachable demands another root. A definition nothing reachable references demands nothing, though it was @@ -3510,7 +3510,7 @@ no checked module, are reused the same way from a file their program keeps Restoring a stored constant's type into Monotype memoizes the immutable source type by its owning `ConstTypeStore` and type id. The memo belongs to the exact destination `Type.Store`: the coordinator and every specialization workspace -own separate maps. Only successfully committed destination ids are published; +own separate maps. Only successfully committed destination ids are committed; transaction-local ids and ids from another workspace never enter that map. This shares immutable type descriptions, not Lambda Solved's mutable row occurrences, which continue to follow the Row Joins contract. @@ -3548,7 +3548,7 @@ covers: is not a value dependency of a root without lexical reads. Local type declarations retain the type digest's version-specific provenance, including when a local type occurs only inside the computation. Two hoisted roots of one - declaration that agree in these facts compute the same value. For a literal conversion root, the + declaration that agree in these results compute the same value. For a literal conversion root, the literal's source bytes alone: the literal is closed, and the conversion it runs is selected by its checked type. For a top-level `expect`, its statement's source bytes and the definition identities of the definitions @@ -3576,7 +3576,7 @@ identity; that is a missed reuse, never a wrong one. A stored nominal value may retain a checked type occurrence in the module that stored it even when a different, unchanged module declares its nominal type. Across an edit, reuse admission can relocate that occurrence through the old -checked root's canonical type key to a root published by the new checked module. +checked root's normalized type key to a root committed by the new checked module. Every local occurrence in the value and its stored type must have this explicit correspondence before any copy is installed. The copy changes only the occurrence module and checked root id. A missing key rejects reuse; a type declared by the @@ -3626,15 +3626,15 @@ constant's type names every nominal it mentions, so a constant whose type mentions one of the module's own types is not portable. A completed value with no module-version references may also reuse the checked -producer's semantic dependency identity (`test_rows`) when an imported artifact -changes. This identity includes the referenced implementations, semantic type +producer's checked dependency identity (`test_rows`) when an imported entry +changes. This identity includes the referenced implementations, checked type contexts, checking policy, and file inputs. Admission requires both the value and its stored type to contain no named type, field default, callable or other module-version reference, and its recorded root-read set to be empty. These -conditions are checked against the previous published result, not guessed from +conditions are checked against the previous committed result, not guessed from source syntax. Debug observations are copied and replayed in the ordinary root -order. Values carrying version-specific references keep their artifact-key -identity contract; semantic equality alone does not authorize retaining an old +order. Values carrying version-specific references keep their entry-key +identity contract; checked equality alone does not authorize retaining an old checked-type occurrence or root id. Verification builds independently evaluate and compare these transplants just like other compile-time value reuse. @@ -5645,53 +5645,53 @@ implementations. It names evaluated constants, procedure templates, lowering inputs and test results. Equality of checking dependencies authorizes reuse of inference only; it never authorizes reuse of those implementation results. -Checking publishes its import-facing inputs while the solved source store is +Checking commits its import-facing inputs while the solved source store is still available. These inputs include exposed names and their value/type classification, declaration and method bindings, complete copied schemes (including descriptor flags and generated-codec requirements), nominal backing -templates, declaration annotations and their argument-position semantics, +templates, declaration annotations and their argument-position meaning, generated-method and hosted-declaration classifications, invoking-callable -facts, diagnostic hosted origins, and the facts that govern stored-constant +results, diagnostic hosted origins, and the results that govern stored-constant eligibility. The ordered import, type-owner and method-lookup scope is part of -the dependency. An implementation edit can change one of these facts without +the dependency. An implementation edit can change one of these results without changing a printed type signature. Module ownership is a parameter of reusable inference. A checking dependency records the ordered owner bindings and their equality relationships, separately from the current owners' deep content hashes. Two distinct current owners never -become one owner merely because they publish equal checking interfaces. +become one owner merely because they commit equal checking interfaces. Recursive type graphs retain identity sharing and cycles. Import cycles remain the explicitly rejected edges described under Module Completion Boundary. The inference-result cache stores the solved source environment together with -the checker-selected publication inputs, before evaluation. Its key includes +the checker-selected commit inputs, before evaluation. Its key includes the module's own source inputs, validation (including the source-pin validation input canonicalization reported against) and resolution context, and the checking dependencies it consumed. Serialized references retain their explicit owner and declaration bindings; admission must establish those bindings against -the current dependency publications. Equality of a signature, a name or an +the current dependency commits. Equality of a signature, a name or an incidental node index is never a license to substitute a declaration. Source regions that are retained as checking input participate in admission as well. -The producer assigns declaration-reference slots while publishing its checking +The producer assigns declaration-reference slots while committing its checking interface. Slots are first-encounter ordinals in the interface's deterministic traversal: sorted exposures, declared types and methods, and their binding and annotation children. They are scoped to that complete interface, not global -identities or a name-matching scheme. The publication carries a parallel table -from slot to current CIR node. The digest encodes slots and their checking facts, +identities or a name-matching scheme. The commit carries a parallel table +from slot to current CIR node. The digest encodes slots and their checking results, never the table's incidental CIR indices. Changed traversal structure or checking -facts changes the interface and invalidates importers. Builtin references remain +results changes the interface and invalidates importers. Builtin references remain fixed within the compiler compatibility domain. An inference capsule carries the old dependency reference tables and their owner parameters. Admission establishes equal interfaces and owner equality partitions -before translating each retained foreign reference through old node -> published +before translating each retained foreign reference through old node -> committed slot -> current node. Translation covers CIR value, associated, nominal, pattern and annotation references, suffix and hosted targets, method-owner keys, nominal and alias source declarations, record defaults, and their type-store indices. Dispatch evidence's type variables and relations are local to the stored solved graph and are preserved; only their foreign declaration locators change. A -reference without a published correspondence rejects the optional capsule. No +reference without a committed correspondence rejects the optional capsule. No name, printed signature or numerical coincidence supplies a missing mapping. This relocation changes no solved type relationship or dispatch decision. Admission indexes the old owner parameters once, preserving their first @@ -5705,15 +5705,15 @@ checking problems and canonicalization diagnostics in order, together with all reporting data they require. Local source is already an exact cache-key input; foreign source-dependent diagnostics require explicit current-source validation and otherwise miss. Evaluation diagnostics and empirical exhaustiveness outcomes -are produced after publication against current implementations. Restored checking -errors follow ordinary reporting and rejection, including publication's erroneous -code exclusions; a cache hit cannot turn an error into successful executable code. +are produced after commit against current implementations. Restored checking +errors follow ordinary reporting and rejection, including commit's erroneous +code exclusions; a cache hit cannot turn an error into successful machine code. -A hit binds the inference result to current owners and publishes a new checked -artifact against current imported artifacts. Publication consumes the stored +A hit binds the inference result to current owners and commits a new checked +entry against current imported entries. Commit consumes the stored root selections and scheme evidence; it neither reruns -inference nor reconstructs these facts. No finalized artifact with old imported -keys is installed under a new key. Evaluation and ordinary checked-artifact +inference nor reconstructs these results. No finalized entry with old imported +keys is installed under a new key. Evaluation and ordinary checked-entry caching then use the new full implementation identity. Inference cache entries have their own versioned content-addressed admission @@ -5724,19 +5724,19 @@ is not admitted unless those reports and their current provenance are explicitly part of the stored result. Checking outcomes include snapshots, their formatted text, checker regions, and pending static-exhaustiveness recipes. Platform-side source regions require an exact current-platform-source digest; otherwise the -capsule misses. Evaluation diagnostics always come from current publication. -Work counters distinguish inference, interface publication, owner and declaration -reference binding, diagnostic replay and artifact publication, so interface creation +capsule misses. Evaluation diagnostics always come from current commit. +Work counters distinguish inference, interface commit, owner and declaration +reference binding, diagnostic replay and entry commit, so interface creation cannot hide a second checking pass. -Stored-constant eligibility of a foreign callable is a producer fact. Checking +Stored-constant eligibility of a foreign callable is a producer result. Checking scans the own body of each module-global definition and of each method binding's definition (block-local nominal types own method definitions inside bodies, and resolved associated lookups reach them) once, records the exact local -callee edges and current imported eligibility facts, and propagates rejection +callee edges and current imported eligibility results, and propagates rejection backwards through those edges to a fixed point. A recursive component with no rejected body or outgoing dependency is eligible. Importers consume the -published result by definition identity; they never reopen the foreign body or +committed result by definition identity; they never reopen the foreign body or interpret a foreign import index in their own import environment. ### Cache Admission Versioning @@ -11167,7 +11167,7 @@ names one target class. Shape equivalence alone is insufficient: expanding a cycle can tie otherwise distinct target rows. An unexpanded mutable target does not establish that proof, even when its subtree is acyclic: a hidden occurrence can share a mutable cell with a sibling. An inherited argument/clone context likewise requires its own explicit proof. Certificates -subscribe to every solved node they read and retire on semantic writes. An +subscribe to every solved node they read and retire on checked writes. An unsuccessful proof leaves the ordinary unification relation unchanged. **`.boxy`.** Boxy plans a representation per checked type identity, and its @@ -11702,7 +11702,7 @@ common checker scratch used by subsequent small queries. The same walk is the checked module's infinite-type check. No checked type is infinite or anonymously recursive: a cycle in a type must pass through a -nominal's backing, and the value graph sees a nominal application's arguments +nominal's backing, and the ConstStore sees a nominal application's arguments only, so every cycle the walk can close through structure is rejected. The walk is depth-first and marks each variable in progress until everything it reaches through structure is done, so an edge back to an in-progress variable is @@ -13931,7 +13931,7 @@ Other solved-graph mutations: operation translates nominal/alias/default locators and the nominal lookup index. It preserves every variable, equivalence class, descriptor flag, structural edge, and dispatch decision. It also normalizes the private - import-facing type projection to producer slots before hashing it. + import-facing type mapping to producer slots before hashing it. - `checkConcreteBuiltinNumeral`—mechanism: Concrete Builtin Numeral Introduction (above). Uses the existing `unifyWith` descriptor-introduction @@ -14301,7 +14301,7 @@ generation. The opt-in is transitive. An `.lss` compilation performs no Boxy planning, descriptor construction, runtime initialization, registration, or sidecar -work. Its code and cached artifacts have no Boxy dependencies. LIR carries an +work. Its code and cached entries have no Boxy dependencies. LIR carries an explicit runtime contract, set by its producer and retained by images; only a Boxy producer can authorize descriptor operations. Store construction enforces this contract, and consumers use it without discovering a strategy by scanning @@ -16452,25 +16452,25 @@ completed. A finite system of these monotone equations is solved from unchanged to its least fixed point. A back edge alone therefore contributes unchanged, but a later completion or mismatch propagates to every dependent pair, including pairs already encountered through the back edge. Provisional answers are never -published. Expansion and propagation use explicit worklists. +committed. Expansion and propagation use explicit worklists. Uninhabitedness is a separate least Boolean fixed point, initially false. Tuples, required record fields and box contents use OR of emptiness; unions use AND across alternatives, each of which uses OR across payloads. Empty AND is true and empty OR is false. Inspectable-only and runtime-layout queries remain separate, as do immutable finished types and mutable graph nodes. Settling a -recursive component publishes every node's converged answer, including false. +recursive component commits every node's converged answer, including false. -Proofs are graph-owned and retain explicit dependencies on the canonical nodes +Proofs are graph-owned and retain explicit dependencies on the normalized nodes read to establish them and observe explicit producer mutation events. -Unrelated writes preserve them; a changed input or canonical representative +Unrelated writes preserve them; a changed input or normalized representative retires its dependent proofs. Path compression preserves root identity and meaning. Lazy expansion, backing policy changes, field changes and class joins are observable mutations. Completion also observes -tag-head order. A traversal that crosses an observable mutation does not publish -its previous assumptions as facts about the resulting graph. +tag-head order. A traversal that crosses an observable mutation does not commit +its previous assumptions as results about the resulting graph. -Proof ownership ends with the body context; no facts cross a graph reset, +Proof ownership ends with the body context; no results cross a graph reset, worker boundary or specialization. Shared subproofs are retained once, and scratch is reused between queries. Lambda Solved compatibility has its own truth conditions and lifetime described below; no completion or inhabitation @@ -18425,7 +18425,7 @@ Compatibility proofs belong to the function currently being solved and are released at its completion, including on failure. Queries outside a function belong to the enclosing solver operation and end with it. Within that scope, interleaved queries retain disjoint completed proofs, including counterexamples. -Each completed traversal publishes only newly examined pairs in one certificate, +Each completed traversal commits only newly examined pairs in one certificate, referring to earlier certificates it consumed rather than copying their subgraphs. Storage is O(P + E) for the distinct demanded pairs and dependency edges of the live @@ -18437,10 +18437,10 @@ eviction policy. Pair relations can inherently require quadratic work inside one scope; the implementation does not retain those pairs across completed function bodies or specializations. -A certificate records every canonical node it reads and references to earlier -certificates. Each dependency owns a reverse link. Producers record semantic +A certificate records every normalized node it reads and references to earlier +certificates. Each dependency owns a reverse link. Producers record checked writes through graph-owned intrusive subscriptions: each node owns the head of -its readers, and each published dependency owns its subscription. A write visits +its readers, and each committed dependency owns its subscription. A write visits only readers of that node and queues their invalidation without allocating. Query owners have stable notification storage even when their enclosing cache moves. At a query boundary the owner drains its notifications and follows reverse proof @@ -18448,25 +18448,25 @@ links, detaching all references before freeing any certificates. The graph must outlive its subscribers; scope exit unsubscribes in constant time per dependency. The graph's index occupies one pointer per node. There is no mutation history for each body-local observer to scan. Unrelated writes do not visit those observers. -Validation costs affected dependency edges and retired facts, independent of +Validation costs affected dependency edges and retired results, independent of unrelated graph writes. Path compression -directly shortens existing links without recording a semantic mutation. Lazy expansion or joining representatives during a -query prevents publication of that traversal. The special untouched-leaf +directly shortens existing links without recording a checked mutation. Lazy expansion or joining representatives during a +query prevents commit of that traversal. The special untouched-leaf shortcut also reads mutable clone-context ownership; it remains a direct -answer and does not publish a structural certificate with missing dependencies. -No other traversal publishes provisional cycle assumptions. A late conflict -prevents all successful pairs of that walk from being published; only the +answer and does not commit a structural certificate with missing dependencies. +No other traversal commits provisional cycle assumptions. A late conflict +prevents all successful pairs of that walk from being committed; only the requested false result may be retained, with its counterexample dependencies. Counters separate queries, structural walks, pair expansions, repeat visits, -reuse hits, invalidations, lazy expansion and unification. Peak fact counts and +reuse hits, invalidations, lazy expansion and unification. Peak result counts and payload bytes account for certificate arrays and table payload capacity (allocator metadata and table control bytes are excluded); allocator tests separately assert that warmed repetition performs no allocation. On x86_64, the issue 12616 interleaving fixtures settle five completion pairs and six -inhabitation nodes into 12 facts (3,224 accounted proof bytes; 5,608 total +inhabitation nodes into 12 results (3,224 accounted proof bytes; 5,608 total query-owned bytes including scratch, across 38 initial allocations), and three -compatibility pairs into three facts (1,216 accounted proof bytes; 1,756 total +compatibility pairs into three results (1,216 accounted proof bytes; 1,756 total query-owned bytes across 14 initial allocations). Repeating A/B 512 times adds no expansions, allocations or retained bytes. These are small deterministic graph measurements, not an attribution of the entire compiler phase's time. @@ -22043,13 +22043,13 @@ callee can return with a call pending, and on each deferred call: by its function pointer and the procedure asks with its own, so a value no callee read cannot answer for a different procedure entered later. -The pass also publishes `LirProcSpec.may_leave_pending`, its exact procedure +The pass also commits `LirProcSpec.may_leave_pending`, its exact procedure summary, into native cache offers. Reusing a body-less native entry seeds the -consumer's propagation with that producer fact, including when no locally +consumer's propagation with that producer result, including when no locally compiled body contains a deferred call. Callers must drive or forward that pending work before treating its result slot as a value. The versioned pack format records the summary alongside the ownership ABI; an older entry without -the fact is incompatible, never assumed to return a completed value. +the result is incompatible, never assumed to return a completed value. A call's result may be converted to another representation before it is returned, by statements after the call. The pass marks a direct call or a @@ -22479,7 +22479,7 @@ inferred from cache hits, source locations, or the presence of code keys. existing site-call stability rule still applies to consumers that infer base signatures but use site code keys. Standalone dev procedures trade call-boundary optimization for cache reuse; iterator fusion, tag-case fusion, TRMC, and loop -append promotion retain their existing semantics and eligibility. +append promotion retain their existing meaning and eligibility. ### Outcome-Conditioned Argument Restitution @@ -22509,7 +22509,7 @@ the callers with a code key, may demand its outcome variant base. A complete outcome table whose restitution masks are all zero declares no -additional ownership convention. The solver publishes an empty capability for +additional ownership convention. The solver commits an empty capability for it: no caller can demand a restituting variant, no second source-liveness graph is needed, and the table cannot prevent object-cache admission. A nonempty capability retains every outcome row, including zero masks on consuming paths. @@ -24689,16 +24689,16 @@ of result slots or generated symbol names. The explicit subset is also the runtime demand of compile-time finalization, including its normalization specialization from completed stored values. A -validated cached result discharges that test's runtime observation obligation; -it does not discharge non-test compile-time obligations. The finalizer still +validated cached result discharges that test's runtime observation requirement; +it does not discharge non-test compile-time requirements. The finalizer still evaluates pending compile-time roots and replays stored diagnostics and debug observations under the checking contract, even when the subset is empty. An explicit empty subset has no runtime consumer and performs no test runtime preparation. It must not turn back into the program's default root set. A source file with no test plan keeps ordinary checking without installing a -runtime configuration; a plain type module need not have an executable owner. +runtime configuration; a plain type module need not have a runnable owner. -Cached test roots remain explicit compile-time discovery obligations, separate +Cached test roots remain explicit compile-time discovery requirements, separate from execution demand. The established `LiteralDemand` analysis selects only those that can register specialization-dependent literal conversions. These follow runtime requests as discovery roots in the shared producer, with no @@ -24725,14 +24725,14 @@ graph, the command can validate its recorded consumed source and imported-file contents and replay the complete outcome before loading checked modules. The test command has its own identity domain, including compiler compatibility, root selection, checking context, resolved package graph and execution policy. -Neither a build nor a check publishes a test-command outcome. +Neither a build nor a check commits a test-command outcome. The early input plan is producer data: the source and file-import reads of a -completed compilation, including missing-file facts, and the physical source +completed compilation, including missing-file results, and the storage source paths admitted by import resolution. Validation hashes bytes and revalidates -those physical paths, never timestamps. Resolution runs before admission, so +those storage paths, never timestamps. Resolution runs before admission, so package sidecars, replacement requests and resolution failures retain their -ordinary semantics. +ordinary meaning. An input plan only locates a content-addressed outcome; changing its inputs cannot admit the previous outcome. Older outcomes remain reusable when those inputs are restored. A changed dependency closure may replace the input plan @@ -24741,23 +24741,23 @@ and conservatively miss an older outcome with a different closure. The command owns ordered compile-time debug observations and structured diagnostic documents. Test observations remain in the individual-root outcome payloads, referenced by key and digest rather than copied into the command record. The manifest -carries each root's reporting order, source region and checked artifact +carries each root's reporting order, source region and checked entry provenance; validated source bytes suffice to decode it without checked IR. Each observation has exactly one replay owner. Source reporting uses current validated source bytes and paths; it requires no `PreparedSolved`, CIR or type store. Presentation settings and elapsed time are never cached. -Publication follows completed execution and durable referenced payloads; an -atomic manifest publishes the outcome last. Passes, failed expects and Roc -crashes qualify. Commands with semantic compilation errors or transient backend -failures do not publish complete outcomes. Missing, incompatible, truncated or +Commit follows completed execution and durable referenced payloads; an +atomic manifest commits the outcome last. Passes, failed expects and Roc +crashes qualify. Commands with checked compilation errors or transient backend +failures do not commit complete outcomes. Missing, incompatible, truncated or corrupt records and evicted payloads are misses. A complete hit returns before runtime demand construction, specialization, CTFE, LIR/ARC, static data, native test code generation and execution. It does not load procedure packs. The command envelope and individual-root outcome payloads carry content checksums. -Command source paths and diagnostic path elements are relative to the current +Command source paths and diagnostic path components are relative to the current working directory and are rebound only after validation. Compilation diagnostics -are replayed from their document elements, before test observations; color, +are replayed from their document items, before test observations; color, verbosity and terminal width are current renderer inputs. The command's arena owns validated source bytes and lightweight source environments throughout replay. @@ -24786,7 +24786,7 @@ link one shared library per checked module. The per-module checked cache boundary remains intact, but cache boundaries are not native link/load boundaries for optimized tests. Cached test results are merged into the command result stream before reporting; uncached test roots are compiled into the single -test library and published as individual outcome records after execution. +test library and committed as individual outcome records after execution. The command-level plan is built after checking has produced all checked modules and before any optimized test backend code is generated: @@ -24919,8 +24919,8 @@ results use the same ordering path. Diagnostics, `dbg`, `expect` failures, `expect_err` regions, crashes, and backend compiler errors stay attached to their test-plan entries until rendering and cache writes replay the plan order. -Checked test outcomes are separate per-root records. Their semantic identity -is produced during checked publication, independently of the root's current +Checked test outcomes are separate per-root records. Their checked identity +is produced during checked commit, independently of the root's current reporting slot. It includes the checked root type, body and name-reference closure, including both sides of conditionals, captures and constants. Definition summaries are Merkle identities of strongly connected components; @@ -24928,7 +24928,7 @@ recursive helpers therefore have stable identities without traversing a fresh transitive graph for every test. The checked type/method context covers literal conversions, generated dispatch and inspection, including failure observations. Imported implementation summaries, rather than runtime specialization or the -last execution's read set, supply imported dependencies. Checked publication +last execution's read set, supply imported dependencies. Checked commit stores these in `CompileTimeRootIdentityTable.test_rows`, `test_context`, and `test_definitions`. Import contexts cover the type and method environment; explicit external and associated references select implementation summaries. @@ -24937,16 +24937,16 @@ identity remains an explicit binding input. This is independent of compile-time value reuse's existing identity and portability contract. Nominal and alias types in test dependency summaries use producer-authored -`test_declarations` provenance instead of an imported artifact key and an +`test_declarations` provenance instead of an imported entry key and an incidental declaration number. The provenance covers the declaring module's qualified name and declaration bytes; a block-local declaration additionally names its checked lexical owner and relative span. A declaration outside that ownership graph retains exact version provenance. Imported representation references resolve their explicit declaration row and retain the representation -owner's semantic type/method context, so location independence never removes +owner's checked type/method context, so location independence never removes nominal identity, backing declaration validation, or method precedence. This contract is specific to outcome identity; ordinary checked type identity and -compile-time value portability are unchanged. The semantic type environment +compile-time value portability are unchanged. The checked type environment also includes the referenced-definition and import summaries of declared field defaults: foreign construction materializes them without a local value-reference edge. Default-expression observations have their declaring type as an explicit @@ -24954,17 +24954,17 @@ reporting owner. The execution strategy, execution mode, compiler compatibility and payload version partition outcome admission. Rendering settings, elapsed time, root -order and the whole enclosing module are not semantic identity. Each current +order and the whole enclosing module are not checked identity. Each current plan entry independently loads its outcome, so identical roots retain their output multiplicity. Missing, corrupt or incompatible records are misses; -atomic cache publication prevents incomplete records from becoming hits. Each +atomic cache commit prevents incomplete records from becoming hits. Each record also contains its expected key and a checksum. Atomic replacement under -one key can refresh reporting provenance for the same semantic outcome; reads +one key can refresh reporting provenance for the same checked outcome; reads still validate that provenance against the current sources. -Source provenance is validated separately from semantics. Outcome format V2 +Source provenance is validated separately from meaning. Outcome format V2 contains no absolute root offsets in its key or payload. A root's checked body -and semantic dependency identity, compiler compatibility, native target, +and checked dependency identity, compiler compatibility, native target, specialization strategy and execution policy decide its outcome. Current root order, source owner and region form an occurrence, supplied by the current test plan. Identical roots may share an outcome, but each occurrence independently @@ -24972,7 +24972,7 @@ instantiates that outcome and contributes its observations. A report inside the unchanged root carries a root-relative span. Other reports carry a qualified source owner and a checked producer-authored site identity. -Checked publication emits `test_report_sites`: each site names its enclosing +Checked commit emits `test_report_sites`: each site names its enclosing checked declaration's name and body bytes plus its relative span, and carries the current absolute region separately. Declaration bodies are hashed once per owner. The command builds both directions of this mapping once per source; @@ -24986,7 +24986,7 @@ returning an outcome for merging or rendering. Missing, corrupt, incompatible or ambiguous reporting data rejects the entire outcome without emitting a partial transcript. Source maps borrow the command's checked sources, or the complete-command arena's validated source snapshots, through final rendering. -Raw `dbg` and crash payload bytes remain semantic output and are never rewritten. +Raw `dbg` and crash payload bytes remain checked output and are never rewritten. Failure excerpts of mentioned bindings are covered by checked definition identity; primary source excerpts and line numbers render from current sources. Passes and failures are equally admissible; compiler errors and incomplete @@ -25003,7 +25003,7 @@ LIR, code generation and execution. Each executing root owns its inline-expect counts as well as its transcript and result. Worker-local counters are harvested and cleared at root completion, retaining sparse nonzero observations with that root. Cached and fresh root observations are merged before command-level -coalescing. Publication happens before coalescing, preserving every root's +coalescing. Commit happens before coalescing, preserving every root's uncombined contribution. Shared code and static data remain command-owned. Cached test results store structured, pre-render data: the result, ordered @@ -25394,8 +25394,8 @@ a constant (static data named by content, a short string as its literal, a construction as its parts), and no runtime program declares a slot, a failure record, or a guard for a compile-time root that completed. -A completed-compilation record retains only artifacts generated after this -normalization. Reusing those final artifacts preserves the same rule without +A completed-compilation record retains only entries generated after this +normalization. Reusing those final entries preserves the same rule without reconstructing either specialization or the values they consumed. The point at which this holds for a live compilation is explicit: the end of the @@ -25406,7 +25406,7 @@ demands, its Solved program is not the one a runtime consumer continues. The program is specialized again from the checked modules, now holding every demanded value stored, exactly as a compilation that found them all in the cache specializes it (`ProgramSession.RuntimeStages.respecialized`). -Discovery-only test roots have already completed their obligations and do not +Discovery-only test roots have already completed their requirements and do not join this second specialization. Runtime roots still register every conversion their own code uses. That specialization demands no compile-time root; it registers the runtime program's literal roots again and evaluates them, and their outcomes, which @@ -27301,9 +27301,9 @@ the pass/fail bar while the language is 128-bit-only. ### Successful Initializer Validation Without Value Rebinding A test command validates all module initializers, including initializers no -executing test reads. Successful silent evaluation also publishes a semantic +executing test reads. Successful silent evaluation also commits a checked validation certificate under the checked producer's dependency identity, native -target and platform/app relation. This fact is independent of the stored value: +target and platform/app relation. This result is independent of the stored value: it contains no type-store positions or nominal occurrences to rebind. On an edit, previously validated initializers can remain unevaluated until a @@ -27313,7 +27313,7 @@ whose source or dependency changed. The producer receives this explicit set and seeds demand with every uncertified root; dependency closure still evaluates any certified value that a reader needs. This preserves test/check validation rules without repeating unused nominal-value construction. Missing or corrupt records -certify nothing. Failed or observing finalizations publish no facts, and ordinary +certify nothing. Failed or observing finalizations commit no results, and ordinary cached compile-time observations continue to replay without demand filtering. Test execution metadata is translated through the producer's selected @@ -27352,25 +27352,25 @@ Persistent Monotype interface summaries are indexed by both checked provider family and exact request identity. A lookup loads only that request's record, validates its family, request and payload digest, then retains owned bytes. It never reads other requests or their historical variants. Absence is cached -only until publication refreshes the lookup snapshot. Each changed request is -published independently, so concurrent writers of different requests cannot +only until commit refreshes the lookup snapshot. Each changed request is +committed independently, so concurrent writers of different requests cannot replace one another's summaries. These records retain the native procedure compatibility domain; the RIFACE03 boundary isolates the per-request format. A produced-value witness traversal retains completed acyclic container pairs for the current graph mutation epoch. Repeated paths through a shared DAG reuse the same witness. A graph mutation invalidates reuse, and a traversal -that depended on an active-path recursive assumption publishes no witness. +that depended on an active-path recursive assumption commits no witness. The table ends with the relation traversal; it carries no result between bodies. Persistent interface records distinguish checked occurrence provenance from nominal ownership. An occurrence of a closed checked type is serialized by its -canonical checked type key and its nominal owner's exact artifact key. Reopening -binds that key only to a current published closed root with the same key and -owner. Representatives are chosen deterministically by artifact-key order and +normalized checked type key and its nominal owner's exact entry key. Reopening +binds that key only to a current committed closed root with the same key and +owner. Representatives are chosen deterministically by entry-key order and memoized for the immutable lowering input set. A missing key rejects the optional -record. Types containing identity variables keep their exact artifact and root id; +record. Types containing identity variables keep their exact entry and root id; no variable identity is reconstructed or merged. Nominal declaration, generated representation and default-expression validation remain mandatory after rebinding. @@ -27381,7 +27381,7 @@ borrowed views. Persistent summary validation and default-owner resolution use this index rather than scanning all modules for every named type. Compile-time root declaration is an immutable checked request ordinal, not an -eager Monotype specialization. Demand-driven runs publish the entire declaration +eager Monotype specialization. Demand-driven runs commit the entire declaration manifest before workers start, without lowering its initializer signatures or probing their native cache entries. A read records its declared ordinal in its private draft; ordered coordinator commit prepares that root's real function and @@ -27393,8 +27393,8 @@ compilation. Worker-visible request ordinals never change during compilation. Interface-cache synchronization follows request identity. The family map lock -publishes stable request records; each record has its own lock covering load, -validation, replacement and publication. Independent requests can perform file +commits stable request records; each record has its own lock covering load, +validation, replacement and commit. Independent requests can perform file I/O concurrently, including requests in the same module. Repeated concurrent reads of one request share its loaded payload or its snapshot-local miss. Refresh and destruction occur after specialization workers join. The map lock @@ -27404,9 +27404,9 @@ never acquires the map lock. Statistics are aggregated atomically. Freshly lowered named types and persistent interface replay select the same current checked occurrence for a closed checked type key and nominal owner. -The checker's `contains_identity_variables` fact determines whether an occurrence +The checker's `contains_identity_variables` result determines whether an occurrence has instantiation identity. Closed occurrences use the existing deterministic -artifact-key representative; open occurrences retain their exact source module +entry-key representative; open occurrences retain their exact source module and type id. This changes only checked provenance: Monotype arguments, backing, generated representation, declared layout and default expressions remain the explicit products of their existing producers. Lambda solving can consequently @@ -27414,15 +27414,15 @@ recognize identical closed shapes without differences introduced solely by whether an interface arrived from a cache or from fresh lowering. -Native artifact acquisition owners may batch exact retention observations while +Native entry acquisition owners may batch exact retention observations while holding their generation leases. Each shard batch contains at most the catalog's 24 changed-record limit and commits owner-leaf updates through the same redo -protocol. The current generation, physical slot and artifact key are revalidated; +protocol. The current generation, storage slot and entry key are revalidated; newer observations never move backward. The owner flushes before releasing any retained record lease, including when captured code outlives the original loader. Rejected acquisitions can release early; an observation for a subsequently retired generation is discarded rather than applied to its replacement. Process death -releases the leases and may lose unpublished retention observations, but cannot +releases the leases and may lose uncommitted retention observations, but cannot invalidate already accepted immutable bytes. Failed optional retention writes do not fail compilation. No age approximation or whole-catalog scan is introduced. @@ -27465,10 +27465,10 @@ the file relative to the already owned shard directory, with the same generation lease and size validation as absolute-path acquisition. Native component acquisition may group the exact content addresses supplied by -validated dependency records. It groups acquisitions by physical shard while +validated dependency records. It groups acquisitions by storage shard while retaining the input-to-result mapping. Each shard ownership interval acquires at most one retention transaction's existing record bound; file reading, hashing, -and decoding happen after releasing shard ownership. Every acquired artifact +and decoding happen after releasing shard ownership. Every acquired entry still retains its individual generation lease and validated content identity. Metadata acquisition follows the explicit dependency frontier. Topological and source-eligibility validation still precede payload consumption. Payload groups @@ -27481,7 +27481,7 @@ Lambda Solved retains completed erased-callable reachability markings only for the exact graph mutation epoch in which a walk made no changes. A later query can skip a certified root or subgraph at that epoch. Unification, replacement, or lazy expansion invalidates reuse through the producer's mutation epoch; -a walk that changed the graph publishes no certificate. Each active traversal +a walk that changed the graph commits no certificate. Each active traversal still owns its independent cycle set, including during nested marking calls. No choice of callable representation changes; this records completed work only. @@ -27493,7 +27493,7 @@ is reused directly; only absent content is copied and sealed. The full reached closure still receives relocation entries, including recursive members and children of reused roots. This shares immutable type content, never mutable instantiation cells or ownership scopes. All fallible work precedes relocation -publication, and allocation failure preserves the original store and mapping. +commit, and allocation failure preserves the original store and mapping. ### Persistent completed specialization data @@ -27502,7 +27502,7 @@ does not persist the mutable instantiation graph, worker workspace, allocation addresses, or coordinator reservation numbers. Its type closure uses the same portable representation as persistent interface constraints: table references preserve cycles and sharing, names are stored as content, and named types and -executable defaults retain checked provenance. A distinct format and schema +runtime defaults retain checked provenance. A distinct format and schema identity separate completed data from interface constraints. Each record producer supplies its complete dependency identity and an explicit @@ -27521,7 +27521,7 @@ its consumed lifted input. That identity includes the monomorphic type graph, name meanings, function signatures and bodies, expression and binding topology, capture identities, and ABI, layout, and runtime-schema boundaries. Literal byte backings and reporting locations are not solver inputs. The current lifted -program always supplies executable expressions, literal contents, and reporting +program always supplies runtime expressions, literal contents, and reporting metadata; an analysis cache never replaces it with an earlier program. Expression, local, and function ids in an analysis record belong to the precisely identified current IR tables, not to an assumed stable source position. Monotype type ids are canonically numbered by first reach from these tables; @@ -27530,7 +27530,7 @@ sharing and cycles while excluding unused store rows. Checked occurrence addresses are used for equality by the solver: the key records their equality partition and exact nominal owners, and the record stores an explicit ordinal into the matching input's occurrence table. Restore binds that ordinal to the -current checked occurrence. It never reuses the previous artifact's address. +current checked occurrence. It never reuses the previous entry's address. All type structure, named definitions, backing authority, dispatch metadata, and ABI boundaries remain in the identity. This whole-analysis boundary is independent of finer-grained specialization body caching. @@ -27550,7 +27550,7 @@ Completed Lambda Solved records contain only the type graph reachable from output annotations. Serialization resolves redirects and assigns compact local ids while preserving cycles and sharing. Discarded unification rows, unreachable lazy Monotype leaves, and solver-private clone-context ids are not output and -must never be persisted. The current executable IR remains unchanged. +must never be persisted. The current lowered IR remains unchanged. Experimental completed-body replay consumes the exact queued request contract @@ -27575,7 +27575,7 @@ when value nodes have been allocated in a different order. Sealed lexically dependent template bodies are complete owned draft output. Their resolved calls, captures and lexical parent relationships remain in that -output; they are never published as independent global specializations. The +output; they are never committed as independent global specializations. The sealed template producer explicitly omits transient dispatch evidence for this completed-local state. Deferred/global requests retain evidence, and consumers require it only in those states. Completed-body replay preserves the local @@ -27593,7 +27593,7 @@ The complete-analysis cache is currently an experiment enabled only by the `lambda_analysis_cache` developer diagnostic. Native object reuse changes the assembled lifted program between builds, so this coarse boundary has not demonstrated useful reuse on the measured applications. Ordinary builds do not -pay for its input fingerprinting, encoding, or publication. Its immutable codec +pay for its input fingerprinting, encoding, or commit. Its immutable codec and explicit validation remain available for finer-grained experiments. @@ -27601,7 +27601,7 @@ This completed-body cache remains an opt-in developer experiment under `completed_body_cache`. Initial restricted coverage reused ordinary bodies but missed costly bodies containing static-data requests. Static-value paths extend that coverage; overall performance and complete production coverage still need -validation. Ordinary builds do not load or publish these body records. +validation. Ordinary builds do not load or commit these body records. Completed-body frontier evidence retains checked target substitutions as explicit module-owned checked types and alias identities, including nested diff --git a/src/backend/dev/CodeExcerpt.zig b/src/backend/dev/CodeExcerpt.zig index 8d28cd7d26d..24e5b6ad911 100644 --- a/src/backend/dev/CodeExcerpt.zig +++ b/src/backend/dev/CodeExcerpt.zig @@ -2236,7 +2236,7 @@ pub fn markNewReferrers(allocator: Allocator, set: *const Set, marked: []bool, f std.debug.assert(marked.len == set.excerpts.len and first <= marked.len); if (first == marked.len) return; // With no seed anywhere in the graph, no dependency can propagate one. - if (std.mem.indexOfScalar(bool, marked, true) == null) return; + if (std.mem.findScalar(bool, marked, true) == null) return; var by_proc = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator); defer by_proc.deinit(); var by_thunk = std.AutoHashMap(lir.ProcIdentity, u32).init(allocator); diff --git a/src/check/const_store.zig b/src/check/const_store.zig index 4e8393b117d..992a3fc5f5e 100644 --- a/src/check/const_store.zig +++ b/src/check/const_store.zig @@ -2414,7 +2414,7 @@ test "module-independent portability excludes nested version-bearing values and const scalar = try store.append(.{ .scalar = .{ .i64 = 17 } }); const list = try store.append(.{ .list = .{ .nodes = &.{scalar} } }); try std.testing.expect(try store.valuePortable(list, null)); - const foreign: NamedType = .{ .module = .{ .bytes = @splat(1) }, .ty = @fromBackingInt(0), .ty_module = .{ .bytes = @splat(2) } }; + const foreign: NamedType = .{ .module = .{ .bytes = @splat(1) }, .ty = @fromBackingInt(1), .ty_module = .{ .bytes = @splat(2) } }; const nominal = try store.append(.{ .nominal = .{ .named_type = foreign, .backing = scalar } }); const boxed = try store.append(.{ .box = nominal }); try std.testing.expect(try store.valuePortable(boxed, .{ .bytes = @splat(3) })); @@ -2425,7 +2425,7 @@ test "module-independent portability excludes nested version-bearing values and try std.testing.expect(try store.type_store.typePortable(&type_names, integer_list, null, null)); const named = try store.type_store.append(.{ .named = .{ .named_type = foreign, - .def = .{ .module = @fromBackingInt(0), .type_name = @fromBackingInt(0) }, + .def = .{ .module = try type_names.internModuleIdentity(&@as([32]u8, @splat(2))), .type_name = try type_names.internTypeName("Foreign") }, .kind = .@"opaque", .args = .{}, } }); diff --git a/src/check/inference_output.zig b/src/check/inference_output.zig index 3004add8466..a5acc52cbe3 100644 --- a/src/check/inference_output.zig +++ b/src/check/inference_output.zig @@ -217,7 +217,7 @@ test "inference reference indexes release partial allocation state" { try testInferenceReferenceBinding(true); } -fn testInferenceReferenceBinding(allocation_failures: bool) !void { +fn testInferenceReferenceBinding(allocation_failures: bool) error{ IncompatibleReferences, MemoryLeakDetected, NondeterministicMemoryUsage, OutOfMemory, SwallowedOutOfMemoryError, TestExpectedEqual, TestExpectedError, TestUnexpectedError }!void { const allocator = std.testing.allocator; var env = try can.ModuleEnv.init(allocator, ""); defer env.deinit(); @@ -260,7 +260,7 @@ fn testInferenceReferenceBinding(allocation_failures: bool) !void { try std.testing.expectError(error.IncompatibleReferences, duplicates.makeBinding(&env, &scope)); } -fn testBindingAllocation(allocator: Allocator, env: *const can.ModuleEnv, scope: *const dependencies.Scope, output: Self) !void { +fn testBindingAllocation(allocator: Allocator, env: *const can.ModuleEnv, scope: *const dependencies.Scope, output: Self) error{ IncompatibleReferences, OutOfMemory }!void { var borrowed = env.*; borrowed.gpa = allocator; var binding = try output.makeBinding(&borrowed, scope); diff --git a/src/check/value_identity.zig b/src/check/value_identity.zig index 30d5bb9ed70..2ffe77ca856 100644 --- a/src/check/value_identity.zig +++ b/src/check/value_identity.zig @@ -532,8 +532,7 @@ const State = struct { try sites.append(self.allocator, .{ .identity = hash.finalResult(), .start = region.start.offset, .end = region.end.offset }); } - fn importedDefinition(self: *const State, view: checked_artifact.ImportedModuleView, def: CIR.Def.Idx) [32]u8 { - _ = self; + fn importedDefinition(view: checked_artifact.ImportedModuleView, def: CIR.Def.Idx) [32]u8 { const summary = view.compile_time_root_identities; var lo: usize = 0; var hi = summary.test_definitions.len; @@ -591,12 +590,12 @@ const State = struct { .e_lookup_external => |lookup| { const view = self.importedByIndex(lookup.module_idx); const target: CIR.Def.Idx = @fromBackingInt(lookup.target_node_idx); - try values.append(self.allocator, self.importedDefinition(view, target)); + try values.append(self.allocator, importedDefinition(view, target)); }, .e_lookup_associated_resolved => |lookup| { const identity = self.inputs.env.moduleIdentityHash(lookup.module_identity); if (!std.meta.eql(identity.*, self.inputs.own_identity)) { - try values.append(self.allocator, self.importedDefinition(self.importedByIdentity(identity.*), lookup.target_def_idx)); + try values.append(self.allocator, importedDefinition(self.importedByIdentity(identity.*), lookup.target_def_idx)); } }, .e_num, @@ -899,7 +898,64 @@ const State = struct { const capture = self.inputs.env.store.getCapture(capture_id); if (!self.pattern_to_def.contains(capture.pattern_idx)) lexical = true; }, - else => {}, + .e_num, + .e_frac_f32, + .e_frac_f64, + .e_dec, + .e_dec_small, + .e_num_from_numeral, + .e_typed_int, + .e_typed_frac, + .e_typed_num_from_numeral, + .e_str_segment, + .e_str, + .e_bytes_literal, + .e_lookup_external, + .e_deferred_import_ref, + .e_lookup_associated_local, + .e_lookup_associated, + .e_lookup_associated_resolved, + .e_lookup_required, + .e_list, + .e_empty_list, + .e_tuple, + .e_match, + .e_if, + .e_call, + .e_record, + .e_empty_record, + .e_block, + .e_tag, + .e_nominal, + .e_nominal_external, + .e_zero_argument_tag, + .e_lambda, + .e_binop, + .e_unary_minus, + .e_field_access, + .e_method_call, + .e_dispatch_call, + .e_interpolation, + .e_structural_eq, + .e_structural_hash, + .e_method_eq, + .e_type_method_call, + .e_type_dispatch_call, + .e_tuple_access, + .e_runtime_error, + .e_crash, + .e_dbg, + .e_expect_err, + .e_expect, + .e_ellipsis, + .e_anno_only, + .e_derived_method, + .e_return, + .e_break, + .e_for, + .e_hosted_lambda, + .e_run_low_level, + => {}, } try appendExprChildren(self.inputs.env, expr, &pending, self.allocator); } diff --git a/src/cli/main.zig b/src/cli/main.zig index 1da98278465..6ed28a830b7 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -9484,7 +9484,7 @@ fn carriesData(excerpt: backend.dev.CodeExcerpt.Excerpt, name: []const u8) bool } /// Test helper: whether the entry at `root` is withheld for program-local symbols. -fn testNamesProgramLocalSymbols(set: *const backend.dev.CodeExcerpt.Set, root: u32) !bool { +fn testNamesProgramLocalSymbols(set: *const backend.dev.CodeExcerpt.Set, root: u32) error{ OutOfMemory, TestExpectedEqual }!bool { var closure = try SpliceClosure.init(std.testing.allocator, set); defer closure.deinit(); const marks = try std.testing.allocator.alloc(bool, set.excerpts.len); @@ -19048,7 +19048,7 @@ fn monotypeGraphCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [42] .{ .name = "Inhabitation memo hits", .count = graph.inhabitation_memo_hits }, .{ .name = "Analysis changed nodes", .count = graph.analysis_changed_nodes }, .{ .name = "Analysis invalidation edges", .count = graph.analysis_invalidation_edges }, - .{ .name = "Analysis peak facts", .count = graph.analysis_peak_facts }, + .{ .name = "Analysis peak entries", .count = graph.analysis_peak_entries }, .{ .name = "Analysis peak payload bytes", .count = graph.analysis_peak_payload_bytes }, .{ .name = "Analysis invalidated groups", .count = graph.analysis_invalidated_groups }, }; diff --git a/src/cli/pack_index.zig b/src/cli/pack_index.zig index b4cea12415d..a6a42079483 100644 --- a/src/cli/pack_index.zig +++ b/src/cli/pack_index.zig @@ -417,7 +417,7 @@ fn equal(comptime T: type, a: T, b: T) bool { inline for (info.field_names) |name| { if (tag == @field(std.meta.Tag(T), name)) break :blk equal(@FieldType(T, name), @field(a, name), @field(b, name)); } - unreachable; + unreachable; // the loop above matches the active tag, which is one of the union's fields }, .optional => |opt| if (a) |value| if (b) |other| equal(opt.child, value, other) else false else b == null, .void => true, diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index b516e97231f..17bc79f3d9a 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -1008,6 +1008,7 @@ const NativeLookupProfile = struct { } }; +/// The native packs a build has acquired, with the offers they link. pub const LoadedPacks = struct { const LinkedOffer = struct { hit: Common.SpecCacheHit, @@ -2800,7 +2801,7 @@ const IndexFixture = struct { try self.store.writeIndexed(.local, identity, &.{ .arena = undefined, .excerpts = artifacts, .source_files = files }, specs); } - fn payloadPath(self: *@This(), value: backend.dev.CodeExcerpt.Excerpt) ![]u8 { + fn payloadPath(self: *@This(), value: backend.dev.CodeExcerpt.Excerpt) error{ Busy, CapacityExceeded, Changed, CorruptCatalog, InvalidArtifact, IoFailure, Missing, OutOfMemory, PathTooLong, TestUnexpectedResult }![]u8 { const bytes = try Index.payload(allocator, value); defer allocator.free(bytes); var record = (try self.store.native.acquire(self.store.indexedIdentity(.local, @splat(0), Index.digest(bytes), .payload), self.store.native.now())) orelse return error.TestUnexpectedResult; @@ -3404,7 +3405,7 @@ test "native publication allocation failures release temporary records and retai try fixture.init(); defer fixture.deinit(); const Check = struct { - fn publish(failing: Allocator) !void { + fn publish(failing: Allocator) error{ AccessDenied, AntivirusInterference, BadPathName, Canceled, DeviceBusy, FileBusy, FileNotFound, FileSystem, FileTooBig, InputOutput, IsDir, NameTooLong, NetworkNotFound, NoDevice, NoHomeDirectory, NoSpaceLeft, NotDir, OperationUnsupported, OutOfMemory, PackWriteFailed, PathAlreadyExists, PermissionDenied, PipeBusy, ProcessFdQuotaExceeded, SymLinkLoop, SystemFdQuotaExceeded, SystemResources, Unexpected, UnrecognizedVolume }!void { var fresh: F = undefined; try fresh.init(); defer fresh.deinit(); @@ -3412,14 +3413,14 @@ test "native publication allocation failures release temporary records and retai writer.allocator = failing; try writer.writeIndexed(.local, F.identity, &.{ .arena = undefined, .excerpts = &.{F.excerpt(1, "code")} }, &.{F.spec(1, 0)}); } - fn load(failing: Allocator, store: *const Store) !void { + fn load(failing: Allocator, store: *const Store) error{ MalformedPack, OutOfMemory, PackDirectoryUnreadable, TestExpectedEqual, UnsupportedPackVersion }!void { var packs = LoadedPacks.init(failing); defer packs.deinit(); try store.loadIdentity(std.testing.io, &packs, .local, F.identity); try packs.indexPacks(); try std.testing.expectEqual(LoadedPacks.IndexedResult.hit, try packs.findIndexed(@splat(1), .dev_code, &.{})); } - fn copy(failing: Allocator, set: *const backend.dev.CodeExcerpt.Set) !void { + fn copy(failing: Allocator, set: *const backend.dev.CodeExcerpt.Set) error{ OutOfMemory, TestExpectedEqual }!void { var owned = try backend.dev.CodeExcerpt.combine(failing, &.{set}); defer owned.deinit(); try std.testing.expectEqual(set.excerpts[0].payload_record, owned.excerpts[0].payload_record); @@ -3643,7 +3644,7 @@ test "pack readers record retention once per invocation without overwriting newe const id = store.packIdentity(.local, @splat(1), @splat(2)); try store.native.write(id, "retained", 1); const Inspect = struct { - fn read(s: *Store, identity: compile.artifacts.Identity) !struct { sequence: u64, used: i64 } { + fn read(s: *Store, identity: compile.artifacts.Identity) error{ Busy, CapacityExceeded, Changed, CorruptCatalog, InvalidArtifact, IoFailure, Missing, OutOfMemory, PathTooLong }!struct { sequence: u64, used: i64 } { var counters = compile.artifacts.catalog.Counters{}; var shard = try compile.artifacts.Shard.openRequired(&s.native, compile.artifacts.Store.shardFor(identity.indexedKey()), false, &counters); defer shard.close(); @@ -3741,7 +3742,7 @@ test "interface requests share same-key reads while independent workers publish self.failed = true; }; } - fn work(self: *@This()) !void { + fn work(self: *@This()) error{ OutOfMemory, TestExpectedEqual, TestUnexpectedResult }!void { const family: [32]u8 = @splat(61); const shared: [32]u8 = @splat(254); for (0..32) |i| { diff --git a/src/compile/coordinator.zig b/src/compile/coordinator.zig index e4625cbd513..651c1110391 100644 --- a/src/compile/coordinator.zig +++ b/src/compile/coordinator.zig @@ -10734,7 +10734,7 @@ test "Coordinator corrupt checked module cache entries compile from source" { // well leaves source as the only admissible input. const inference_cache_dir = try config.getInferenceCacheDir(allocator); defer allocator.free(inference_cache_dir); - const overwritten_inference = try overwriteFilesUnderDir(allocator, cache_dir, inference_cache_dir, .inference, "not an inference cache entry"); + const overwritten_inference = try overwriteFilesUnderDir(allocator, cache_dir, inference_cache_dir, .checked, "not an inference cache entry"); try std.testing.expect(overwritten_inference > 0); const second = try compileAppWithCheckedModuleCache(allocator, cache_dir, "test/str/app_message.roc"); @@ -10764,7 +10764,7 @@ test "Coordinator corrupt checked module cache env relocations compile from sour // well leaves source as the only admissible input. const inference_cache_dir = try config.getInferenceCacheDir(allocator); defer allocator.free(inference_cache_dir); - const overwritten_inference = try overwriteFilesUnderDir(allocator, cache_dir, inference_cache_dir, .inference, "not an inference cache entry"); + const overwritten_inference = try overwriteFilesUnderDir(allocator, cache_dir, inference_cache_dir, .checked, "not an inference cache entry"); try std.testing.expect(overwritten_inference > 0); const second = try compileAppWithCheckedModuleCache(allocator, cache_dir, "test/str/app_message.roc"); @@ -13369,7 +13369,7 @@ test "semantic test discovery survives edits and never hides invalid literals or ; const Run = struct { const Result = struct { hits: u32, errors: bool, observed: bool }; - fn run(gpa: Allocator, directory: []const u8, path: []const u8) !Result { + fn run(gpa: Allocator, directory: []const u8, path: []const u8) error{ AbsolutePlatformPath, AccessDenied, BuiltinArtifactVersionMismatch, BuiltinLowLevelAnnotationMustBeFunction, Canceled, CorruptArtifact, CorruptBuiltinArtifact, CorruptEmbeddedBuiltins, EmptyCode, FileNotFound, HeaderParseFailed, Internal, InvalidPackageHeader, InvalidPlatformPath, IoError, LockedMemoryLimitExceeded, LowLevelOperationsNotFound, MmapFailed, MprotectFailed, NotAnAppHeader, OutOfMemory, RuntimeError, SourceTokenizationFailed, StaleEmbeddedBuiltins, StreamTooLong, SystemResources, ThreadQuotaExceeded, Unexpected, UnsupportedBuiltinAnnotationOnly, UnsupportedPackageSpec, UnsupportedPlatform, UnsupportedPlatformSpec, UnwindRegistrationFailed, VirtualAllocFailed, VirtualProtectFailed }!Result { var capture = StderrCapture{ .allocator = gpa }; defer capture.bytes.deinit(gpa); var context = CoreCtx.os(gpa, gpa, std.testing.io); @@ -13448,7 +13448,7 @@ test "initializer validation certificates defer values without losing errors obs ; const Run = struct { const Result = struct { hits: u32, evaluated: u64, errors: bool, observed: bool }; - fn run(gpa: Allocator, directory: []const u8, app_path: []const u8) !Result { + fn run(gpa: Allocator, directory: []const u8, app_path: []const u8) error{ AbsolutePlatformPath, AccessDenied, BuiltinArtifactVersionMismatch, BuiltinLowLevelAnnotationMustBeFunction, Canceled, CorruptArtifact, CorruptBuiltinArtifact, CorruptEmbeddedBuiltins, EmptyCode, FileNotFound, HeaderParseFailed, Internal, InvalidPackageHeader, InvalidPlatformPath, IoError, LockedMemoryLimitExceeded, LowLevelOperationsNotFound, MmapFailed, MprotectFailed, NotAnAppHeader, OutOfMemory, RuntimeError, SourceTokenizationFailed, StaleEmbeddedBuiltins, StreamTooLong, SystemResources, TestExpectedEqual, TestUnexpectedResult, ThreadQuotaExceeded, Unexpected, UnsupportedBuiltinAnnotationOnly, UnsupportedPackageSpec, UnsupportedPlatform, UnsupportedPlatformSpec, UnwindRegistrationFailed, VirtualAllocFailed, VirtualProtectFailed }!Result { var capture = StderrCapture{ .allocator = gpa }; defer capture.bytes.deinit(gpa); var context = CoreCtx.os(gpa, gpa, std.testing.io); diff --git a/src/compile/discovery_roots.zig b/src/compile/discovery_roots.zig index a744d769cf9..c41bb66b30a 100644 --- a/src/compile/discovery_roots.zig +++ b/src/compile/discovery_roots.zig @@ -12,6 +12,8 @@ const Key = [32]u8; const magic = "ROC_DISCOVERY_ROOTS_1"; const scope = "semantic-test-root-discovery-v1"; +/// The cache key for one test root's discovery result, covering its checked +/// identity, the native target, and the checking context. pub fn rootKey(artifact: *const checked.CheckedModuleArtifact, request: checked.RootRequest) Key { const root = request.compile_time_root orelse base.invariant("semantic validation root lacks checked identity", .{}); var hash = base.Sha256.init(.{}); diff --git a/src/compile/test/demand_driven_ctfe_test.zig b/src/compile/test/demand_driven_ctfe_test.zig index 2284df5b74e..ef9a2e2b6ea 100644 --- a/src/compile/test/demand_driven_ctfe_test.zig +++ b/src/compile/test/demand_driven_ctfe_test.zig @@ -346,7 +346,12 @@ test "a build never reserves the initializer of an unread compile-time root" { const source = function.source orelse continue; switch (source.fn_def) { .local_template, .imported_template, .checked_generated => |actual| try std.testing.expect(!std.meta.eql(template, actual)), - else => {}, + .nested, + .local_hosted, + .imported_hosted, + .parser_runtime, + .encoder_for_runtime, + => {}, } } } diff --git a/src/lir/pack_program.zig b/src/lir/pack_program.zig index 912d8743f48..c2f49aab7f6 100644 --- a/src/lir/pack_program.zig +++ b/src/lir/pack_program.zig @@ -909,7 +909,7 @@ test "native offers record evaluation-owned slots even without Monotype read sum .layout_idx = .u64, .compile_time_root = .{ .module = .{}, - .root = .{ .literal = @fromBackingInt(0) }, + .root = .{ .literal = @fromBackingInt(1) }, .const_locator = null, .role = .{ .failure_message = .{ .failed_field = 0, .message_field = 0, .failed_offset = 0, .message_offset = 0 } }, }, diff --git a/src/postcheck/inhabitation.zig b/src/postcheck/inhabitation.zig index f98e45f4b52..340d70ffc9b 100644 --- a/src/postcheck/inhabitation.zig +++ b/src/postcheck/inhabitation.zig @@ -4,7 +4,7 @@ const AnyAll = @import("collections").AnyAll; const FixedPoint = @import("fixed_point.zig"); const Allocator = std.mem.Allocator; -/// Evaluate producer-authored emptiness equations and publish converged leaves. +/// Evaluate producer-authored emptiness equations and commit converged leaves. pub fn Evaluation(comptime Leaf: type, comptime Context: type) type { return struct { const Tree = AnyAll.Evaluation(Leaf, Context); @@ -42,7 +42,7 @@ pub fn Evaluation(comptime Leaf: type, comptime Context: type) type { scratch.equations.clear(); scratch.items.clearRetainingCapacity(); } - const root_index = try scratch.intern(gpa, context.canonical(root)); + const root_index = try scratch.intern(gpa, context.representative(root)); var cursor: usize = 0; while (cursor < scratch.leaves.items.len) : (cursor += 1) { const leaf = scratch.leaves.items[cursor]; @@ -57,7 +57,7 @@ pub fn Evaluation(comptime Leaf: type, comptime Context: type) type { while (item_index < scratch.items.items.len) : (item_index += 1) { switch (scratch.items.items[item_index]) { .leaf => |child| { - const child_index = try scratch.intern(gpa, context.canonical(child)); + const child_index = try scratch.intern(gpa, context.representative(child)); try scratch.equations.edge(gpa, index, child_index, .identity); }, .group => |group| { @@ -65,7 +65,7 @@ pub fn Evaluation(comptime Leaf: type, comptime Context: type) type { if (group.op == .all) scratch.equations.all(nested); try scratch.equations.edge(gpa, index, nested, .identity); for (scratch.items.items[item_index + 1 ..][0..group.count]) |item| { - const child_index = try scratch.intern(gpa, context.canonical(item.leaf)); + const child_index = try scratch.intern(gpa, context.representative(item.leaf)); try scratch.equations.edge(gpa, nested, child_index, .identity); } item_index += group.count; diff --git a/src/postcheck/lambda_solved/analysis_cache.zig b/src/postcheck/lambda_solved/analysis_cache.zig index a7f93ca3ae1..1dde003b8d0 100644 --- a/src/postcheck/lambda_solved/analysis_cache.zig +++ b/src/postcheck/lambda_solved/analysis_cache.zig @@ -24,7 +24,7 @@ const type_columns = .{ "types", "named_nodes", "erased_digests", "spans", "fiel /// Exact consumed input modulo renaming of Monotype table ids. Expression, /// function and binding tables keep their current identities. Named occurrence /// references are rebound through the matching current input, never an old -/// checked artifact. Their equality partition remains part of the key. +/// checked module. Their equality partition remains part of the key. pub const Identity = struct { key: [32]u8, occurrences: []Mono.NamedType, @@ -33,6 +33,8 @@ pub const Identity = struct { } }; +/// The identity of a lifted program as lambda analysis reads it: its types, +/// named type occurrences, and functions. pub fn identity(allocator: Allocator, input: Lifted.ProgramView) Allocator.Error!Identity { var scratch = std.heap.ArenaAllocator.init(allocator); defer scratch.deinit(); @@ -193,7 +195,7 @@ const Fingerprint = struct { self.pending_occurrences[self.occurrence_count] = v; self.occurrence_count += 1; } - // Nominal owner is semantic. The checked occurrence's raw address + // Nominal owner is part of the meaning. The checked occurrence's raw address // is only used for equality by this solver; preserve that relation // and bind the annotation to its current occurrence on restore. self.bytes(&v.module.bytes); @@ -213,7 +215,7 @@ const Fingerprint = struct { .@"union" => |info| { self.value(info.tag_type.?, std.meta.activeTag(v)); inline for (info.field_names, info.field_types) |field, F| if (std.meta.activeTag(v) == @field(info.tag_type.?, field)) return self.value(F, @field(v, field)); - unreachable; + unreachable; // the loop above matches the active tag, which is one of the union's fields }, .optional => |info| { self.value(bool, v != null); @@ -243,12 +245,27 @@ const Fingerprint = struct { self.bytes(&bytes_); }, .void => {}, - else => @compileError("unsupported analysis identity field: " ++ @typeName(T)), + .type, + .noreturn, + .comptime_float, + .comptime_int, + .undefined, + .null, + .error_union, + .error_set, + .@"fn", + .@"opaque", + .frame, + .@"anyframe", + .vector, + .enum_literal, + .spirv, + => @compileError("unsupported analysis identity field: " ++ @typeName(T)), } } }; -/// Solver annotations only. The executable IR always comes from this build. +/// Solver annotations only. The lowered IR always comes from this build. const Payload = struct { input_identity: [32]u8, types: Type.Store.View, @@ -326,7 +343,7 @@ const CompletedClosure = struct { }, .@"union" => |info| blk: { inline for (info.field_names, info.field_types) |field, F| if (std.meta.activeTag(v) == @field(info.tag_type.?, field)) break :blk @unionInit(T, field, try self.value(F, @field(v, field))); - unreachable; + unreachable; // the loop above matches the active tag, which is one of the union's fields }, .optional => |info| if (v) |present| try self.value(info.child, present) else null, .pointer => |info| blk: { @@ -335,7 +352,28 @@ const CompletedClosure = struct { for (v, out) |source, *dest| dest.* = try self.value(info.child, source); break :blk out; }, - else => v, + .type, + .void, + .bool, + .noreturn, + .int, + .float, + .array, + .comptime_float, + .comptime_int, + .undefined, + .null, + .error_union, + .error_set, + .@"enum", + .@"fn", + .@"opaque", + .frame, + .@"anyframe", + .vector, + .enum_literal, + .spirv, + => v, }; } @@ -422,10 +460,10 @@ const Context = struct { std.mem.writeInt(u32, key[32..36], expr, .little); return key; } - pub fn defaultArtifact(self: *const Context, module: [32]u8, expr: u32) ?[32]u8 { + pub fn defaultDeclaringModule(self: *const Context, module: [32]u8, expr: u32) ?[32]u8 { // Analysis only carries a current input default reference; it never - // persists its executable body. Its validation token is the complete - // input identity, rather than a newly reconstructed checked artifact. + // persists its lowered body. Its validation token is the complete + // input identity, rather than a newly reconstructed checked module. return if (self.defaults.contains(defaultKey(module, expr))) self.key else null; } pub fn validModuleIdentity(self: *const Context, module: [32]u8) bool { @@ -544,7 +582,7 @@ fn validate(allocator: Allocator, input: Lifted.ProgramView, p: Payload) Codec.D for (p.fn_tys) |ty| if (t.rootContent(ty) != .func) return error.InvalidSummary; } -/// Encode only annotations; no source or executable payload is persisted. +/// Encode only annotations; no source or lowered payload is persisted. pub fn encode(allocator: Allocator, program: *Ast.Program, id: *const Identity) Allocator.Error!?[]u8 { var context = try Context.init(allocator, program.lifted.view(), id); defer context.deinit(); @@ -642,13 +680,13 @@ fn emptyInput(allocator: Allocator) Lifted.Program { ); } -fn literalInput(allocator: Allocator, text: []const u8) !Lifted.Program { +fn literalInput(allocator: Allocator, text: []const u8) Allocator.Error!Lifted.Program { var input = emptyInput(allocator); errdefer input.deinit(); const ty = try input.types.internPrimitive(&input.names, .str); const literal = try input.addStringLiteral(text); const body = try input.addExpr(.{ .ty = ty, .data = .{ .str_lit = literal } }); - _ = try input.addFn(.{ .symbol = @fromBackingInt(0), .args = .empty(), .captures = .empty(), .body = .{ .roc = body }, .ret = ty }); + _ = try input.addFn(.{ .symbol = @fromBackingInt(3), .args = .empty(), .captures = .empty(), .body = .{ .roc = body }, .ret = ty }); return input; } @@ -700,7 +738,7 @@ test "lambda analysis identity includes callable topology and rejects mismatched for (0..bytes.len) |end| try std.testing.expectEqual(null, try restore(gpa, ¤t, &id, bytes[0..end])); } -fn analysisAllocationRoundTrip(gpa: Allocator) !void { +fn analysisAllocationRoundTrip(gpa: Allocator) Allocator.Error!void { var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); defer first.deinit(); const id = try identity(gpa, first.lifted.view()); @@ -719,17 +757,17 @@ test "lambda analysis allocation failures preserve input ownership" { try std.testing.checkAllAllocationFailures(std.testing.allocator, analysisAllocationRoundTrip, .{}); } -fn recursiveInput(allocator: Allocator) !Lifted.Program { +fn recursiveInput(allocator: Allocator) Allocator.Error!Lifted.Program { return recursiveInputWithPadding(allocator, false); } -fn recursiveInputWithPadding(allocator: Allocator, padding: bool) !Lifted.Program { +fn recursiveInputWithPadding(allocator: Allocator, padding: bool) Allocator.Error!Lifted.Program { var input = emptyInput(allocator); errdefer input.deinit(); if (padding) _ = try input.types.internPrimitive(&input.names, .bool); const root = try input.types.beginRecursive(); const callback = try input.types.add(.{ .func = .{ .args = .empty(), .ret = root.ty } }); input.types.finishRecursive(root, .{ .record = try input.types.addFields(&.{.{ .name = try input.names.internRecordFieldLabel("next"), .ty = callback, .default = null }}) }); - const local = try input.addLocal(@fromBackingInt(0), root.ty); + const local = try input.addLocal(@fromBackingInt(2), root.ty); const args = try input.addTypedLocalSpan(&.{.{ .local = local, .ty = root.ty }}); const body = try input.addExpr(.{ .ty = root.ty, .data = .{ .local = local } }); _ = try input.addFn(.{ .symbol = @fromBackingInt(1), .args = args, .captures = .empty(), .body = .{ .roc = body }, .ret = root.ty }); @@ -761,7 +799,9 @@ test "lambda analysis preserves recursive callable relations and rejects redirec var bad = payloadOf(&first, key); const vars = try gpa.dupe(Type.StoredNode, bad.types.vars); defer gpa.free(vars); - vars[0] = .{ .link = @fromBackingInt(0) }; + // A variable linked to itself is a cycle no encoder produces. + const self_linked = 0; + vars[self_linked] = .{ .link = @fromBackingInt(self_linked) }; bad.types.vars = vars; var context = try Context.init(gpa, first.lifted.view(), &id); defer context.deinit(); @@ -776,12 +816,13 @@ test "lambda analysis identity distinguishes callbacks with the same signature" const gpa = std.testing.allocator; var input = try literalInput(gpa, "first callback"); defer input.deinit(); - const first = input.view().fns[0]; + const first_index = 0; + const first = input.view().fns[first_index]; const literal = try input.addStringLiteral("second callback"); const body = try input.addExpr(.{ .ty = first.ret, .data = .{ .str_lit = literal } }); const second = try input.addFn(.{ .symbol = @fromBackingInt(1), .args = .empty(), .captures = .empty(), .body = .{ .roc = body }, .ret = first.ret }); const signature = try input.types.add(.{ .func = .{ .args = .empty(), .ret = first.ret } }); - const closure = try input.addExpr(.{ .ty = signature, .data = .{ .fn_ref = .{ .fn_id = @fromBackingInt(0), .captures = .empty() } } }); + const closure = try input.addExpr(.{ .ty = signature, .data = .{ .fn_ref = .{ .fn_id = @fromBackingInt(first_index), .captures = .empty() } } }); _ = try input.addFn(.{ .symbol = @fromBackingInt(2), .args = .empty(), .captures = .empty(), .body = .{ .roc = closure }, .ret = signature }); const before = try identity(gpa, input.view()); defer before.deinit(gpa); @@ -827,7 +868,7 @@ test "lambda analysis identity ignores type numbering and unreachable types" { try std.testing.expect(first.getFnAt(0).ret != second.getFnAt(0).ret); } -fn occurrenceInput(allocator: Allocator, version: u8, shared: bool, owner: u8) !Lifted.Program { +fn occurrenceInput(allocator: Allocator, version: u8, shared: bool, owner: u8) Allocator.Error!Lifted.Program { var input = emptyInput(allocator); errdefer input.deinit(); if (version != 0) _ = try input.types.internPrimitive(&input.names, .bool); @@ -844,7 +885,7 @@ fn occurrenceInput(allocator: Allocator, version: u8, shared: bool, owner: u8) ! const first = try input.types.add(.{ .named = named }); if (!shared) named.named_type.ty = @fromBackingInt(@backingInt(named.named_type.ty) + 1); const second = try input.types.add(.{ .named = named }); - const a = try input.addLocal(@fromBackingInt(0), first); + const a = try input.addLocal(@fromBackingInt(3), first); const b = try input.addLocal(@fromBackingInt(1), second); const args = try input.addTypedLocalSpan(&.{ .{ .local = a, .ty = first }, .{ .local = b, .ty = second } }); const body = try input.addExpr(.{ .ty = second, .data = .{ .local = b } }); @@ -894,7 +935,7 @@ test "lambda analysis preserves checked occurrence equality and nominal ownershi try std.testing.expect(!std.mem.eql(u8, &a.key, &c.key)); } -fn capturedInput(allocator: Allocator, binder: ?u32) !Lifted.Program { +fn capturedInput(allocator: Allocator, binder: ?u32) Allocator.Error!Lifted.Program { var input = emptyInput(allocator); errdefer input.deinit(); const ty = try input.types.internPrimitive(&input.names, .str); @@ -904,7 +945,7 @@ fn capturedInput(allocator: Allocator, binder: ?u32) !Lifted.Program { input.locals.set(@backingInt(local), local_data); const captures = try input.addTypedLocalSpan(&.{.{ .local = local, .ty = ty }}); const body = try input.addExpr(.{ .ty = ty, .data = .{ .local = local } }); - _ = try input.addFn(.{ .symbol = @fromBackingInt(0), .args = .empty(), .captures = captures, .body = .{ .roc = body }, .ret = ty }); + _ = try input.addFn(.{ .symbol = @fromBackingInt(2), .args = .empty(), .captures = captures, .body = .{ .roc = body }, .ret = ty }); return input; } diff --git a/src/postcheck/lambda_solved/solve.zig b/src/postcheck/lambda_solved/solve.zig index 39e94e6ca6d..fa3cac8e5d3 100644 --- a/src/postcheck/lambda_solved/solve.zig +++ b/src/postcheck/lambda_solved/solve.zig @@ -238,7 +238,7 @@ pub const WorkCounters = struct { compatibility_cycles: u64 = 0, compatibility_hits: u64 = 0, compatibility_invalidations: u64 = 0, - compatibility_peak_facts: u64 = 0, + compatibility_peak_entries: u64 = 0, compatibility_peak_payload_bytes: u64 = 0, compatibility_walks: u64 = 0, compatibility_changed_nodes: u64 = 0, @@ -260,7 +260,7 @@ pub fn run( } /// The optional cache records only completed analysis, under the exact lifted -/// input consumed by this solver. The current program remains the executable IR. +/// input consumed by this solver. The current program remains the lowered IR. pub fn runWithCache(allocator: Allocator, lifted: Lifted.Program, cache: ?Common.InterfaceSummaryCache) Common.LowerError!Ast.Program { // `Ast.Program.init` cannot fail and takes ownership of `lifted`, so // `program.deinit()` is the only cleanup a later error needs. @@ -2378,15 +2378,15 @@ const Solver = struct { const root_b = self.program.types.rootCompressed(b); if (root_a == root_b) return true; self.invalidateCompatibilityProofs(); - if (self.compatibility_proofs.get(UnifyPair.init(root_a, root_b))) |fact| { + if (self.compatibility_proofs.get(UnifyPair.init(root_a, root_b))) |proven| { self.countWork(.compatibility_hits, 1); - return fact.value != 0; + return proven.value != 0; } const epoch = self.program.types.analysis_changes.epoch; const dependencies = &self.compatibility_dependencies; dependencies.clearRetainingCapacity(); self.countWork(.compatibility_walks, 1); - var publishable = true; + var committable = true; const work = &self.compatibility_work; work.clearRetainingCapacity(); const visited = &self.compatibility_visited; @@ -2406,12 +2406,12 @@ const Solver = struct { if (raw_left == .mono and raw_right == .mono and raw_left.mono.id == raw_right.mono.id) continue; if (try self.canLinkUntouchedLeafToInstance(left, raw_left, pair.second, right)) { // This answer also observes clone-context ownership. Keep its - // direct lazy proof, but do not publish a structural certificate. - publishable = false; + // direct lazy proof, but do not commit a structural certificate. + committable = false; continue; } if (try self.canLinkUntouchedLeafToInstance(right, raw_right, pair.first, left)) { - publishable = false; + committable = false; continue; } const key = UnifyPair.init(left, right); @@ -2419,10 +2419,10 @@ const Solver = struct { self.countWork(.compatibility_cycles, 1); continue; } - if (epoch == self.program.types.analysis_changes.epoch) if (self.compatibility_proofs.get(key)) |fact| { - try dependencies.append(self.allocator, .{ .proof = fact.proof }); + if (epoch == self.program.types.analysis_changes.epoch) if (self.compatibility_proofs.get(key)) |proven| { + try dependencies.append(self.allocator, .{ .proof = proven.proof }); self.countWork(.compatibility_hits, 1); - if (fact.value == 0) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); + if (proven.value == 0) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable); continue; }; self.countWork(.compatibility_pairs, 1); @@ -2441,35 +2441,35 @@ const Solver = struct { if (right_content.named.backing) |backing| try work.append(self.allocator, .{ .first = left, .second = backing.ty }); continue; } - if (left_tag != right_tag) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); + if (left_tag != right_tag) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable); switch (left_content) { .link, .mono, .unbound, .forall => unreachable, // resolvedContentAt follows links and monos, and unbound/forall pairs continued above - .primitive => |prim| if (prim != right_content.primitive) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable), + .primitive => |prim| if (prim != right_content.primitive) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable), .zst, .hole, .lambda_set, .erased, .tag_union => {}, .list => |elem| try work.append(self.allocator, .{ .first = elem, .second = right_content.list }), .box => |elem| try work.append(self.allocator, .{ .first = elem, .second = right_content.box }), .tuple => |items| { const right_items = right_content.tuple; - if (items.count() != right_items.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); + if (items.count() != right_items.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable); for (0..items.count()) |index| { try work.append(self.allocator, .{ .first = self.program.types.spanItem(items, index), .second = self.program.types.spanItem(right_items, index) }); } }, .record => |fields| { const right_fields = right_content.record; - if (fields.count() != right_fields.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); + if (fields.count() != right_fields.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable); for (0..fields.count()) |index| { const field = self.program.types.fieldItem(fields, index); const right_field = self.program.types.fieldItem(right_fields, index); - if (field.name != right_field.name) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); - if ((field.value_ty == null) != (right_field.value_ty == null)) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); + if (field.name != right_field.name) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable); + if ((field.value_ty == null) != (right_field.value_ty == null)) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable); try work.append(self.allocator, .{ .first = field.ty, .second = right_field.ty }); if (field.value_ty) |value_ty| try work.append(self.allocator, .{ .first = value_ty, .second = right_field.value_ty.? }); } }, .func => |func| { const right_func = right_content.func; - if (func.args.count() != right_func.args.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); + if (func.args.count() != right_func.args.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable); for (0..func.args.count()) |index| { try work.append(self.allocator, .{ .first = self.program.types.spanItem(func.args, index), .second = self.program.types.spanItem(right_func.args, index) }); } @@ -2481,7 +2481,7 @@ const Solver = struct { // of their own (iterator views, opaque views); only a pair // of one declaration is examined further. if (!std.meta.eql(named.def, right_named.def) or named.kind != right_named.kind or named.builtin_owner != right_named.builtin_owner) continue; - if (named.args.count() != right_named.args.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, publishable); + if (named.args.count() != right_named.args.count()) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable); for (0..named.args.count()) |index| { try work.append(self.allocator, .{ .first = self.program.types.spanItem(named.args, index), .second = self.program.types.spanItem(right_named.args, index) }); } @@ -2490,13 +2490,13 @@ const Solver = struct { } // All visited pairs now hold, including mutually recursive ones. // A failed or graph-mutating walk never retains its assumptions. - if (publishable and epoch == self.program.types.analysis_changes.epoch) { + if (committable and epoch == self.program.types.analysis_changes.epoch) { self.compatibility_entries.clearRetainingCapacity(); var entries = visited.keyIterator(); while (entries.next()) |key| { - if (!self.compatibility_proofs.facts.contains(key.*)) try self.compatibility_entries.append(self.allocator, .{ .key = key.*, .value = 1 }); + if (!self.compatibility_proofs.proven.contains(key.*)) try self.compatibility_entries.append(self.allocator, .{ .key = key.*, .value = 1 }); } - try self.compatibility_proofs.publish(self.allocator, self.compatibility_entries.items, dependencies.items); + try self.compatibility_proofs.commit(self.allocator, self.compatibility_entries.items, dependencies.items); self.recordCompatibilityStats(); } return true; @@ -2505,17 +2505,17 @@ const Solver = struct { fn recordCompatibilityStats(self: *Solver) void { if (comptime @import("builtin").is_test or diagnostics.available) { if (self.counters) |counters| { - counters.compatibility_peak_facts = @max(counters.compatibility_peak_facts, self.compatibility_proofs.facts.count()); + counters.compatibility_peak_entries = @max(counters.compatibility_peak_entries, self.compatibility_proofs.proven.count()); counters.compatibility_peak_payload_bytes = @max(counters.compatibility_peak_payload_bytes, self.compatibility_proofs.retainedPayloadBytes()); } } } /// A failed walk proves only the requested failure, never the provisional - /// successes on its visited set. Retain that counterexample as one fact. - fn rejectCompatibility(self: *Solver, key: UnifyPair, epoch: u64, publishable: bool) Allocator.Error!bool { - if (publishable and epoch == self.program.types.analysis_changes.epoch) { - try self.compatibility_proofs.publish(self.allocator, &.{.{ .key = key, .value = 0 }}, self.compatibility_dependencies.items); + /// successes on its visited set. Retain that counterexample as one proven. + fn rejectCompatibility(self: *Solver, key: UnifyPair, epoch: u64, committable: bool) Allocator.Error!bool { + if (committable and epoch == self.program.types.analysis_changes.epoch) { + try self.compatibility_proofs.commit(self.allocator, &.{.{ .key = key, .value = 0 }}, self.compatibility_dependencies.items); self.recordCompatibilityStats(); } return false; @@ -2945,7 +2945,7 @@ const Solver = struct { self.exact_clone_dependencies.clearRetainingCapacity(); try self.exact_clone_work.append(self.allocator, key); const matches = try self.walkExactMonoClone(); - try self.exact_clone_proofs.publish(self.allocator, &.{.{ .key = key, .value = @intFromBool(matches) }}, self.exact_clone_dependencies.items); + try self.exact_clone_proofs.commit(self.allocator, &.{.{ .key = key, .value = @intFromBool(matches) }}, self.exact_clone_dependencies.items); return matches; } @@ -6135,7 +6135,7 @@ test "issue 12595 shared recursive row payload compatibility work" { try std.testing.expectEqual(depth + 1, counts.compatibility_pairs); try std.testing.expectEqual(width - 1, counts.compatibility_hits); try std.testing.expectEqual(@as(u64, 1), counts.compatibility_cycles); - try std.testing.expectEqual(depth + 1, counts.compatibility_peak_facts); + try std.testing.expectEqual(depth + 1, counts.compatibility_peak_entries); } } } @@ -6201,9 +6201,9 @@ test "issue 12595 failed cyclic compatibility retains no assumptions" { // The LIFO walk cuts the cycle before finding the conflicting sibling. try std.testing.expect(!try solver.typesCompatible(a, b)); try std.testing.expectEqual(@as(u64, 1), counts.compatibility_cycles); - try std.testing.expectEqual(@as(usize, 1), solver.compatibility_proofs.facts.count()); + try std.testing.expectEqual(@as(usize, 1), solver.compatibility_proofs.proven.count()); try std.testing.expectEqual(@as(u2, 0), solver.compatibility_proofs.get(UnifyPair.init(a, b)).?.value); - try std.testing.expect(!solver.compatibility_proofs.facts.contains(UnifyPair.init(a_list, b_list))); + try std.testing.expect(!solver.compatibility_proofs.proven.contains(UnifyPair.init(a_list, b_list))); try std.testing.expect(!try solver.typesCompatible(a_list, b_list)); try std.testing.expectEqual(@as(u64, 1), counts.compatibility_hits); } @@ -6224,9 +6224,9 @@ test "issue 12595 compatibility that expands lazy types retains no proofs" { const lazy = try solver.lowerTypeFresh(mono_list); try std.testing.expect(try solver.typesCompatible(lazy, materialized)); try std.testing.expect(counts.expansions > 0); - try std.testing.expectEqual(@as(usize, 0), solver.compatibility_proofs.facts.count()); + try std.testing.expectEqual(@as(usize, 0), solver.compatibility_proofs.proven.count()); try std.testing.expect(try solver.typesCompatible(lazy, materialized)); - try std.testing.expect(solver.compatibility_proofs.facts.count() > 0); + try std.testing.expect(solver.compatibility_proofs.proven.count() > 0); } test "issue 12616 completed compatibility retains disjoint proofs and still propagates erasure" { @@ -6242,7 +6242,7 @@ test "issue 12616 completed compatibility retains disjoint proofs and still prop const a = try types.add(.{ .list = number }); const b = try types.add(.{ .list = number }); try std.testing.expect(try solver.typesCompatible(a, b)); - try std.testing.expectEqual(index + 1, solver.compatibility_proofs.facts.count()); + try std.testing.expectEqual(index + 1, solver.compatibility_proofs.proven.count()); } // Erasure digests the capture symbol, so it is distinct from the lambda symbol. const captured_local: Lifted.LocalId = @fromBackingInt(1); @@ -6622,7 +6622,7 @@ test "issue 12616 compatibility retains interleaved recursive proofs across unre const bytes = solver.compatibility_proofs.retainedPayloadBytes(); const allocated = allocations.allocations; const total_allocated_bytes = allocations.allocated_bytes; - std.debug.print("12616 lambda solved: pairs={d} facts={d} payload_bytes={d} query_owned_bytes={d} query_allocations={d}; 512 A/B repetitions allocate and expand nothing\n", .{ initial, solver.compatibility_proofs.facts.count(), bytes, allocations.allocated_bytes - allocations.freed_bytes - live_before, allocated - allocations_before }); + std.debug.print("12616 lambda solved: pairs={d} facts={d} payload_bytes={d} query_owned_bytes={d} query_allocations={d}; 512 A/B repetitions allocate and expand nothing\n", .{ initial, solver.compatibility_proofs.proven.count(), bytes, allocations.allocated_bytes - allocations.freed_bytes - live_before, allocated - allocations_before }); for (0..512) |_| { try std.testing.expect(try solver.typesCompatible(a, b)); try std.testing.expect(try solver.typesCompatible(c, d)); @@ -6664,11 +6664,11 @@ test "issue 12616 compatibility publication is atomic on allocation failure" { const b = try types.add(.unbound); try types.set(a, .{ .tuple = try types.addSpan(&.{ scalar, a }) }); try types.set(b, .{ .tuple = try types.addSpan(&.{ scalar, b }) }); - const retained = solver.compatibility_proofs.facts.count(); + const retained = solver.compatibility_proofs.proven.count(); failing.fail_index = failing.alloc_index + offset; _ = solver.typesCompatible(a, b) catch |err| { try std.testing.expectEqual(error.OutOfMemory, err); - try std.testing.expectEqual(retained, solver.compatibility_proofs.facts.count()); + try std.testing.expectEqual(retained, solver.compatibility_proofs.proven.count()); failing.fail_index = std.math.maxInt(usize); try std.testing.expect(try solver.typesCompatible(old_a, old_b)); try std.testing.expect(try solver.typesCompatible(a, b)); diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index a1dd92a5442..b921a67b012 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -1,6 +1,7 @@ //! Persistent, request-relative interface constraints. No solver or producer //! type-store identity crosses this boundary. const std = @import("std"); +const collections = @import("collections"); const check = @import("check"); const base = @import("base"); const Type = @import("type.zig"); @@ -13,9 +14,9 @@ const invariant = base.invariant; const magic = "RIFSUM04"; const frozen_magic = "RIFRZ001"; -/// A checked occurrence either names its immutable artifact directly, or -/// names a closed checked type by canonical content. The latter has no -/// instantiation identity and can be bound to any current published root +/// A checked occurrence either names its immutable checked module directly, or +/// names a closed checked type by normalized content. The latter has no +/// instantiation identity and can be bound to any current committed root /// with that exact key; its nominal owner remains content-addressed. pub const NamedOccurrence = union(enum) { exact: struct { owner: [32]u8, occurrence: [32]u8, ty: u32 }, @@ -40,7 +41,7 @@ fn invalidSummary(comptime line: u32) error{InvalidSummary} { } /// Encode only the immutable graph reachable from the settled relation. The -/// context supplies exact checked artifact addresses for executable defaults. +/// context supplies exact checked module addresses for runtime defaults. pub fn encode(allocator: Allocator, types: *Type.Store, ns: *const names.NameStore, leaves: []const Type.TypeId, summary: Summary, context: anytype) EncodeError![]u8 { return encodeWithStats(allocator, types, ns, leaves, summary, context, null); } @@ -81,7 +82,7 @@ pub fn encodeWithStats(allocator: Allocator, types: *Type.Store, ns: *const name /// References in temporary decoded data name a request leaf or a table node, /// never a live Type.Store slot. The complete record is validated before the -/// destination transaction starts. Its result is published only after commit. +/// destination transaction starts. Its result becomes visible only after commit. pub fn decode(arena: Allocator, types: *Type.Store, ns: *names.NameStore, leaves: []const Type.TypeId, bytes: []const u8, context: anytype) DecodeError!Summary { return decodeForRequest(arena, types, ns, leaves, bytes, context, null, true, null); } @@ -237,7 +238,7 @@ fn copyFrozen(comptime T: type, arena: Allocator, value: T) Allocator.Error!T { }, .@"union" => |info| blk: { inline for (info.field_names, info.field_types) |field, F| if (std.meta.activeTag(value) == @field(info.tag_type.?, field)) break :blk @unionInit(T, field, try copyFrozen(F, arena, @field(value, field))); - unreachable; + unreachable; // the loop above matches the active tag, which is one of the union's fields }, .optional => |info| if (value) |v| try copyFrozen(info.child, arena, v) else null, .pointer => |info| blk: { @@ -254,7 +255,22 @@ fn copyFrozen(comptime T: type, arena: Allocator, value: T) Allocator.Error!T { break :blk out; }, .@"enum", .int, .float, .bool, .void => value, - else => @compileError("unsupported frozen payload field: " ++ @typeName(T)), + .type, + .noreturn, + .comptime_float, + .comptime_int, + .undefined, + .null, + .error_union, + .error_set, + .@"fn", + .@"opaque", + .frame, + .@"anyframe", + .vector, + .enum_literal, + .spirv, + => @compileError("unsupported frozen payload field: " ++ @typeName(T)), }; } @@ -279,7 +295,22 @@ fn relocateFrozen(comptime T: type, value: anytype, ids: []const Type.TypeId) vo }, .array => |info| for (value) |*v| relocateFrozen(info.child, v, ids), .@"enum", .int, .float, .bool, .void => {}, - else => @compileError("unsupported frozen payload field: " ++ @typeName(T)), + .type, + .noreturn, + .comptime_float, + .comptime_int, + .undefined, + .null, + .error_union, + .error_set, + .@"fn", + .@"opaque", + .frame, + .@"anyframe", + .vector, + .enum_literal, + .spirv, + => @compileError("unsupported frozen payload field: " ++ @typeName(T)), } } @@ -335,7 +366,22 @@ const TypeEdges = struct { }, .array => |info| for (value) |v| try self.collect(info.child, v), .@"enum", .int, .float, .bool, .void => {}, - else => @compileError("unsupported summary graph field"), + .type, + .noreturn, + .comptime_float, + .comptime_int, + .undefined, + .null, + .error_union, + .error_set, + .@"fn", + .@"opaque", + .frame, + .@"anyframe", + .vector, + .enum_literal, + .spirv, + => @compileError("unsupported summary graph field"), } } }; @@ -471,7 +517,7 @@ fn Writer(comptime Context: type) type { const Self = @This(); context: Context, stats: Stats = .{}, - references: std.AutoHashMapUnmanaged(Type.TypeId, u32) = .empty, + references: collections.DenseMapUnmanaged(Type.TypeId, u32) = .empty, pending: std.ArrayList(Type.TypeId) = .empty, leaf_buckets: std.AutoHashMapUnmanaged([32]u8, u32) = .empty, leaf_next: std.ArrayList(?u32) = .empty, @@ -601,10 +647,10 @@ fn Writer(comptime Context: type) type { if (T == Type.FieldDefault) { self.stats.defaults += 1; const identity = self.names.moduleIdentityBytes(value.module).*; - const artifact = self.context.defaultArtifact(identity, value.expr_node) orelse - invariant("published field default has no declaring checked artifact or archived expression", .{}); + const declaring_module = self.context.defaultDeclaringModule(identity, value.expr_node) orelse + invariant("committed field default has no declaring checked module or archived expression", .{}); try self.write([32]u8, identity); - try self.write([32]u8, artifact); + try self.write([32]u8, declaring_module); return self.write(u32, value.expr_node); } switch (@typeInfo(T)) { @@ -694,10 +740,10 @@ fn Reader(comptime Context: type) type { if (T == Type.FieldDefault) { self.stats.defaults += 1; const identity = try self.read([32]u8); - const artifact = try self.read([32]u8); + const declaring_module = try self.read([32]u8); const expr = try self.read(u32); - const current = self.context.defaultArtifact(identity, expr) orelse return invalidSummary(@src().line); - if (!std.mem.eql(u8, &artifact, ¤t)) return invalidSummary(@src().line); + const current = self.context.defaultDeclaringModule(identity, expr) orelse return invalidSummary(@src().line); + if (!std.mem.eql(u8, &declaring_module, ¤t)) return invalidSummary(@src().line); return .{ .module = try self.names.internModuleIdentity(&identity), .expr_node = expr }; } if (T == Solve.NodeId or T == Solve.MaybeNodeId or T == Solve.FieldKindId) { @@ -789,13 +835,13 @@ fn Reader(comptime Context: type) type { } const TestProvenance = struct { - artifact: [32]u8 = @splat(7), + declaring_module: [32]u8 = @splat(7), available: bool = true, closed_key: ?[32]u8 = null, occurrence_version: u8 = 0, - pub fn defaultArtifact(self: @This(), identity: [32]u8, expr: u32) ?[32]u8 { + pub fn defaultDeclaringModule(self: @This(), identity: [32]u8, expr: u32) ?[32]u8 { if (!self.available or identity[0] != 1 or expr > 2) return null; - return self.artifact; + return self.declaring_module; } pub fn validModuleIdentity(self: @This(), identity: [32]u8) bool { return self.available and identity[0] == 1; @@ -838,7 +884,7 @@ const FrozenTestPayload = struct { optional: Type.MaybeTypeId, }; -fn frozenTestBytes(allocator: Allocator) ![]u8 { +fn frozenTestBytes(allocator: Allocator) error{ NotRepresentable, OutOfMemory }![]u8 { var ns = names.NameStore.init(allocator); defer ns.deinit(); var source = Type.Store.init(allocator); @@ -916,7 +962,7 @@ test "frozen specialization payload rejects corrupt schema references and trunca } } -fn frozenAllocationRoundTrip(gpa: Allocator) !void { +fn frozenAllocationRoundTrip(gpa: Allocator) error{ InvalidSummary, NotRepresentable, OutOfMemory, TestExpectedEqual, TestUnexpectedResult }!void { const bytes = try frozenTestBytes(gpa); defer gpa.free(bytes); var ns = names.NameStore.init(gpa); @@ -1072,9 +1118,14 @@ test "persistent interface evidence contexts preserve types and exact procedure try std.testing.expect(!std.meta.eql(procedure, Ast.fnEvidenceContextDigest(evidence, &.{}, null, &contexts))); } +/// The root naming the node at `index` of a test summary's `nodes`. +fn testNodeRoot(index: u32) Solve.NodeId { + return @fromBackingInt(index); +} + fn typeSummary(ty: Type.TypeId, nodes: *[1]Constraints.Node) Summary { nodes.* = .{.{ .mono = ty }}; - return .{ .constraints = .{ .roots = &.{@fromBackingInt(0)}, .nodes = nodes, .open_nodes = &.{}, .kinds = &.{} } }; + return .{ .constraints = .{ .roots = &.{testNodeRoot(0)}, .nodes = nodes, .open_nodes = &.{}, .kinds = &.{} } }; } test "persistent interface composite graph preserves sharing defaults and representation metadata" { @@ -1153,7 +1204,7 @@ test "persistent interface composite graph preserves sharing defaults and repres try std.testing.expectEqual(restored, replay.constraints.nodes[0].mono); try std.testing.expectEqual(size, dest.types.len()); } - try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &dest, &dest_names, &.{input}, bytes, TestProvenance{ .artifact = @splat(8) })); + try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &dest, &dest_names, &.{input}, bytes, TestProvenance{ .declaring_module = @splat(8) })); try std.testing.expectError(error.InvalidSummary, decode(arena.allocator(), &dest, &dest_names, &.{input}, bytes, TestProvenance{ .available = false })); try std.testing.expectEqual(size, dest.types.len()); var other_field = field; @@ -1230,7 +1281,7 @@ test "persistent interface open variables and defaulted field kinds remain relat defer store.deinit(); const module = try ns.internModuleIdentity(&@as([32]u8, @splat(1))); const summary: Summary = .{ .constraints = .{ - .roots = &.{ @fromBackingInt(0), @fromBackingInt(0) }, + .roots = &.{ testNodeRoot(0), testNodeRoot(0) }, .nodes = &.{.{ .open = 0 }}, .open_nodes = &.{.{ .content = .{ .unresolved = Solve.InstVariable.checkedVariable(null, null) } }}, .kinds = &.{.{ .resolved = .{ .defaulted = .{ .module = module, .expr_node = 1 } }, .cells = null }}, @@ -1245,7 +1296,7 @@ test "persistent interface open variables and defaulted field kinds remain relat try std.testing.expectEqual(@as(u32, 1), result.kinds[0].resolved.?.defaulted.expr_node); } -fn allocationFailureRoundTrip(gpa: Allocator) !void { +fn allocationFailureRoundTrip(gpa: Allocator) error{ InvalidSummary, NotRepresentable, OutOfMemory, TestExpectedEqual, TestUnexpectedResult }!void { var ns = names.NameStore.init(gpa); defer ns.deinit(); var source = Type.Store.init(gpa); @@ -1409,7 +1460,7 @@ test "persistent parametric interfaces retain explicit holes and reject unfinish try std.testing.expectEqual(Type.Primitive.str, (try graph.content((try graph.content(instance[0])).list)).primitive); } const parameter: Summary = .{ .constraints = .{ - .roots = &.{@fromBackingInt(0)}, + .roots = &.{testNodeRoot(0)}, .nodes = &.{.{ .open = 0 }}, .open_nodes = &.{.{ .content = .{ .unresolved = Solve.InstVariable.interfaceParameter() } }}, .kinds = &.{}, diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 9c0db1420a9..46b335f58e4 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -417,7 +417,7 @@ pub const ComptimeDemand = struct { /// /// The state persists across lowering drains. A discovery request may expose /// a parked declaration before an evaluation request completes its body. -/// Explicit body-publication events invalidate that declaration's summary and +/// Explicit body-commit events invalidate that declaration's summary and /// revisit it if it was already reachable. const ComptimeDemandState = struct { const Range = struct { start: u32, len: u32 }; @@ -433,7 +433,7 @@ const ComptimeDemandState = struct { /// two disjoint ranges of one id space. const nested_bit: u32 = 1 << 31; - /// Previously reached declarations whose final bodies were published + /// Previously reached declarations whose final bodies were committed /// after an earlier demand round inspected their parked declarations. changed_reached: std.ArrayList(u32) = .empty, def_summaries: std.ArrayList(?Summary) = .empty, @@ -608,10 +608,10 @@ const ComptimeDemandState = struct { if (list.items.len < len) try list.appendNTimes(allocator, value, len - list.items.len); } - /// Body publication is the producer event invalidating an earlier + /// A body commit is the producer event invalidating an earlier /// summary of a parked declaration. Unreached bodies need no traversal; /// reached ones join the next round at the changed definition itself. - fn bodyPublished(self: *ComptimeDemandState, allocator: Allocator, fn_id: Ast.FnId) Allocator.Error!void { + fn bodyCommitted(self: *ComptimeDemandState, allocator: Allocator, fn_id: Ast.FnId) Allocator.Error!void { const raw = @backingInt(fn_id); if (raw >= self.fn_items.items.len) return; const item = self.fn_items.items[raw] orelse return; @@ -5435,7 +5435,7 @@ fn appendRuntimeSchemaRequestToProgram( try program.addRuntimeSchemaRequest(request); } -// Immutable request ordinals, published before workers start. Function ids +// Immutable request ordinals, fixed before workers start. Function ids // are assigned separately by the coordinator only when a declaration is used. const DeclaredComptimeRoots = std.AutoHashMap(EntryRoot, u32); @@ -5551,7 +5551,7 @@ const Builder = struct { /// Top-level statements of each top module whose types a literal value /// identity named, by module key. literal_top_statements: std.AutoHashMapUnmanaged([32]u8, collections.DenseMap(can.CIR.Statement.Idx, void)) = .empty, - /// Current published representatives of canonical closed checked types. + /// Current committed representatives of normalized closed checked types. /// Views are immutable for this lowering; a missing key stays missing. persistent_closed_occurrences: std.AutoHashMapUnmanaged([32]u8, ?CheckedOccurrence) = .empty, @@ -11899,7 +11899,7 @@ const Builder = struct { const entry = self.lowered_templates.get(fn_id) orelse Common.invariant("lowered procedure template definition disappeared before completion"); try self.spec_store.markReady(entry.spec, fn_ty, self.specializationTypeDigest(fn_ty)); - try self.comptime_demand.bodyPublished(self.allocator, fn_id); + try self.comptime_demand.bodyCommitted(self.allocator, fn_id); } fn typedLocalsForArgs(self: *Builder, arg_tys: anytype) Allocator.Error!Ast.Span(Ast.TypedLocal) { @@ -12825,7 +12825,7 @@ const Builder = struct { if (root.contains_identity_variables) return .{ .module = owner, .ty = ty, .ty_module = moduleDigestFromId(view.key) }; const provenance = BodyContext.PersistentProvenance{ .builder = self }; return (try provenance.resolveNamedType(.{ .closed = .{ .owner = owner.bytes, .key = root.key.bytes } })) orelse - Common.invariant("a lowered closed named type has no current canonical occurrence"); + Common.invariant("a lowered closed named type has no current representative occurrence"); } fn moduleForDigest(self: *Builder, module_digest: names.CheckedModuleDigest) ModuleView { @@ -16550,7 +16550,7 @@ const Builder = struct { try self.lowered_templates.put(fn_id, .{ .def = ids.def(draft_def), .spec = spec_id, - .evidence = spec.evidence orelse Common.invariant("global template publication lost dispatch evidence"), + .evidence = spec.evidence orelse Common.invariant("global template commit lost dispatch evidence"), .topology = try self.retainFnEvidence(evidence), }); try self.markTemplateReady(fn_id, solved_fn_ty); @@ -18584,7 +18584,7 @@ const SealedTemplateSpec = struct { source_fn_key: names.TypeDigest, request_fn_ty: Type.TypeId, /// A fully lowered lexical body carries its resolved calls in the draft. - /// Only deferred or globally publishable requests retain dispatch evidence. + /// Only deferred or globally committable requests retain dispatch evidence. evidence: ?[]const SpecEvidence, /// The request's substitution sealed in the shard's store epoch. subst: SealedSubstitution, @@ -28661,7 +28661,7 @@ const BodyContext = struct { if (self.completed_body_validation) |validation| try validation.validate(payload); } - pub fn defaultArtifact(self: PersistentProvenance, identity: [32]u8, expr: u32) ?[32]u8 { + pub fn defaultDeclaringModule(self: PersistentProvenance, identity: [32]u8, expr: u32) ?[32]u8 { const declaring = self.builder.moduleForIdentityHash(&identity) orelse return null; _ = declaring.bodies.defaultExpr(expr) orelse return null; return declaring.key.bytes; @@ -28719,7 +28719,17 @@ const BodyContext = struct { const owner = switch (view.types.payload(occurrence.ty)) { .nominal => |nominal| nominal.owner_module, .alias => |alias| alias.owner_module, - else => break :blk null, + .pending, + .err, + .flex, + .rigid, + .record, + .tuple, + .function, + .empty_record, + .tag_union, + .empty_tag_union, + => break :blk null, }; if (!std.mem.eql(u8, &owner.bytes, &closed.owner)) break :blk null; break :blk .{ .module = .{ .bytes = closed.owner }, .ty_module = occurrence.module, .ty = occurrence.ty }; @@ -29611,7 +29621,7 @@ const BodyContext = struct { fn recordStats(self: *AnalysisMemo, ctx: *BodyContext) void { if (ctx.graph.diagnostics) |stats| { - stats.analysis_peak_facts = @max(stats.analysis_peak_facts, self.proofs.facts.count()); + stats.analysis_peak_entries = @max(stats.analysis_peak_entries, self.proofs.proven.count()); stats.analysis_peak_payload_bytes = @max(stats.analysis_peak_payload_bytes, self.proofs.retainedPayloadBytes()); } } @@ -30316,9 +30326,9 @@ const BodyContext = struct { m.entries.clearRetainingCapacity(); for (scratch.pairs.items, 0..) |pair, i| { const key: AnalysisKey = .{ .completion = pair }; - if (!m.proofs.facts.contains(key)) try m.entries.append(self.allocator, .{ .key = key, .value = scratch.equations.value(i) }); + if (!m.proofs.proven.contains(key)) try m.entries.append(self.allocator, .{ .key = key, .value = scratch.equations.value(i) }); } - try m.proofs.publish(self.allocator, m.entries.items, m.dependencies.items[recording..]); + try m.proofs.commit(self.allocator, m.entries.items, m.dependencies.items[recording..]); m.recordStats(self); }; return @fromBackingInt(@intCast(scratch.equations.value(0))); @@ -38090,7 +38100,7 @@ const BodyContext = struct { var scan = NodeUninhabitedScan{ .body = self, .backing_access = backing_access }; const answer = try NodeUninhabitedScan.Evaluation.runWith(self.allocator, &self.node_uninhabited_scratch, &scan, node); if (epoch == self.graph.analysis_changes.epoch and memo.proofs.epoch == epoch) { - try memo.proofs.publish(self.allocator, memo.entries.items, memo.dependencies.items[recording..]); + try memo.proofs.commit(self.allocator, memo.entries.items, memo.dependencies.items[recording..]); memo.recordStats(self); const fact = memo.proofs.get(key).?; memo.end(recording); @@ -48363,10 +48373,11 @@ const BodyContext = struct { } fn constCaptureTypeCache(self: *BodyContext) *ConstTypeMemo { - if (self.typeStore() == &self.builder.program.types) return &self.builder.const_type_cache; + // Durable types are the program's own; the others belong to the + // specialization workspace that owns this body. + if (self.interfaceSummaryCache().types_are_durable) return &self.builder.const_type_cache; const workspace = self.draft.spec_job_workspace orelse Common.invariant("stored-type restoration has no owning destination workspace"); - std.debug.assert(self.typeStore() == &workspace.types); return &workspace.const_type_cache; } @@ -67609,7 +67620,7 @@ test "completion analysis shared subgraph work" { } try std.testing.expectEqual(@as(u64, 17), diagnostics.completion_pair_expansions); try std.testing.expectEqual(@as(u64, 18), diagnostics.inhabitation_node_expansions); - try std.testing.expectEqual(@as(usize, 36), ctx.analysis_memo.proofs.facts.count()); + try std.testing.expectEqual(@as(usize, 36), ctx.analysis_memo.proofs.proven.count()); try std.testing.expectEqual(@as(u64, 127), diagnostics.completion_memo_hits); try std.testing.expect(counting.allocations > allocations_before); @@ -67659,8 +67670,8 @@ test "completion relation preserves recursive and shared DAG outcomes" { const produced = try graph.newNode(.{ .tuple = produced_siblings }); try fixture.expectRelation(expected, request, produced); // Every recursive equation is settled together, including enclosing pairs. - try std.testing.expect(fixture.ctx.analysis_memo.proofs.facts.contains(.{ .completion = .{ .request = request, .produced = produced } })); - try std.testing.expect(fixture.ctx.analysis_memo.proofs.facts.contains(.{ .completion = .{ .request = request_tuple, .produced = produced_tuple } })); + try std.testing.expect(fixture.ctx.analysis_memo.proofs.proven.contains(.{ .completion = .{ .request = request, .produced = produced } })); + try std.testing.expect(fixture.ctx.analysis_memo.proofs.proven.contains(.{ .completion = .{ .request = request_tuple, .produced = produced_tuple } })); try std.testing.expect(fixture.diagnostics.completion_repeated_pairs > 0); } } @@ -67708,9 +67719,9 @@ test "completion analysis retires facts across lazy expansion and tag reordering try std.testing.expect(graph.structure_epoch != epoch); // This traversal expanded leaves. Its outer answer cannot be published // as though the whole walk had observed the final epoch. - try std.testing.expect(!fixture.ctx.analysis_memo.proofs.facts.contains(.{ .completion = .{ .request = request, .produced = produced } })); + try std.testing.expect(!fixture.ctx.analysis_memo.proofs.proven.contains(.{ .completion = .{ .request = request, .produced = produced } })); try fixture.expectRelation(.unchanged, request, produced); - try std.testing.expect(fixture.ctx.analysis_memo.proofs.facts.contains(.{ .completion = .{ .request = request, .produced = produced } })); + try std.testing.expect(fixture.ctx.analysis_memo.proofs.proven.contains(.{ .completion = .{ .request = request, .produced = produced } })); const first = try fixture.name_store.internTagLabel("A"); const last = try fixture.name_store.internTagLabel("Z"); @@ -70252,7 +70263,7 @@ const NodeUninhabitedScan = struct { const Evaluation = @import("../inhabitation.zig").Evaluation(NodeId, NodeUninhabitedScan); /// Graph aliases name one equation and one eventual proof entry. - pub fn canonical(self: *NodeUninhabitedScan, node: NodeId) NodeId { + pub fn representative(self: *NodeUninhabitedScan, node: NodeId) NodeId { return self.body.graph.rootNode(node); } @@ -70329,7 +70340,7 @@ const NodeUninhabitedScan = struct { const body = self.body; const root = body.graph.rootNode(node); const key: BodyContext.AnalysisKey = .{ .inhabitation = .{ .node = root, .access = self.backing_access } }; - if (result) |answer| if (!body.analysis_memo.proofs.facts.contains(key)) { + if (result) |answer| if (!body.analysis_memo.proofs.proven.contains(key)) { try body.analysis_memo.entries.append(body.allocator, .{ .key = key, .value = @intFromBool(answer) }); }; } @@ -73408,7 +73419,7 @@ test "issue 12616 recursive completion and inhabitation survive interleaving and const bytes = fixture.ctx.analysis_memo.proofs.retainedPayloadBytes(); const allocated = allocations.allocations; const total_allocated_bytes = allocations.allocated_bytes; - std.debug.print("12616 monotype: pairs={d} inhabitation_nodes={d} facts={d} payload_bytes={d} query_owned_bytes={d} query_allocations={d}; 512 A/B repetitions allocate and expand nothing\n", .{ initial.completion_pair_expansions, initial.inhabitation_node_expansions, fixture.ctx.analysis_memo.proofs.facts.count(), bytes, allocations.allocated_bytes - allocations.freed_bytes - live_before, allocated - allocations_before }); + std.debug.print("12616 monotype: pairs={d} inhabitation_nodes={d} facts={d} payload_bytes={d} query_owned_bytes={d} query_allocations={d}; 512 A/B repetitions allocate and expand nothing\n", .{ initial.completion_pair_expansions, initial.inhabitation_node_expansions, fixture.ctx.analysis_memo.proofs.proven.count(), bytes, allocations.allocated_bytes - allocations.freed_bytes - live_before, allocated - allocations_before }); for (0..512) |_| { try std.testing.expect(!try fixture.ctx.resultCompletesRequest(request, produced)); try std.testing.expect(!try fixture.ctx.resultCompletesRequest(other_a, other_b)); @@ -73456,7 +73467,7 @@ test "issue 12616 aliased inhabitation children publish one canonical fact" { try graph.unify(alias, empty); const tuple = try graph.newNode(.{ .tuple = try graph.arena().dupe(NodeId, &.{ empty, alias }) }); try std.testing.expect(try fixture.ctx.nodeIsProvenUninhabited(tuple)); - try std.testing.expectEqual(@as(usize, 2), fixture.ctx.analysis_memo.proofs.facts.count()); + try std.testing.expectEqual(@as(usize, 2), fixture.ctx.analysis_memo.proofs.proven.count()); try std.testing.expect(try fixture.ctx.nodeIsProvenUninhabited(alias)); } @@ -73524,7 +73535,7 @@ test "fresh closed named occurrences agree with persistent replay and preserve o const owner: names.CheckedModuleDigest = .{ .bytes = @splat(42) }; const key: [32]u8 = @splat(73); const Fixture = struct { - fn add(store: *checked.CheckedTypeStore, identity: [32]u8, open: bool) !checked.CheckedTypeId { + fn add(store: *checked.CheckedTypeStore, identity: [32]u8, open: bool) Allocator.Error!checked.CheckedTypeId { const backing = try store.reserveSyntheticTypeRoot(std.testing.allocator, .{ .bytes = @splat(3) }, false); try store.fillSyntheticTypeRoot(std.testing.allocator, backing, .empty_record); const id = try store.reserveSyntheticTypeRoot(std.testing.allocator, .{ .bytes = identity }, open); @@ -73726,7 +73737,27 @@ const FrozenReferenceProbe = struct { } else @compileError("probe pointer is not a slice"); }, .array => |info| for (value) |v| self.scan(info.child, v), - else => {}, + .type, + .void, + .bool, + .noreturn, + .int, + .float, + .comptime_float, + .comptime_int, + .undefined, + .null, + .error_union, + .error_set, + .@"enum", + .@"fn", + .@"opaque", + .frame, + .@"anyframe", + .vector, + .enum_literal, + .spirv, + => {}, } } }; @@ -73852,7 +73883,11 @@ const CompletedBodyValidation = struct { for (stored.entries.nodes) |node| switch (node) { .target => |index| if (index >= stored.entries.targets.len) return rejected(@src().line), .structural => |index| if (index >= stored.entries.structurals.len) return rejected(@src().line), - else => {}, + .from_callable, + .from_scheme, + .unreachable_value, + .checked_error, + => {}, }; const end = (try checked.ConstStore.evidenceVectorEnd(self.builder.allocator, stored.entries, 0, stored.frames[0].roots_len)) orelse return rejected(@src().line); if (end != stored.entries.nodes.len) return rejected(@src().line); @@ -73987,7 +74022,27 @@ const CompletedBodyValidation = struct { for (@constCast(value.*)) |*item| try self.walk(info.child, item, rebind_); }, .array => |info| for (value) |*item| try self.walk(info.child, item, rebind_), - else => {}, + .type, + .void, + .bool, + .noreturn, + .int, + .float, + .comptime_float, + .comptime_int, + .undefined, + .null, + .error_union, + .error_set, + .@"enum", + .@"fn", + .@"opaque", + .frame, + .@"anyframe", + .vector, + .enum_literal, + .spirv, + => {}, } } }; @@ -74027,7 +74082,12 @@ const FrozenDispatchEvidence = struct { } else null; try substitutions.append(allocator, stored); }, - else => {}, + .structural, + .from_callable, + .from_scheme, + .unreachable_value, + .checked_error, + => {}, }; return .{ .stored = try builder.constFnEvidence(rootEvidence(owner, vector)), .substitutions = substitutions.items }; } @@ -74078,7 +74138,12 @@ const FrozenDispatchEvidence = struct { } else null; index += 1; }, - else => {}, + .structural, + .from_callable, + .from_scheme, + .unreachable_value, + .checked_error, + => {}, }; if (index != self.substitutions.len) Common.invariant("validated body evidence changed target count"); } @@ -74171,13 +74236,13 @@ test "completed body rejects foreign reservations and rebinds its own references var payload = std.mem.zeroes(FrozenBodyProbe); payload.address = job.completed_body_address.?; payload.demand = .evaluation; - var exprs = [_]DraftExpr{.{ .ty = .{ .sealed = @fromBackingInt(0) }, .data = .{ .def_ref = .{ .final = @fromBackingInt(0) } } }}; + var exprs = [_]DraftExpr{.{ .ty = .{ .sealed = @fromBackingInt(1) }, .data = .{ .def_ref = .{ .final = payload.self_reservation.def } } }}; payload.exprs = &exprs; const validation = CompletedBodyValidation{ .builder = &builder, .job = job, .names = &ns }; try validation.validate(payload); exprs[0].data.def_ref.final = @fromBackingInt(1); try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); - exprs[0].data.def_ref.final = @fromBackingInt(0); + exprs[0].data.def_ref.final = payload.self_reservation.def; try validation.rebind(&payload); try std.testing.expectEqual(job.reservation.def, exprs[0].data.def_ref.final); try std.testing.expectEqualDeep(job.reservation, payload.self_reservation); @@ -74194,16 +74259,17 @@ test "completed body rejects incompatible demand missing columns and invalid dra var payload = std.mem.zeroes(FrozenBodyProbe); payload.address = job.completed_body_address.?; payload.demand = .evaluation; - const exprs = [_]DraftExpr{.{ .ty = .{ .sealed = @fromBackingInt(0) }, .data = .unit }}; + const exprs = [_]DraftExpr{.{ .ty = .{ .sealed = @fromBackingInt(1) }, .data = .unit }}; payload.exprs = &exprs; const validation = CompletedBodyValidation{ .builder = &builder, .job = job, .names = &ns }; try validation.validate(payload); payload.demand = .discovery; try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); payload.demand = .evaluation; + const valid_body = payload.lowered.body; payload.lowered.body = @fromBackingInt(1); try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); - payload.lowered.body = @fromBackingInt(0); + payload.lowered.body = valid_body; payload.lowered.args = .{ .start = 0, .len = 1 }; try std.testing.expectError(error.InvalidSummary, validation.validate(payload)); payload.lowered.args = .empty(); @@ -74249,7 +74315,7 @@ const CompletedBodyStaticDependency = struct { const CompletedConstPaths = struct { const Entry = struct { node: checked.ConstNodeId, parent: ?u32, step: CompletedConstStep }; entries: std.ArrayList(Entry) = .empty, - by_node: std.AutoHashMapUnmanaged(checked.ConstNodeId, u32) = .empty, + by_node: collections.DenseMapUnmanaged(checked.ConstNodeId, u32) = .empty, allocator: Allocator, fn add(self: *CompletedConstPaths, node: checked.ConstNodeId, parent: ?u32, step: CompletedConstStep) Allocator.Error!void { const entry = try self.by_node.getOrPut(self.allocator, node); @@ -74369,19 +74435,21 @@ test "completed body static paths require current producer identity and checked const ty = try types.reserveSyntheticTypeRoot(a, .{ .bytes = @splat(7) }, false); try types.fillSyntheticTypeRoot(a, ty, .empty_record); const owner: checked.ModuleId = .{ .bytes = @splat(11) }; + var templates: checked.ConstTemplateTable = .{}; + defer templates.templates.deinit(a); const locator: checked.ConstLocator = .{ .artifact = owner, - .owner = .{ .top_level_binding = .{ .module_idx = 0, .pattern = @fromBackingInt(0) } }, - .template = @fromBackingInt(0), + .owner = .{ .top_level_binding = .{ .module_idx = 0, .pattern = @fromBackingInt(1) } }, + .template = @fromBackingInt(@intCast(templates.templates.items.len)), .source_scheme = .{ .bytes = @splat(12) }, }; - var templates: checked.ConstTemplateTable = .{}; - defer templates.templates.deinit(a); + // The template's root is the identity table's only row. + const identity_row = 0; try templates.templates.append(a, .{ .id = locator.template, .owner = locator.owner, .source_scheme = locator.source_scheme, - .state = .{ .stored_const = .{ .node = root, .root = @fromBackingInt(0), .root_type = @fromBackingInt(0) } }, + .state = .{ .stored_const = .{ .node = root, .root = @fromBackingInt(identity_row), .root_type = @fromBackingInt(1) } }, }); var rows = [_]checked.CompileTimeRootIdentity{.{ .value = @splat(13), .site = @splat(14) }}; const identities: checked.CompileTimeRootIdentityTable = .{ .rows = &rows }; @@ -74406,12 +74474,12 @@ test "completed body static paths require current producer identity and checked .const_node = @fromBackingInt(111), .checked_type = ty, .checked_type_module = owner, - .ty = .{ .sealed = @fromBackingInt(0) }, + .ty = .{ .sealed = @fromBackingInt(1) }, }; const path = [_]CompletedConstStep{.{ .kind = .box }}; const dependency: CompletedBodyStaticDependency = .{ - .root = @fromBackingInt(0), - .identity = rows[0], + .root = @fromBackingInt(identity_row), + .identity = rows[identity_row], .checked_type_key = views[0].types.rootKey(ty), .path = &path, }; diff --git a/src/postcheck/monotype/solve.zig b/src/postcheck/monotype/solve.zig index 136afd10628..af068654f6c 100644 --- a/src/postcheck/monotype/solve.zig +++ b/src/postcheck/monotype/solve.zig @@ -1934,7 +1934,7 @@ pub const GraphDiagnostics = struct { inhabitation_memo_hits: u64 = 0, analysis_changed_nodes: u64 = 0, analysis_invalidation_edges: u64 = 0, - analysis_peak_facts: u64 = 0, + analysis_peak_entries: u64 = 0, analysis_peak_payload_bytes: u64 = 0, analysis_invalidated_groups: u64 = 0, @@ -9018,8 +9018,8 @@ const TypeUninhabitedScan = struct { return &self.graph.proven_uninhabited_types[@backingInt(self.policy)]; } - /// Immutable finished types already carry their canonical identity. - pub fn canonical(_: *TypeUninhabitedScan, ty: Type.TypeId) Type.TypeId { + /// Immutable finished types already carry their representative identity. + pub fn representative(_: *TypeUninhabitedScan, ty: Type.TypeId) Type.TypeId { return ty; } @@ -15394,7 +15394,7 @@ test "interface request identity ignores the tag sorting memo" { test "nominal joins after lifting another instance backing preserve structure in either order" { const Case = struct { - fn run(a: Allocator) !void { + fn run(a: Allocator) error{ OutOfMemory, TestUnexpectedResult }!void { for (0..4) |variant| { const reverse = variant & 1 != 0; const phantom = variant & 2 != 0; diff --git a/src/postcheck/monotype/type.zig b/src/postcheck/monotype/type.zig index a4a94d9b702..f5b2cf7ab71 100644 --- a/src/postcheck/monotype/type.zig +++ b/src/postcheck/monotype/type.zig @@ -2545,7 +2545,7 @@ pub const Store = struct { return self.computeDigest(name_store, ty, .representation, stats) catch digestOutOfMemory(); } - /// Fallible representation hashing for optional persistent data publication. + /// Fallible representation hashing for optional persistent data commits. pub fn representationDigestFallible(self: *Store, name_store: *const names.NameStore, ty: TypeId) std.mem.Allocator.Error!names.TypeDigest { return self.computeDigest(name_store, ty, .representation, null); } diff --git a/src/postcheck/monotype_lifted/lift.zig b/src/postcheck/monotype_lifted/lift.zig index a121c118dc6..2230193aa9f 100644 --- a/src/postcheck/monotype_lifted/lift.zig +++ b/src/postcheck/monotype_lifted/lift.zig @@ -3248,9 +3248,9 @@ test "lift restores cached iterator normalization scope before source identity i .kind = .checked_source, .ordinal = 0, }), - .template = @fromBackingInt(0), + .template = @fromBackingInt(1), } }, - .source_fn_ty = @fromBackingInt(0), + .source_fn_ty = @fromBackingInt(1), .source_fn_key = .{}, .mono_fn_ty = function, .cached = .{ @@ -3266,11 +3266,14 @@ test "lift restores cached iterator normalization scope before source identity i }, }; const fn_id = try mono.addFn(template); - _ = try mono.addDef(.{ .symbol = @fromBackingInt(0), .fn_id = fn_id, .fn_def = template, .args = .empty(), .body = .hosted, .ret = result }); + _ = try mono.addDef(.{ .symbol = @fromBackingInt(1), .fn_id = fn_id, .fn_def = template, .args = .empty(), .body = .hosted, .ret = result }); mono.freeze(); owned = false; var lifted = try run(a, mono); defer lifted.deinit(); - try std.testing.expectEqual(normalized, lifted.getFn(@fromBackingInt(0)).iterator_fusion_scope); + try std.testing.expectEqual(@as(usize, 1), lifted.fnCount()); + for (0..lifted.fnCount()) |index| { + try std.testing.expectEqual(normalized, lifted.getFn(@fromBackingInt(@intCast(index))).iterator_fusion_scope); + } } } diff --git a/src/postcheck/proc_identity.zig b/src/postcheck/proc_identity.zig index 91f5a925092..ec215bba5df 100644 --- a/src/postcheck/proc_identity.zig +++ b/src/postcheck/proc_identity.zig @@ -65,7 +65,7 @@ const domain = "roc.proc.identity.v4"; pub const Memo = struct { const Description = struct { label: Identity, children: []const Identity }; allocator: Allocator, - /// Canonical one-step descriptions of completed types. A later recursive + /// Normalized one-step descriptions of completed types. A later recursive /// component may be a partial copy of one already reduced, with edges /// both into itself and into that earlier component. descriptions: std.AutoHashMapUnmanaged(Identity, Description) = .empty, @@ -599,7 +599,7 @@ const Engine = struct { try candidates.value_ptr.append(self.gpa, digest); } - /// An SCC boundary is a storage fact, not part of an infinite type. + /// An SCC boundary is a storage property, not part of an infinite type. /// Before assigning a new group identity, prove whether this component /// unfolds to an already completed type. All matching head labels are /// considered, and the proof follows every ordered edge to a fixed point. @@ -637,7 +637,7 @@ const Engine = struct { } match.value_ptr.* = pair.digest; const description = self.renderer.memo.descriptions.get(pair.digest) orelse - Common.invariant("a completed procedure type lacks its canonical description"); + Common.invariant("a completed procedure type lacks its normalized description"); if (!std.mem.eql(u8, &description.label, &self.nodeLabel(pair.node))) return false; const children = self.childrenOf(pair.node); if (children.len != description.children.len) return false; diff --git a/src/postcheck/proofs.zig b/src/postcheck/proofs.zig index f70cc894771..81df0e89299 100644 --- a/src/postcheck/proofs.zig +++ b/src/postcheck/proofs.zig @@ -3,12 +3,12 @@ const std = @import("std"); const Allocator = std.mem.Allocator; -/// Graph-owned subscriptions to semantic writes. A disconnected node has no +/// Graph-owned subscriptions to checked writes. A disconnected node has no /// subscribers. Writes visit only proofs which actually read the changed node. pub fn Changes(comptime Node: type) type { return struct { const Self = @This(); - /// Intrusive subscription owned by a published proof's dependency array. + /// Intrusive subscription owned by a committed proof's dependency array. pub const Watch = struct { subscribed: bool = false, previous: ?*Watch = null, @@ -19,7 +19,7 @@ pub fn Changes(comptime Node: type) type { records: std.ArrayList(?*Watch) = .empty, epoch: u64 = 0, - /// Reserve node slots before the producer publishes their identities. + /// Reserve node slots before the producer commits their identities. pub fn ensureTotalCapacityPrecise(self: *Self, gpa: Allocator, count: usize) Allocator.Error!void { try self.records.ensureTotalCapacityPrecise(gpa, count); } @@ -77,7 +77,7 @@ pub fn Changes(comptime Node: type) type { pub fn Cache(comptime Key: type, comptime Node: type) type { return struct { const Self = @This(); - /// A directly read canonical node or an earlier completed certificate. + /// A directly read normalized node or an earlier completed certificate. pub const Dependency = union(enum) { node: Node, proof: *Proof }; const MutationIndex = Changes(Node); const Link = struct { dependency: Dependency, owner: *Proof, slot: usize, watch: MutationIndex.Watch = undefined }; @@ -89,7 +89,7 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { changed_nodes: usize = 0, edges: usize = 0, }; - /// A group settled together, with its facts and reverse dependencies. + /// A group settled together, with its proven and reverse dependencies. pub const Proof = struct { valid: bool = true, pending_next: ?*Proof = null, @@ -124,10 +124,10 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { } }; /// One settled outcome and its justification. - pub const Fact = struct { value: u2, proof: *Proof }; - /// An outcome prepared for atomic publication. + pub const Proven = struct { value: u2, proof: *Proof }; + /// An outcome prepared for an atomic commit. pub const Entry = struct { key: Key, value: u2 }; - facts: std.AutoHashMapUnmanaged(Key, Fact) = .empty, + proven: std.AutoHashMapUnmanaged(Key, Proven) = .empty, changes: ?*MutationIndex = null, notifications: ?*Notifications = null, groups: std.ArrayList(*Proof) = .empty, @@ -142,19 +142,19 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { /// Release certificates, tables and retained capacity. pub fn deinit(self: *Self, gpa: Allocator) void { self.clear(gpa); - self.facts.deinit(gpa); + self.proven.deinit(gpa); if (self.notifications) |state| gpa.destroy(state); self.groups.deinit(gpa); self.pending.deinit(gpa); } /// End the producer scope and release every certificate and reverse list. pub fn clear(self: *Self, gpa: Allocator) void { - for (self.groups.items) |proof| for (proof.dependencies) |*link| self.unlink(gpa, link); + for (self.groups.items) |proof| for (proof.dependencies) |*link| self.unlink(link); for (self.groups.items) |proof| self.freeProof(gpa, proof); if (self.notifications) |state| state.head = null; self.groups.clearRetainingCapacity(); self.pending.clearRetainingCapacity(); - self.facts.clearRetainingCapacity(); + self.proven.clearRetainingCapacity(); self.dependency_bytes = 0; self.dependent_capacity_bytes = 0; } @@ -184,10 +184,10 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { self.changed_nodes = state.changed_nodes; self.invalidation_edges = state.edges; // Unlink everything before freeing any referenced proof. - for (self.pending.items) |proof| for (proof.dependencies) |*link| self.unlink(gpa, link); + for (self.pending.items) |proof| for (proof.dependencies) |*link| self.unlink(link); for (self.pending.items) |proof| { for (proof.keys) |key| { - const removed = self.facts.remove(key); + const removed = self.proven.remove(key); std.debug.assert(removed); } _ = self.groups.swapRemove(proof.slot); @@ -198,7 +198,7 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { } self.pending.clearRetainingCapacity(); } - fn unlink(self: *Self, gpa: Allocator, link: *Link) void { + fn unlink(self: *Self, link: *Link) void { switch (link.dependency) { .node => |node| self.changes.?.unsubscribe(node, &link.watch), .proof => |proof| { @@ -207,17 +207,16 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { if (link.slot < list.items.len) list.items[link.slot].slot = link.slot; }, } - _ = gpa; } - /// Find a fact after the caller has refreshed dependencies. - pub fn get(self: *Self, key: Key) ?Fact { - return self.facts.get(key); + /// Find a proven value after the caller has refreshed dependencies. + pub fn get(self: *Self, key: Key) ?Proven { + return self.proven.get(key); } - /// Publish only after convergence. Allocation failure rolls back every - /// reverse link and exposes none of the new facts. - pub fn publish(self: *Self, gpa: Allocator, entries: []const Entry, dependencies: []const Dependency) Allocator.Error!void { + /// Commit only after convergence. Allocation failure rolls back every + /// reverse link and exposes none of the new proven. + pub fn commit(self: *Self, gpa: Allocator, entries: []const Entry, dependencies: []const Dependency) Allocator.Error!void { if (entries.len == 0) return; - try self.facts.ensureUnusedCapacity(gpa, @intCast(entries.len)); + try self.proven.ensureUnusedCapacity(gpa, @intCast(entries.len)); try self.groups.ensureUnusedCapacity(gpa, 1); try self.pending.ensureTotalCapacity(gpa, self.groups.items.len + 1); if (self.notifications == null) { @@ -233,7 +232,7 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { errdefer gpa.free(keys); proof.* = .{ .notifications = self.notifications.?, .changes = self.changes.?, .slot = self.groups.items.len, .dependencies = links, .keys = keys }; var linked: usize = 0; - errdefer for (links[0..linked]) |*link| self.unlink(gpa, link); + errdefer for (links[0..linked]) |*link| self.unlink(link); for (dependencies, links) |dependency, *link| { link.* = .{ .dependency = dependency, .owner = proof, .slot = 0 }; switch (dependency) { @@ -251,9 +250,9 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { linked += 1; } for (entries, keys) |entry, *key| { - std.debug.assert(!self.facts.contains(entry.key)); + std.debug.assert(!self.proven.contains(entry.key)); key.* = entry.key; - self.facts.putAssumeCapacity(entry.key, .{ .value = entry.value, .proof = proof }); + self.proven.putAssumeCapacity(entry.key, .{ .value = entry.value, .proof = proof }); } self.groups.appendAssumeCapacity(proof); self.dependency_bytes += links.len * @sizeOf(Link) + keys.len * @sizeOf(Key); @@ -261,7 +260,7 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { /// Table payload and certificate bytes, excluding allocator metadata, /// alignment padding and hash-table control bytes. pub fn retainedPayloadBytes(self: *const Self) usize { - return self.facts.capacity() * (@sizeOf(Key) + @sizeOf(Fact)) + + return self.proven.capacity() * (@sizeOf(Key) + @sizeOf(Proven)) + (if (self.notifications != null) @as(usize, @sizeOf(Notifications)) else 0) + (self.groups.capacity + self.pending.capacity) * @sizeOf(*Proof) + self.groups.items.len * @sizeOf(Proof) + self.dependency_bytes + @@ -270,7 +269,7 @@ pub fn Cache(comptime Key: type, comptime Node: type) type { }; } -test "issue 12616 proof dependencies invalidate transitively without dropping disjoint facts" { +test "issue 12616 proof dependencies invalidate transitively without dropping disjoint proven" { const Node = enum(u32) { a, b, c }; const Store = struct { changes: Changes(Node) = .{}, @@ -288,12 +287,12 @@ test "issue 12616 proof dependencies invalidate transitively without dropping di var proofs: Proofs = .{}; defer proofs.deinit(gpa); proofs.refresh(gpa, graph.changes.epoch, &graph); - try proofs.publish(gpa, &.{.{ .key = 0, .value = 1 }}, &.{.{ .node = .a }}); - try proofs.publish(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .proof = proofs.get(0).?.proof }}); - try proofs.publish(gpa, &.{.{ .key = 2, .value = 0 }}, &.{.{ .node = .c }}); + try proofs.commit(gpa, &.{.{ .key = 0, .value = 1 }}, &.{.{ .node = .a }}); + try proofs.commit(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .proof = proofs.get(0).?.proof }}); + try proofs.commit(gpa, &.{.{ .key = 2, .value = 0 }}, &.{.{ .node = .c }}); for (0..100) |_| graph.changes.mark(.b); proofs.refresh(gpa, graph.changes.epoch, &graph); - try std.testing.expectEqual(@as(usize, 3), proofs.facts.count()); + try std.testing.expectEqual(@as(usize, 3), proofs.proven.count()); try std.testing.expectEqual(@as(usize, 0), proofs.changed_nodes); try std.testing.expectEqual(@as(usize, 0), proofs.invalidation_edges); graph.changes.mark(.a); @@ -303,7 +302,7 @@ test "issue 12616 proof dependencies invalidate transitively without dropping di try std.testing.expectEqual(@as(usize, 1), proofs.groups.items.len); try std.testing.expectEqual(@as(usize, 1), proofs.changed_nodes); proofs.clear(gpa); - try std.testing.expectEqual(@as(usize, 0), proofs.facts.count()); + try std.testing.expectEqual(@as(usize, 0), proofs.proven.count()); try std.testing.expectEqual(@as(usize, 0), proofs.groups.items.len); } @@ -319,10 +318,11 @@ test "proof observers ignore disjoint graph writes and survive cache movement an var graph: Graph = .{}; defer graph.changes.deinit(gpa); try graph.changes.ensureUnusedCapacity(gpa, 1); + const first_node: Node = @fromBackingInt(@intCast(graph.changes.records.items.len)); graph.changes.appendAssumeCapacity(); var cache: Cache(u32, Node) = .{}; cache.refresh(gpa, 0, &graph); - try cache.publish(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .node = @fromBackingInt(@intCast(0)) }}); + try cache.commit(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .node = first_node }}); var moved = cache; cache = .{}; defer moved.deinit(gpa); @@ -335,14 +335,14 @@ test "proof observers ignore disjoint graph writes and survive cache movement an } try std.testing.expectEqual(@as(usize, 0), moved.changed_nodes); try std.testing.expectEqual(@as(usize, 0), moved.invalidation_edges); - graph.changes.mark(@fromBackingInt(@intCast(0))); + graph.changes.mark(first_node); moved.refresh(gpa, graph.changes.epoch, &graph); try std.testing.expect(moved.get(1) == null); try std.testing.expectEqual(@as(usize, 1), moved.changed_nodes); try std.testing.expectEqual(@as(usize, 1), moved.invalidation_edges); } -fn allocationFailureFixture(gpa: Allocator) !void { +fn allocationFailureFixture(gpa: Allocator) error{ OutOfMemory, TestExpectedEqual }!void { const Node = enum(u32) { a, b }; const Graph = struct { changes: Changes(Node) = .{}, @@ -358,11 +358,11 @@ fn allocationFailureFixture(gpa: Allocator) !void { var cache: Cache(u32, Node) = .{}; defer cache.deinit(gpa); cache.refresh(gpa, 0, &graph); - try cache.publish(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .node = .a }}); - try cache.publish(gpa, &.{.{ .key = 2, .value = 1 }}, &.{ .{ .node = .b }, .{ .proof = cache.get(1).?.proof } }); + try cache.commit(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .node = .a }}); + try cache.commit(gpa, &.{.{ .key = 2, .value = 1 }}, &.{ .{ .node = .b }, .{ .proof = cache.get(1).?.proof } }); graph.changes.mark(.a); cache.refresh(gpa, graph.changes.epoch, &graph); - try std.testing.expectEqual(@as(usize, 0), cache.facts.count()); + try std.testing.expectEqual(@as(usize, 0), cache.proven.count()); } test "proof publication allocation failures detach every graph subscription" { @@ -385,9 +385,9 @@ test "a dirty proof unsubscribes before later writes in the same batch" { var cache = Cache(u32, Node){}; defer cache.deinit(a); cache.refresh(a, 0, &graph); - try cache.publish(a, &.{.{ .key = 1, .value = 1 }}, &.{ .{ .node = .a }, .{ .node = .b } }); - try cache.publish(a, &.{.{ .key = 2, .value = 2 }}, &.{ .{ .node = .a }, .{ .node = .a } }); - try cache.publish(a, &.{.{ .key = 3, .value = 3 }}, &.{.{ .node = .c }}); + try cache.commit(a, &.{.{ .key = 1, .value = 1 }}, &.{ .{ .node = .a }, .{ .node = .b } }); + try cache.commit(a, &.{.{ .key = 2, .value = 2 }}, &.{ .{ .node = .a }, .{ .node = .a } }); + try cache.commit(a, &.{.{ .key = 3, .value = 3 }}, &.{.{ .node = .c }}); graph.changes.mark(.a); for (0..4096) |_| { graph.changes.mark(.a); From b3a946824e86cd127ae0410ba7a3ef0ed49be3ab Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 15:48:18 -0400 Subject: [PATCH 70/97] Give block-local and for-clause type declarations test identities, and update the serialized layout hash and counter positions --- src/check/checked_artifact.zig | 4 ++-- src/check/value_identity.zig | 17 ++++++++++++++++- src/cli/main.zig | 20 ++++++++++---------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/check/checked_artifact.zig b/src/check/checked_artifact.zig index 755a2038285..9a6fd077f93 100644 --- a/src/check/checked_artifact.zig +++ b/src/check/checked_artifact.zig @@ -40637,8 +40637,8 @@ test "SERIALIZED_VERSION_HASH golden value" { // `serialized_layout_version` only for semantic changes the structural hash // cannot observe, as documented at that discriminant. const golden: [32]u8 = .{ - 0x0F, 0xB8, 0xF9, 0x4D, 0xFB, 0x75, 0x54, 0x0E, 0xD7, 0xFC, 0x0B, 0xF5, 0x24, 0x00, 0x01, 0x45, - 0xE1, 0x86, 0x76, 0xF2, 0x41, 0xBF, 0x8D, 0x93, 0x10, 0xA2, 0x97, 0xCC, 0x13, 0xB7, 0xA5, 0xBD, + 0xE4, 0xD3, 0xF1, 0x91, 0xE2, 0x97, 0x1A, 0x7C, 0x15, 0xC5, 0xB5, 0x00, 0x17, 0x2C, 0xE0, 0x80, + 0x09, 0x89, 0x12, 0x69, 0x7D, 0xA4, 0x81, 0x03, 0xC6, 0xD1, 0x13, 0xB0, 0xD4, 0x9D, 0x43, 0x14, }; try std.testing.expectEqualSlices(u8, &golden, &CheckedModuleArtifact.SERIALIZED_VERSION_HASH); } diff --git a/src/check/value_identity.zig b/src/check/value_identity.zig index 2ffe77ca856..a6d5900e8d7 100644 --- a/src/check/value_identity.zig +++ b/src/check/value_identity.zig @@ -437,11 +437,18 @@ const State = struct { const env = self.inputs.env; var owners = collections.DenseMap(CIR.Statement.Idx, Owner).init(self.allocator); defer owners.deinit(); + // Block-local type declarations and a platform's for-clause aliases + // are declarations too, outside `type_decls`. + var declarations = std.ArrayList(CIR.Statement.Idx).empty; + defer declarations.deinit(self.allocator); + try declarations.appendSlice(self.allocator, env.store.sliceStatements(env.type_decls)); + for (env.for_clause_aliases.items.items) |alias| try declarations.append(self.allocator, alias.alias_stmt_idx); var expressions = self.owners.keyIterator(); while (expressions.next()) |expr| { if (env.store.getExpr(expr.*) == .e_block) { for (env.store.sliceStatements(env.store.getExpr(expr.*).e_block.stmts)) |statement| { try owners.put(statement, self.owners.get(expr.*).?); + if (declaresType(env.store.getStatement(statement))) try declarations.append(self.allocator, statement); } } } @@ -449,7 +456,7 @@ const State = struct { defer seen.deinit(); var owner_digests = std.AutoHashMapUnmanaged(Owner, [32]u8).empty; defer owner_digests.deinit(self.allocator); - for (env.store.sliceStatements(env.type_decls)) |statement| { + for (declarations.items) |statement| { const entry = try seen.getOrPut(statement); if (entry.found_existing) continue; var hash = Sha256.init(.{}); @@ -1340,6 +1347,14 @@ const TypeDigest = struct { } }; +/// Whether `statement` declares a named type. +fn declaresType(statement: CIR.Statement) bool { + return switch (statement) { + .s_alias_decl, .s_nominal_decl, .s_where_alias_decl, .s_type_var_alias => true, + .s_decl, .s_var, .s_var_uninitialized, .s_reassign, .s_crash, .s_dbg, .s_expr, .s_expect, .s_for, .s_while, .s_infinite_loop, .s_breakable_loop, .s_break, .s_return, .s_import, .s_type_anno, .s_runtime_error => false, + }; +} + fn declarationIdentity(rows: []const DeclarationIdentity, raw: u32) [32]u8 { var lo: usize = 0; var hi = rows.len; diff --git a/src/cli/main.zig b/src/cli/main.zig index 6ed28a830b7..c848b5a152c 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -19494,16 +19494,16 @@ test "post-check diagnostics preserve labeled Monotype counts" { try std.testing.expectEqual(@as(u64, 206), graph[27].count); const body = monotypeBodyCounters(diagnostics); - try std.testing.expectEqualStrings("Type instantiation scopes", body[8].name); - try std.testing.expectEqual(@as(u64, 303), body[8].count); - try std.testing.expectEqualStrings("Checked node cache hits", body[10].name); - try std.testing.expectEqual(@as(u64, 301), body[10].count); - try std.testing.expectEqualStrings("Eager iterator template bodies lowered", body[17].name); - try std.testing.expectEqual(@as(u64, 304), body[17].count); - try std.testing.expectEqualStrings("Deferred template reuses", body[18].name); - try std.testing.expectEqual(@as(u64, 305), body[18].count); - try std.testing.expectEqualStrings("Nested closures prepared", body[27].name); - try std.testing.expectEqual(@as(u64, 302), body[27].count); + try std.testing.expectEqualStrings("Type instantiation scopes", body[15].name); + try std.testing.expectEqual(@as(u64, 303), body[15].count); + try std.testing.expectEqualStrings("Checked node cache hits", body[17].name); + try std.testing.expectEqual(@as(u64, 301), body[17].count); + try std.testing.expectEqualStrings("Eager iterator template bodies lowered", body[24].name); + try std.testing.expectEqual(@as(u64, 304), body[24].count); + try std.testing.expectEqualStrings("Deferred template reuses", body[25].name); + try std.testing.expectEqual(@as(u64, 305), body[25].count); + try std.testing.expectEqualStrings("Nested closures prepared", body[34].name); + try std.testing.expectEqual(@as(u64, 302), body[34].count); const parallel = monotypeParallelCounters(.{ .worker_work_ns = 401, From ba4c1bda068b14c0133e293485dbee3416971be7 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 15:53:38 -0400 Subject: [PATCH 71/97] Give each test alias its own backing root, and check the inhabitation scan for the shared evaluation it uses --- src/postcheck/monotype/interface_summary.zig | 6 +++--- src/postcheck/monotype/lower.zig | 6 +++++- src/postcheck/structural_test.zig | 4 +++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index b921a67b012..e612ce13bfd 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -1125,7 +1125,7 @@ fn testNodeRoot(index: u32) Solve.NodeId { fn typeSummary(ty: Type.TypeId, nodes: *[1]Constraints.Node) Summary { nodes.* = .{.{ .mono = ty }}; - return .{ .constraints = .{ .roots = &.{testNodeRoot(0)}, .nodes = nodes, .open_nodes = &.{}, .kinds = &.{} } }; + return .{ .constraints = .{ .roots = &.{comptime testNodeRoot(0)}, .nodes = nodes, .open_nodes = &.{}, .kinds = &.{} } }; } test "persistent interface composite graph preserves sharing defaults and representation metadata" { @@ -1281,7 +1281,7 @@ test "persistent interface open variables and defaulted field kinds remain relat defer store.deinit(); const module = try ns.internModuleIdentity(&@as([32]u8, @splat(1))); const summary: Summary = .{ .constraints = .{ - .roots = &.{ testNodeRoot(0), testNodeRoot(0) }, + .roots = &.{ comptime testNodeRoot(0), comptime testNodeRoot(0) }, .nodes = &.{.{ .open = 0 }}, .open_nodes = &.{.{ .content = .{ .unresolved = Solve.InstVariable.checkedVariable(null, null) } }}, .kinds = &.{.{ .resolved = .{ .defaulted = .{ .module = module, .expr_node = 1 } }, .cells = null }}, @@ -1460,7 +1460,7 @@ test "persistent parametric interfaces retain explicit holes and reject unfinish try std.testing.expectEqual(Type.Primitive.str, (try graph.content((try graph.content(instance[0])).list)).primitive); } const parameter: Summary = .{ .constraints = .{ - .roots = &.{testNodeRoot(0)}, + .roots = &.{comptime testNodeRoot(0)}, .nodes = &.{.{ .open = 0 }}, .open_nodes = &.{.{ .content = .{ .unresolved = Solve.InstVariable.interfaceParameter() } }}, .kinds = &.{}, diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 46b335f58e4..2ca4e8468e0 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -73536,7 +73536,11 @@ test "fresh closed named occurrences agree with persistent replay and preserve o const key: [32]u8 = @splat(73); const Fixture = struct { fn add(store: *checked.CheckedTypeStore, identity: [32]u8, open: bool) Allocator.Error!checked.CheckedTypeId { - const backing = try store.reserveSyntheticTypeRoot(std.testing.allocator, .{ .bytes = @splat(3) }, false); + // Each alias has its own backing root; a shared identity would + // name one already-filled root. + var backing_identity = identity; + backing_identity[0] ^= 0xff; + const backing = try store.reserveSyntheticTypeRoot(std.testing.allocator, .{ .bytes = backing_identity }, false); try store.fillSyntheticTypeRoot(std.testing.allocator, backing, .empty_record); const id = try store.reserveSyntheticTypeRoot(std.testing.allocator, .{ .bytes = identity }, open); try store.fillSyntheticTypeRoot(std.testing.allocator, id, .{ .alias = .{ diff --git a/src/postcheck/structural_test.zig b/src/postcheck/structural_test.zig index 14ec9b8b5df..fc39cc29e92 100644 --- a/src/postcheck/structural_test.zig +++ b/src/postcheck/structural_test.zig @@ -1164,7 +1164,9 @@ test "Monotype inspect-only unresolved values defer until final graph sealing" { "pub const GraphTypeFinals = struct {", ); try expectContains(durable_scan, "const types = self.graph.types;"); - try expectContains(durable_scan, "self.visiting.fetchRemove(ty)"); + // Cycles are resolved by the shared emptiness evaluation, not a local + // visiting set. + try expectContains(durable_scan, "@import(\"../inhabitation.zig\").Evaluation(Type.TypeId, TypeUninhabitedScan)"); try expectNotContains(durable_scan, "activeNodeFromType"); try expectNotContains(durable_scan, "self.graph.content("); const inspect_call = sourceSliceBetween( From f896fef92af7480f82a5c544476df707951c8236 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 16:10:59 -0400 Subject: [PATCH 72/97] Time completed-body caching with the lowering's own clock, so freestanding wasm builds compile, and size snapshot copies by usize --- src/check/checking_outcome.zig | 4 ++-- src/postcheck/monotype/lower.zig | 23 +++++++++++++---------- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/check/checking_outcome.zig b/src/check/checking_outcome.zig index 2a044a5a18c..f5844f6659c 100644 --- a/src/check/checking_outcome.zig +++ b/src/check/checking_outcome.zig @@ -85,11 +85,11 @@ pub fn capture(allocator: Allocator, checker: anytype, scope: *const dependencie try tags.append(allocator, .{ .name = tag.name, .args = tag.args, .text = try appendText(allocator, &text, tag.formatted) }); } var formatted = std.ArrayList(Formatted).empty; - for (0..snapshots.contents.len()) |i| { + for (0..@as(usize, @intCast(snapshots.contents.len()))) |i| { const id: snapshot.SnapshotContentIdx = @fromBackingInt(@as(u32, @intCast(i))); if (snapshots.getFormattedString(id)) |bytes| try formatted.append(allocator, .{ .snapshot = id, .text = try appendText(allocator, &text, bytes) }); } - const fields = try allocator.alloc(snapshot.SnapshotRecordField, snapshots.record_fields.len()); + const fields = try allocator.alloc(snapshot.SnapshotRecordField, @intCast(snapshots.record_fields.len())); for (fields, 0..) |*field, i| field.* = snapshots.record_fields.get(@fromBackingInt(@as(u32, @intCast(i)))); var sources = std.ArrayList(Source).empty; // Platform diagnostics can retain a platform-side source region. Pin that diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 2ca4e8468e0..695b21e373c 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -1302,9 +1302,11 @@ const ResolutionProfile = struct { entries: [count]u64 = @splat(0), active: ?Phase = null, started: i64 = 0, + /// The clock of the lowering's timing; without one, phases record no time. + std_io: ?std.Io = null, fn transition(self: *ResolutionProfile, next: ?Phase) void { - const now = timingNowNs(std.Io.Threaded.global_single_threaded.io()); + const now = if (self.std_io) |std_io| timingNowNs(std_io) else 0; if (self.active) |phase| self.ns[@backingInt(phase)] +%= @intCast(@max(0, now - self.started)); self.started = now; self.active = next; @@ -5884,6 +5886,7 @@ const Builder = struct { .spec_key_aliases_out = options.spec_key_aliases_out, .post_check_executor = options.post_check_executor, .timing = options.timing, + .resolution_profile = .{ .std_io = if (options.timing) |timing| timing.std_io else null }, .interface_summaries = InterfaceSummaryCache.init(allocator), .spec_store = spec_store, .lowered_templates = collections.DenseMap(Ast.FnId, LoweredTemplate).init(allocator), @@ -10646,8 +10649,8 @@ const Builder = struct { ) Allocator.Error!?RestoredCompletedBody { const address = job.completed_body_address orelse return null; const provider = self.interface_cache.?; - const io = std.Io.Threaded.global_single_threaded.io(); - const start = timingNowNs(io); + const timing_io = if (self.timing) |timing| timing.std_io else null; + const start = if (timing_io) |std_io| timingNowNs(std_io) else 0; const bytes = (try provider.load(provider.context, self.allocator, address.family, address.key)) orelse return null; defer self.allocator.free(bytes); const arena = try self.allocator.create(std.heap.ArenaAllocator); @@ -10705,7 +10708,7 @@ const Builder = struct { draft.upgrade_owner = payload.upgrade_owner; draft.sealWorkerLocalSymbols(payload.worker_local_symbol_count); owned = false; - if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("completed-body hit key={x} bytes={d} restore_ns={d}\n", .{ address.key, bytes.len, timingNowNs(io) - start }); + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("completed-body hit key={x} bytes={d} restore_ns={d}\n", .{ address.key, bytes.len, if (timing_io) |std_io| timingNowNs(std_io) - start else 0 }); return .{ .arena = arena, .draft = draft, @@ -10721,8 +10724,8 @@ const Builder = struct { fn profileFrozenBody(self: *Builder, workspace: *SpecJobWorkspace, draft: *const BodyDraftStore, pending: SealedPendingTemplateBody, job: PendingSpecJob, lower_ns: i64) Allocator.Error!?FrozenProbeRecord { var scratch = std.heap.ArenaAllocator.init(self.allocator); defer scratch.deinit(); - const profile_io = std.Io.Threaded.global_single_threaded.io(); - const start = timingNowNs(profile_io); + const timing_io = if (self.timing) |timing| timing.std_io else null; + const start = if (timing_io) |std_io| timingNowNs(std_io) else 0; const templates = try scratch.allocator().alloc(FrozenTemplateProbe, draft.sealed_template_specs.items.len); for (draft.sealed_template_specs.items, templates) |source, *out| { inline for (@typeInfo(FrozenTemplateProbe).@"struct".field_names) |field| { @@ -10814,10 +10817,10 @@ const Builder = struct { }; errdefer self.allocator.free(bytes); if (replay) { - if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("completed-body write key={x} bytes={d} encode_ns={d} lower_ns={d}\n", .{ address.key, bytes.len, timingNowNs(profile_io) - start, lower_ns }); + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("completed-body write key={x} bytes={d} encode_ns={d} lower_ns={d}\n", .{ address.key, bytes.len, if (timing_io) |std_io| timingNowNs(std_io) - start else 0, lower_ns }); return .{ .family = address.family, .key = address.key, .bytes = bytes }; } - const elapsed = timingNowNs(profile_io) - start; + const elapsed = if (timing_io) |std_io| timingNowNs(std_io) - start else 0; var hash = base.Sha256.init(.{}); hash.update("diagnostic-completed-body-record.v2"); hash.update(@tagName(self.inline_expects)); @@ -10859,7 +10862,7 @@ const Builder = struct { method_scope: ModuleView, template: checked.CheckedProcedureTemplate, ) Allocator.Error!CompletedSpecJobShard { - const profile_start = if (dev_diagnostics.isSet(.sealed_body_profile) or dev_diagnostics.isSet(.completed_body_cache)) timingNowNs(std.Io.Threaded.global_single_threaded.io()) else 0; + const profile_start = if (dev_diagnostics.isSet(.sealed_body_profile) or dev_diagnostics.isSet(.completed_body_cache)) if (self.timing) |timing| timingNowNs(timing.std_io) else 0 else 0; switch (template.target) { .roc, .intrinsic, @@ -10956,7 +10959,7 @@ const Builder = struct { try self.finalizeBodyDraftGraph(graph, &body_draft, final_guard, final_end); const sealed_pending = try self.sealOrdinarySpecJobDraft(graph, &body_draft, pending); body_draft.sealWorkerLocalSymbols(self.symbols.worker_local.next); - const frozen_probe_record = if (dev_diagnostics.isSet(.sealed_body_profile) or dev_diagnostics.isSet(.completed_body_cache)) try self.profileFrozenBody(workspace, &body_draft, sealed_pending, job, timingNowNs(std.Io.Threaded.global_single_threaded.io()) - profile_start) else null; + const frozen_probe_record = if (dev_diagnostics.isSet(.sealed_body_profile) or dev_diagnostics.isSet(.completed_body_cache)) try self.profileFrozenBody(workspace, &body_draft, sealed_pending, job, if (self.timing) |timing| timingNowNs(timing.std_io) - profile_start else 0) else null; errdefer if (frozen_probe_record) |record| self.allocator.free(record.bytes); if (self.spec_job_parallel_callback) { const program_types_after = self.program.types.epochBoundary(); From 869ff42716a1c7be2b0d4ed1c40b15ae1cfc9d0a Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 16:20:33 -0400 Subject: [PATCH 73/97] Publish test identities for destructure binders, the nodes importers name destructured values by --- src/check/value_identity.zig | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/check/value_identity.zig b/src/check/value_identity.zig index a6d5900e8d7..56100e28071 100644 --- a/src/check/value_identity.zig +++ b/src/check/value_identity.zig @@ -165,6 +165,29 @@ pub fn compute(allocator: Allocator, inputs: Inputs) Allocator.Error!ModuleIdent try seen.put(def, {}); try definitions.append(allocator, .{ .def = def, .value = try state.methodIdentity(def) }); } + // An importer names a value by its definition's node or, for a name + // a destructure binds, by its binder's node, which shares the + // definitions' node numbering. Each binder carries the identity of + // the definition that destructures, rejected ones included. + var binders = std.ArrayList(CIR.Pattern.Idx).empty; + defer binders.deinit(allocator); + var pending = std.ArrayList(CIR.Pattern.Idx).empty; + defer pending.deinit(allocator); + for (inputs.env.store.sliceDefs(inputs.env.global_value_defs)) |def| { + const value = try state.methodIdentity(def); + if (!seen.contains(def)) { + try seen.put(def, {}); + try definitions.append(allocator, .{ .def = def, .value = value }); + } + binders.clearRetainingCapacity(); + try DependencyGraph.appendPatternBinders(inputs.env, inputs.env.store.getDef(def).pattern, &binders, &pending, allocator); + for (binders.items) |binder| { + const node: CIR.Def.Idx = @fromBackingInt(@backingInt(binder)); + if (seen.contains(node)) continue; + try seen.put(node, {}); + try definitions.append(allocator, .{ .def = node, .value = value }); + } + } } std.mem.sort(DefinitionIdentity, definitions.items, {}, struct { fn lessThan(_: void, a: DefinitionIdentity, b: DefinitionIdentity) bool { From 1dc66f6c7f7a527610303e420249073075bef006 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 17:06:51 -0400 Subject: [PATCH 74/97] Specialize ARC modes in the optimized targets the compile-time value path tests model --- src/compile/test/compile_time_value_paths_test.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/compile/test/compile_time_value_paths_test.zig b/src/compile/test/compile_time_value_paths_test.zig index f60aeea5e20..3e2b7aa8900 100644 --- a/src/compile/test/compile_time_value_paths_test.zig +++ b/src/compile/test/compile_time_value_paths_test.zig @@ -53,7 +53,9 @@ const platform_files = [_]struct { path: []const u8, source: []const u8 }{ /// builtin wrappers inlined, so a list operation's runtime uniqueness check /// sits in the procedure that calls the builtin. fn optimizedTarget(inline_mode: lir.CheckedPipeline.InlineMode) lir.CheckedPipeline.TargetConfig { - return .{ .inline_expects = .omit, .inline_mode = inline_mode }; + // An optimized build specializes ARC modes, as `--opt=size` and + // `--opt=speed` do. + return .{ .inline_expects = .omit, .inline_mode = inline_mode, .specialize_arc_modes = true }; } /// Both runtime programs of one app. @@ -139,7 +141,7 @@ fn lowerBothPaths(gpa: std.mem.Allocator, arena: std.mem.Allocator, tmp_dir: std .include_provided_data_exports = true, .include_internal_static_data = true, }, - .{ .target_usize = base.target.TargetUsize.native, .inline_mode = inline_mode }, + .{ .target_usize = base.target.TargetUsize.native, .inline_mode = inline_mode, .specialize_arc_modes = true }, ); errdefer restored.deinit(); const image = static_data_exports.buildStaticData( From 2e903747a1113ed66382be486d4a8b44fd7b82cc Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 17:12:37 -0400 Subject: [PATCH 75/97] Give the compile-time code identity test's dev target the owned procedure ABI a dev build uses --- src/compile/test/ctfe_code_identity_test.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/compile/test/ctfe_code_identity_test.zig b/src/compile/test/ctfe_code_identity_test.zig index a722dcfd94e..e51834fa07e 100644 --- a/src/compile/test/ctfe_code_identity_test.zig +++ b/src/compile/test/ctfe_code_identity_test.zig @@ -159,6 +159,7 @@ test "every procedure compile-time evaluation shares with a dev build is the bui .target = .{ .inline_mode = lir.CheckedPipeline.dev_inline_mode, .spec_constr_clone_inlining = lir.CheckedPipeline.dev_spec_constr_clone_inlining, + .owned_proc_signatures = true, .erased_capture_prefix = @import("builtins").erased_callable.hot_reload_capture_prefix_size, .consume_dead_boxes = true, .inline_expects = .run, From ca251067c0b0f868fc27fe232bc4708491440f5e Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 17:27:05 -0400 Subject: [PATCH 76/97] Name a test declaration's module by its own name, not a path-qualified one, so a module's cache entry does not depend on where it is compiled --- src/check/value_identity.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/check/value_identity.zig b/src/check/value_identity.zig index 56100e28071..0a9b7b9fb56 100644 --- a/src/check/value_identity.zig +++ b/src/check/value_identity.zig @@ -484,7 +484,7 @@ const State = struct { if (entry.found_existing) continue; var hash = Sha256.init(.{}); hashTag(&hash, "roc.test-declaration.v1"); - hashBytes(&hash, env.qualifiedModuleName()); + hashBytes(&hash, env.module_name); const region = env.store.getStatementRegion(statement); hashBytes(&hash, self.regionText(region)); if (owners.get(statement)) |owner| { From 905727a8ab4c75f9c2b8c476557727af264696b7 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 17:37:10 -0400 Subject: [PATCH 77/97] Represent a closed checked type by its owner's occurrence when the owner holds it, so stored values keep the occurrence fresh lowering picks after unrelated edits --- design.md | 11 +++++++---- src/postcheck/monotype/lower.zig | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/design.md b/design.md index b4c1a9a21fa..90fa5fef514 100644 --- a/design.md +++ b/design.md @@ -27368,8 +27368,11 @@ Persistent interface records distinguish checked occurrence provenance from nominal ownership. An occurrence of a closed checked type is serialized by its normalized checked type key and its nominal owner's exact entry key. Reopening binds that key only to a current committed closed root with the same key and -owner. Representatives are chosen deterministically by entry-key order and -memoized for the immutable lowering input set. A missing key rejects the optional +owner. The representative is the owner's own occurrence when the owner holds +the closed root, so it does not move when another module's entry key changes +with an edit; a closed root the owner does not hold is represented by the +lowest-keyed module that holds it. Representatives are memoized for the +immutable lowering input set. A missing key rejects the optional record. Types containing identity variables keep their exact entry and root id; no variable identity is reconstructed or merged. Nominal declaration, generated representation and default-expression validation remain mandatory after rebinding. @@ -27405,8 +27408,8 @@ never acquires the map lock. Statistics are aggregated atomically. Freshly lowered named types and persistent interface replay select the same current checked occurrence for a closed checked type key and nominal owner. The checker's `contains_identity_variables` result determines whether an occurrence -has instantiation identity. Closed occurrences use the existing deterministic -entry-key representative; open occurrences retain their exact source module +has instantiation identity. Closed occurrences use the representative above; +open occurrences retain their exact source module and type id. This changes only checked provenance: Monotype arguments, backing, generated representation, declared layout and default expressions remain the explicit products of their existing producers. Lambda solving can consequently diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 695b21e373c..b387f815433 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -28706,15 +28706,27 @@ const BodyContext = struct { .closed => |closed| blk: { const entry = try self.builder.persistent_closed_occurrences.getOrPut(self.builder.allocator, closed.key); if (!entry.found_existing) { + // The declaring module's own occurrence represents the + // type whenever that module has it, so the choice does + // not move with other modules' keys as they are edited. + // A closed type its owner does not hold is represented + // by the lowest-keyed module that does. var representative: ?CheckedOccurrence = null; - for (self.builder.moduleViews()) |view| { + if (self.builder.moduleForKeyBytes(closed.owner)) |owner_view| { + if (owner_view.types.rootForKey(.{ .bytes = closed.key })) |root| { + if (!owner_view.types.rootRows()[@backingInt(root)].contains_identity_variables) { + representative = .{ .module = owner_view.key, .ty = root }; + } + } + } + if (representative == null) for (self.builder.moduleViews()) |view| { const root = view.types.rootForKey(.{ .bytes = closed.key }) orelse continue; if (view.types.rootRows()[@backingInt(root)].contains_identity_variables) continue; if (representative) |previous| { if (std.mem.order(u8, &previous.module.bytes, &view.key.bytes) != .gt) continue; } representative = .{ .module = view.key, .ty = root }; - } + }; entry.value_ptr.* = representative; } const occurrence = entry.value_ptr.* orelse break :blk null; From 8a534559d979cedfd217694e57605cabded313da Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 17:44:18 -0400 Subject: [PATCH 78/97] Give the CLI runners' artifact_store module the base module it imports --- build.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index 14dbf4eb683..b7a5ffe8a76 100755 --- a/build.zig +++ b/build.zig @@ -2785,7 +2785,7 @@ pub fn build(b: *std.Build) void { .{ .name = "bytebox", .module = bytebox.module("bytebox") }, .{ .name = "build_options", .module = roc_modules.build_options }, .{ .name = "msvc_runtime", .module = b.createModule(.{ .root_source_file = b.path("src/echo_platform/msvc_runtime.zig") }) }, - .{ .name = "artifact_store", .module = b.createModule(.{ .root_source_file = b.path("src/compile/artifact_store.zig") }) }, + .{ .name = "artifact_store", .module = b.createModule(.{ .root_source_file = b.path("src/compile/artifact_store.zig"), .imports = &.{.{ .name = "base", .module = roc_modules.base }} }) }, }, }), }); @@ -4171,7 +4171,7 @@ pub fn build(b: *std.Build) void { .{ .name = "bytebox", .module = bytebox.module("bytebox") }, .{ .name = "build_options", .module = roc_modules.build_options }, .{ .name = "msvc_runtime", .module = b.createModule(.{ .root_source_file = b.path("src/echo_platform/msvc_runtime.zig") }) }, - .{ .name = "artifact_store", .module = b.createModule(.{ .root_source_file = b.path("src/compile/artifact_store.zig") }) }, + .{ .name = "artifact_store", .module = b.createModule(.{ .root_source_file = b.path("src/compile/artifact_store.zig"), .imports = &.{.{ .name = "base", .module = roc_modules.base }} }) }, }, }), .filters = test_filters, From 54e586f75dce16ddc23fe1a8542715b47c17e2c8 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 18:31:56 -0400 Subject: [PATCH 79/97] Persist and replay an undetermined field kind's placeholder slot, as an in-memory interface summary does --- src/postcheck/monotype/interface_summary.zig | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index e612ce13bfd..416ae42b029 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -118,7 +118,6 @@ pub fn decodeForRequest(arena: Allocator, types: *Type.Store, ns: *names.NameSto for (c.nodes) |node| if (node == .open and node.open >= c.open_nodes.len) return invalidSummary(@src().line); for (c.open_nodes) |node| { if (node.content == .redirect or node.content == .leaf) return invalidSummary(@src().line); - if (node.content == .unresolved and node.content.unresolved.origin == .placeholder) return invalidSummary(@src().line); if (node.content == .unresolved and node.content.unresolved.origin == .interface_parameter and (node.content.unresolved.numeric_default_phase != null or node.content.unresolved.row_default != null)) return invalidSummary(@src().line); if (node.related_group != .none and @backingInt(node.related_group) >= c.open_nodes.len) return invalidSummary(@src().line); @@ -637,7 +636,10 @@ fn Writer(comptime Context: type) type { return self.write(u32, try self.reference(value)); } if (T == Solve.InstNode) { - if (value == .redirect or value == .leaf or (value == .unresolved and value.unresolved.origin == .placeholder)) invariant("settled interface contains unfinished solver state", .{}); + // An undetermined field kind's slot is a placeholder until its + // relation is frozen; replay instantiates it as one, exactly as + // an in-memory summary does. + if (value == .redirect or value == .leaf) invariant("settled interface contains unfinished solver state", .{}); } if (T == Type.MaybeTypeId) return self.write(?Type.TypeId, value.get()); if (T == names.ModuleIdentityId) if (!self.checked_names) return self.write([32]u8, self.names.moduleIdentityBytes(value).*); From 5163596681bce4c57283b894f0d0456e4aa6a152 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 18:41:04 -0400 Subject: [PATCH 80/97] Count a module cache section by its own scope, now that inference capsules share the checked family --- src/cli/test/parallel_cli_runner.zig | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 2461630d55e..ebbd3e8bc32 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -13080,15 +13080,24 @@ fn writeGeneratedTypeModule( } /// Count published entries of the explicitly selected module-cache family. +/// The committed entries of one module cache section (`mod` or `can`). Other +/// caches share the checked family, such as inference capsules, so entries are +/// selected by the section's own scope, as the compiler's cache manager files +/// them. fn countModuleCacheFiles(io: std.Io, allocator: Allocator, cache_path: []const u8, section: []const u8) CliRunnerError!usize { const family: artifact_store.Family = if (std.mem.eql(u8, section, "mod")) .checked else .canonicalized; - const entries = try indexedCacheEntries(io, allocator, cache_path); - defer allocator.free(entries); - var count: usize = 0; - for (entries) |entry| if (entry.state == .published and entry.family == family) { - count += 1; - }; - return count; + const scope = try std.fs.path.join(allocator, &.{ cache_path, "compat-" ++ build_options.compiler_compatibility_id, section }); + defer allocator.free(scope); + var store = try artifact_store.Store.init(allocator, io, cache_path); + defer store.deinit(); + const keys = try store.groupKeys(allocator, .{ + .namespace = artifact_store.hashBytes(build_options.compiler_compatibility_id), + .family = family, + .group = artifact_store.hashBytes(scope), + .key = @splat(0), + }); + defer allocator.free(keys); + return keys.len; } /// The `--timings` row that covers LLVM's optimization pipeline and object From 52501f587e3eb0e5585c1658fa0758a2e0de9aa8 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 18:52:35 -0400 Subject: [PATCH 81/97] Select object packs by cache family, since indexed packs are named by key --- src/cli/test/parallel_cli_runner.zig | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index ebbd3e8bc32..055b792501e 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -8219,12 +8219,15 @@ fn indexedCacheEntries(io: std.Io, allocator: Allocator, cache_root: []const u8) const PackStoreFiles = struct { paths: std.ArrayList([]u8) = .empty, contents: std.ArrayList([]u8) = .empty, + /// Each pack's cache family, parallel to `paths`. + families: std.ArrayList(artifact_store.Family) = .empty, fn deinit(self: *PackStoreFiles, allocator: Allocator) void { for (self.paths.items) |path| allocator.free(path); for (self.contents.items) |bytes| allocator.free(bytes); self.paths.deinit(allocator); self.contents.deinit(allocator); + self.families.deinit(allocator); } fn read(io: std.Io, allocator: Allocator, cache_root: []const u8) CliRunnerError!PackStoreFiles { @@ -8240,6 +8243,7 @@ const PackStoreFiles = struct { .local_pack, .package_pack, .optimized_pack => {}, .checked, .canonicalized, .placement, .literals, .pairing, .executable, .test_result, .wasm_host, .glue_plugin, .local_discovery, .package_discovery, .program_output, .scratch => continue, } + try result.families.append(allocator, entry.family); const path = try allocator.dupe(u8, &std.fmt.bytesToHex(entry.key, .lower)); result.paths.append(allocator, path) catch |err| { allocator.free(path); @@ -8824,8 +8828,8 @@ fn customPackageDevPacks( return failureFromRun(allocator, timer, ran, message); } var package_packs: usize = 0; - for (files.paths.items) |path| { - if (std.mem.find(u8, path, "-dev") != null and std.mem.find(u8, path, std.fs.path.sep_str ++ "pkg" ++ std.fs.path.sep_str) != null) package_packs += 1; + for (files.families.items) |family| { + if (family == .package_pack) package_packs += 1; } if (package_packs == 0) return failureFromRun(allocator, timer, ran, "checking wrote no pkg dev packs"); prepared = files; @@ -8973,12 +8977,12 @@ fn customLinkedObjectPack( // by where this store keeps it, and named by its key, which is the same // in every store. var object_packs: usize = 0; - for (first.paths.items, first.contents.items) |path, bytes| { - if (std.mem.find(u8, path, "-speed") == null) continue; + for (first.paths.items, first.contents.items, first.families.items) |path, bytes, family| { + if (family != .optimized_pack) continue; object_packs += 1; const key_name = std.fs.path.basename(path); - const other = for (second.paths.items, second.contents.items) |other_path, other_bytes| { - if (std.mem.find(u8, other_path, "-speed") != null and std.mem.eql(u8, std.fs.path.basename(other_path), key_name)) break other_bytes; + const other = for (second.paths.items, second.contents.items, second.families.items) |other_path, other_bytes, other_family| { + if (other_family == .optimized_pack and std.mem.eql(u8, std.fs.path.basename(other_path), key_name)) break other_bytes; } else return customFailure(allocator, timer, "optimized pack {s} was written by one store and not the other", .{key_name}); if (!std.mem.eql(u8, bytes, other)) { return customFailure(allocator, timer, "optimized pack {s} differs between two stores", .{key_name}); From e74484691e81d53ce4127348105ed79c96c34044 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 18:54:11 -0400 Subject: [PATCH 82/97] Invalidate the path dependency's test results with a code edit, since a comment-only edit changes no value --- src/cli/test/parallel_cli_runner.zig | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 055b792501e..2fbcc87886b 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -18136,9 +18136,21 @@ fn customRocTestCachesLocalDependencyExpects( .not_contains = reused_results, })) |failure| return failure; - // Editing the dependency changes its source hash, which must invalidate the - // entry it keys and send those roots back through execution. - if (writeCaseFile(io, allocator, timer, dep_dir, "Greeting.roc", "# edited\n" ++ dep_module_source)) |failure| return failure; + // Editing the dependency's code changes the values its expects compute + // from, which must invalidate their stored results and send those roots + // back through execution. (A comment-only edit changes no value, so its + // results stay reusable.) + const edited_dep_module_source = + \\Greeting := [].{ + \\ greet : Str -> Str + \\ greet = |name| Str.concat("Hello, ", "${name}!") + \\} + \\ + \\expect Greeting.greet("Roc") == "Hello, Roc!" + \\expect Greeting.greet("") == "Hello, !" + \\ + ; + if (writeCaseFile(io, allocator, timer, dep_dir, "Greeting.roc", edited_dep_module_source)) |failure| return failure; if (runRocAndCheck(io, allocator, env, timer, timeout_ms, .{ .args = &.{"test"}, From ae3ede38e462dc6ce6c99d83d29484392441ecc5 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 18:55:33 -0400 Subject: [PATCH 83/97] Fix the blocked expect to one the passing expects do not already answer --- src/cli/test/parallel_cli_runner.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 2fbcc87886b..39a7a75d32a 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -17992,7 +17992,9 @@ fn customRocTestCountsCheckingErrorsWithCachedResults( })) |failure| return failure; } - if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "checking_errors.roc", source_prefix ++ "expect identity(1) == 1\n")) |failure| return failure; + // The fixed expect differs from the passing ones: an expect identical to + // one already passed would reuse its stored result. + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "checking_errors.roc", source_prefix ++ "expect identity(2) == 2\n")) |failure| return failure; for (0..2) |run| { if (runRocAndCheck(io, allocator, env, timer, timeout_ms, .{ .args = &.{ "test", "--no-color" }, From c70f127bc5eafd81d1bf3331c9cb3c0982152410 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 19:00:59 -0400 Subject: [PATCH 84/97] Damage completed test payloads through the indexed store, and require work only when a record's payload is gone --- src/cli/test/parallel_cli_runner.zig | 30 +++++++++++++++++----------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 39a7a75d32a..ceb0b15b4c6 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -14243,7 +14243,10 @@ fn customCompletedTestCommand(io: std.Io, allocator: Allocator, env: *const Case .result => |result| result, .failure => |failure| return failure, }; - if (std.mem.find(u8, repaired.stderr, "test-work specialization=0\n") != null) + // A command record whose outcome payload is gone is incomplete and must + // not be admitted. A truncated record is not a command hit at all; the + // per-root outcomes it summarized still answer every root without work. + if (remove_payload and std.mem.find(u8, repaired.stderr, "test-work specialization=0\n") != null) return customFailure(allocator, timer, "incomplete cache admitted as a complete command", .{}); if (expectSamePreSummaryBytes(allocator, timer, "cold", cold, "repaired cache", repaired)) |failure| return failure; } @@ -14292,22 +14295,25 @@ fn customCompletedTestCommand(io: std.Io, allocator: Allocator, env: *const Case /// Damage only this case's isolated cache. Command records and referenced /// module payloads must both be admitted before any observation is replayed. -const DamageCompletedTestCacheError = std.Io.Dir.OpenError || std.Io.Dir.SelectiveWalker.Error || std.Io.Dir.ReadFileAllocError || std.Io.Dir.DeleteFileError || std.Io.Dir.WriteFileError; +const DamageCompletedTestCacheError = CliRunnerError || std.Io.Dir.DeleteFileError || std.Io.Dir.WriteFileError; +/// Damage every stored test outcome or completed dev test payload: truncate it, +/// or delete it when `remove_payload` is set. Returns whether any was found. fn damageCompletedTestCache(io: std.Io, allocator: Allocator, cache_path: []const u8, remove_payload: bool) DamageCompletedTestCacheError!bool { - var directory = try std.Io.Dir.cwd().openDir(io, cache_path, .{ .iterate = true }); - defer directory.close(io); - var walker = try directory.walk(allocator); - defer walker.deinit(); - const segment = std.fs.path.sep_str ++ "test" ++ std.fs.path.sep_str; + var store = try artifact_store.Store.init(allocator, io, cache_path); + defer store.deinit(); + const entries = try indexedCacheEntries(io, allocator, cache_path); + defer allocator.free(entries); + const magic = if (remove_payload) "ROC_TEST_OUTCOMES_V3" else "ROC_COMPLETED_DEV_TEST_V3"; var damaged = false; - while (try walker.next(io)) |entry| { - if (entry.kind != .file or std.mem.find(u8, entry.path, segment) == null) continue; - const bytes = try directory.readFileAlloc(io, entry.path, allocator, .limited(16 * 1024 * 1024)); + for (entries) |entry| { + if (entry.state != .published or entry.family != .test_result) continue; + var artifact = (try store.acquireKey(entry.key, store.now())) orelse continue; + defer artifact.deinit(); + const bytes = try artifact.read(allocator); defer allocator.free(bytes); - const magic = if (remove_payload) "ROC_TEST_OUTCOMES_V3" else "ROC_COMPLETED_DEV_TEST_V3"; if (!std.mem.startsWith(u8, bytes, magic)) continue; - if (remove_payload) try directory.deleteFile(io, entry.path) else try directory.writeFile(io, .{ .sub_path = entry.path, .data = bytes[0 .. bytes.len / 2] }); + if (remove_payload) try std.Io.Dir.cwd().deleteFile(io, artifact.path) else try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = artifact.path, .data = bytes[0 .. bytes.len / 2] }); damaged = true; } return damaged; From 57244fd6af6ea62adfc5718fac09758ff4a67278 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 19:17:14 -0400 Subject: [PATCH 85/97] Discover a template's literal roots from its body, never from cached code, and make the subset test's edits change its roots --- src/cli/test/parallel_cli_runner.zig | 27 ++++++++++++++++++++++----- src/postcheck/monotype/lower.zig | 5 ++++- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index ceb0b15b4c6..aaa122dd8fd 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -15213,7 +15213,16 @@ fn customUncachedTestSubset(io: std.Io, allocator: Allocator, env: *const CaseEn for (0..@as(usize, if (backend == .dev) 2 else 1)) |pass| { for (paths[0..4], sources[0..4], 0..) |path, source, index| { if (mask & (@as(u4, 1) << @intCast(index)) == 0) continue; - const edited = std.fmt.allocPrint(allocator, "{s}\n# revision {d} pass {d}\n", .{ source, revision, pass }) catch |err| + // Test results are keyed by what each root computes, so the + // edit pads the code inside both of the module's expects (a + // comment changes no root). Each revision and pass pads anew. + const padding = 1 + 2 * revision + pass; + const pad = allocator.alloc(u8, padding) catch |err| + return customInfraFailure(allocator, timer, "subset edit: {}", .{err}); + @memset(pad, ' '); + const replacement = std.fmt.allocPrint(allocator, "expect {{\n{s}", .{pad}) catch |err| + return customInfraFailure(allocator, timer, "subset edit: {}", .{err}); + const edited = std.mem.replaceOwned(u8, allocator, source, "expect {\n", replacement) catch |err| return customInfraFailure(allocator, timer, "subset edit: {}", .{err}); std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = edited }) catch |err| return customInfraFailure(allocator, timer, "write subset edit: {}", .{err}); @@ -15232,9 +15241,10 @@ fn customUncachedTestSubset(io: std.Io, allocator: Allocator, env: *const CaseEn const misses: u64 = @popCount(mask); if (timingCounter(run.stderr, "Test result cache", "Modules cached") != 4 - misses or timingCounter(run.stderr, "Test result cache", "Roots uncached") != 2 * misses or - timingCounter(run.stderr, "Dev test emission", "Entrypoints emitted") != 2 * misses) + // A run that emits nothing reports no emission section. + (timingCounter(run.stderr, "Dev test emission", "Entrypoints emitted") orelse 0) != 2 * misses) return failureFromRun(allocator, timer, run, "cache hits leaked into the execution subset"); - if (mask == 0 and timingCounter(run.stderr, "Dev test emission", "Procedures emitted") != 0) + if (mask == 0 and (timingCounter(run.stderr, "Dev test emission", "Procedures emitted") orelse 0) != 0) return failureFromRun(allocator, timer, run, "all-hit plan emitted runtime procedures"); } } @@ -15251,6 +15261,11 @@ fn customUncachedTestSubset(io: std.Io, allocator: Allocator, env: *const CaseEn .{ .group = "Dev test emission", .counter = "Entrypoints emitted" }, }; var original_work: [work.len]u64 = undefined; + // Restore `A` from the subset edits and cache its roots, so each growth + // edit below reruns exactly the one root that reads the stored closure. + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = paths[0], .data = sources[0] }) catch |err| + return customInfraFailure(allocator, timer, "restore subset module: {}", .{err}); + if (runRocAndCheck(io, allocator, env, timer, timeout_ms, .{ .args = &args, .roc_file = paths[4], .exit = .{ .code = 1 } })) |failure| return failure; for ([_]usize{ 0, 24 }, 0..) |extra_roots, size_index| { var expanded = std.Io.Writer.Allocating.init(allocator); defer expanded.deinit(); @@ -15277,9 +15292,11 @@ fn customUncachedTestSubset(io: std.Io, allocator: Allocator, env: *const CaseEn .result => |run| run, .failure => |failure| return failure, }; + // Editing `A`'s stored closure reruns only the root that reads it; its + // first root's result still holds. if (timingCounter(run.stderr, "Test result cache", "Modules cached") != 3 or - timingCounter(run.stderr, "Test result cache", "Roots cached") != 6 + extra_roots) - return failureFromRun(allocator, timer, run, "growth fixture did not cache exactly B, C and D"); + timingCounter(run.stderr, "Test result cache", "Roots cached") != 7 + extra_roots) + return failureFromRun(allocator, timer, run, "growth fixture did not cache exactly B, C and D, and A's root the edit does not reach"); for (work, 0..) |counter, index| { const value = timingCounter(run.stderr, counter.group, counter.counter) orelse return failureFromRun(allocator, timer, run, "missing execution-subset work counter"); diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index b387f815433..0fe9378310a 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -9162,7 +9162,10 @@ const Builder = struct { // no cached code, and a program whose every request is settled // reads no code pack. const settled_discovery = demand == .discovery and body_scheduling == .queued and !self.parking_root_job and self.discoverySettled(key.bytes); - if (!settled_discovery) if (self.spec_cache) |cache| { + // Discovery lowers a body to find the literal roots it registers; + // cached code records the compile-time values it reads, not those + // obligations, so only an evaluation request takes a cached body. + if (!settled_discovery and demand != .discovery) if (self.spec_cache) |cache| { if (cache.lookup(key.bytes, self.spec_cache_takes, try self.programModules())) |hit| { fn_template.cached = hit; self.count("spec_cache_hits"); From 623bd8cf330ba84df8decda08a2432bf2ef55daa Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 19:18:59 -0400 Subject: [PATCH 86/97] Invalidate the native artifact fixture's results with code edits, since a comment changes no root --- src/cli/test/parallel_cli_runner.zig | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index aaa122dd8fd..88d0325f778 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -14385,7 +14385,22 @@ fn customNativeTestArtifactReuse(io: std.Io, allocator: Allocator, env: *const C defer if (failure_transcript) |transcript| allocator.free(transcript); for (0..10) |pass| { if (pass == 2) { - if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Main.roc", "# invalidate only this module's results\n" ++ main_source)) |failure| return failure; + // Invalidate only this module's results: each of its roots computes + // the same outcome through different code. (Results are keyed by + // what a root computes, so a comment would invalidate nothing.) + const invalidated = header ++ + \\expect { + \\ a = Helper.double(20) + \\ b = Helper.double(21) + \\ expect b > 0 + \\ a + b == 41 + 41 + \\} + \\expect Helper.observed(3) == 3 + 3 + \\expect Helper.sum(3) == 40 + 5 + \\expect Helper.greet({}) == Str.concat("a greeting long enough", " to live on the heap") + \\ + ; + if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Main.roc", invalidated)) |failure| return failure; } if (pass == 4) { if (writeCaseFile(io, allocator, timer, env.dirs.work_dir, "Main.roc", header ++ observed_test ++ numeric_test ++ constant_tests)) |failure| return failure; From 19b8883aebe683082107e93f65449165c2e47847 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 19:22:00 -0400 Subject: [PATCH 87/97] Check composite and default interface replay across the cached runs, since an edit reruns only its own root --- src/cli/test/parallel_cli_runner.zig | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index 88d0325f778..f6b21b42ffd 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -9694,6 +9694,11 @@ fn persistentInterfaceTests(io: std.Io, allocator: Allocator, env: *const CaseEn isolated.env_map.put("ROC_CACHE_DIR", cache) catch |err| return customInfraFailure(allocator, timer, "interface test cache environment: {}", .{err}); var cold_expansions: u64 = 0; + // Cached runs replay the composite and default-bearing interfaces the + // first cached run stored. Results are kept per root, so the edited run + // reruns only the edited root, whose callees may need neither kind. + var composite_hits: u64 = 0; + var default_hits: u64 = 0; for (0..3) |iteration| { if (iteration == 2) { const source = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(1024 * 1024)) catch |err| @@ -9718,11 +9723,13 @@ fn persistentInterfaceTests(io: std.Io, allocator: Allocator, env: *const CaseEn const hits = timingCounter(result.stderr, "Shared Monotype body + dispatch", "Persistent interface hits") orelse 0; if (hits == 0 or expansions >= cold_expansions) return failureFromRun(allocator, timer, result, "edited tests did not avoid callee relation computation through persistent replay"); - const composites = timingCounter(result.stderr, "Shared Monotype body + dispatch", "Persistent interface composite hits") orelse 0; - const defaults = timingCounter(result.stderr, "Shared Monotype body + dispatch", "Persistent interface default hits") orelse 0; - if (composites == 0 or defaults == 0) - return failureFromRun(allocator, timer, result, "edited tests did not replay composite and default-bearing interfaces"); } + if (iteration != 0) { + composite_hits += timingCounter(result.stderr, "Shared Monotype body + dispatch", "Persistent interface composite hits") orelse 0; + default_hits += timingCounter(result.stderr, "Shared Monotype body + dispatch", "Persistent interface default hits") orelse 0; + } + if (iteration == 2 and (composite_hits == 0 or default_hits == 0)) + return failureFromRun(allocator, timer, result, "cached tests did not replay composite and default-bearing interfaces"); } return null; } From f62dae5c094c1cea32c01b08ed7a2342084a3593 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 19:25:14 -0400 Subject: [PATCH 88/97] Read the early CTFE fixture's debugging root at runtime, since a build evaluates only the roots its program demands --- src/cli/test/parallel_cli_runner.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cli/test/parallel_cli_runner.zig b/src/cli/test/parallel_cli_runner.zig index f6b21b42ffd..62b9d03a720 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -9760,10 +9760,14 @@ fn customEarlyCtfeCache( return customInfraFailure(allocator, timer, "failed to import the CTFE helper: {}", .{err}); const with_helper = std.mem.replaceOwned(u8, allocator, with_import, "Shapes.sides_of(\"hexagon\")", "Closed.total(4)") catch |err| return customInfraFailure(allocator, timer, "failed to select the CTFE helper: {}", .{err}); + // A build evaluates the compile-time roots its program demands, so the + // runtime reads the debugging root in place of the value it returns. + const with_reader = std.mem.replaceOwned(u8, allocator, with_helper, "comptime_total = hexagon_sides +", "comptime_total = replayed +") catch |err| + return customInfraFailure(allocator, timer, "failed to read the CTFE debugging root: {}", .{err}); const with_debug = std.fmt.allocPrint( allocator, "{s}\nreplayed = {{\n dbg \"early ctfe replay\"\n hexagon_sides\n}}\nexpect replayed == hexagon_sides\n", - .{with_helper}, + .{with_reader}, ) catch |err| return customInfraFailure(allocator, timer, "failed to allocate CTFE app: {}", .{err}); std.Io.Dir.cwd().writeFile(io, .{ .sub_path = app, .data = with_debug }) catch |err| return customInfraFailure(allocator, timer, "failed to write CTFE app: {}", .{err}); From 3723f2f36a252be4857989d5ef1fe0ef11fb4fa0 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 27 Oct 2026 22:20:47 -0400 Subject: [PATCH 89/97] Size the serialized module environment for its callable-checking and checking-dependency columns --- src/postcheck/monotype/lower.zig | 2 +- test/serialization_size_check.zig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 0fe9378310a..90a2ccbb051 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -9164,7 +9164,7 @@ const Builder = struct { const settled_discovery = demand == .discovery and body_scheduling == .queued and !self.parking_root_job and self.discoverySettled(key.bytes); // Discovery lowers a body to find the literal roots it registers; // cached code records the compile-time values it reads, not those - // obligations, so only an evaluation request takes a cached body. + // roots, so only an evaluation request takes a cached body. if (!settled_discovery and demand != .discovery) if (self.spec_cache) |cache| { if (cache.lookup(key.bytes, self.spec_cache_takes, try self.programModules())) |hit| { fn_template.cached = hit; diff --git a/test/serialization_size_check.zig b/test/serialization_size_check.zig index a7b996a1ee8..7b1c83662d7 100644 --- a/test/serialization_size_check.zig +++ b/test/serialization_size_check.zig @@ -38,7 +38,7 @@ const expected_string_literal_store_size = 24; const expected_node_payload_size = 16; const expected_node_payload_align = 4; const expected_node_size = 20; -const expected_moduleenv_size = 2824; // Includes the serialized invoking-definitions, hosted-error origin, slot-kind, numeral-fact, interpolation-metadata, derived-map-plan, generated-codec-renaming, and identifier end and fingerprint columns. +const expected_moduleenv_size = 2936; // Includes the serialized invoking-definitions, callable-checking-fact, checking-dependency, hosted-error origin, slot-kind, numeral-fact, interpolation-metadata, derived-map-plan, generated-codec-renaming, and identifier end and fingerprint columns. const expected_nodestore_size = 600; // Platform-independent size // Compile-time assertions - build will fail if sizes don't match expected values From adacf2684bb8df466eb109267c954b7ff3336f22 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 28 Oct 2026 00:23:18 -0400 Subject: [PATCH 90/97] Keep dev builds from copying lists their standalone procedures used to inline away Join scalarization runs in every mode so pruning can drop unread vars carried with a construct's result, pruning deletes dead pure definitions, known-tag jump threading tracks which tuple fields an exit observes, dev inlines only compiler-provided low-level wrappers, and the ARC certifier undoes outcome restitution as commuting changes and keeps restituted parameters live to their returns. Persisted interface summaries accept a placeholder only as a field kind's slot. --- design.md | 42 +++- src/cli/main.zig | 1 - src/compile/test/ctfe_code_identity_test.zig | 1 - src/eval/compile_time_finalization.zig | 1 - src/lir/arc_certify.zig | 46 +++- src/lir/arc_claims.zig | 29 +++ src/lir/checked_pipeline.zig | 15 +- src/lir/known_tag_jump.zig | 213 ++++++++++++++++--- src/lir/proc_passes_test.zig | 49 +++++ src/lir/prune_join_params.zig | 59 +++-- src/postcheck/monotype/ast.zig | 6 + src/postcheck/monotype/interface_summary.zig | 32 ++- src/postcheck/monotype/lower.zig | 1 + src/postcheck/solved_inline.zig | 29 ++- 14 files changed, 445 insertions(+), 79 deletions(-) diff --git a/design.md b/design.md index 90fa5fef514..335e715918d 100644 --- a/design.md +++ b/design.md @@ -9215,7 +9215,13 @@ The explicit `InlineMode` controls the optional specialization work: separately compiled callers the same inline decisions. - `.procedures` retains standalone procedures rather than substituting their bodies. Dev builds and compile-time evaluation select it so unchanged - procedures remain eligible for early native-code reuse. Like `.none`, it + procedures remain eligible for early native-code reuse. The one exception + is a wrapper whose body the compiler provides: a specialization of a + Builtin template whose body is its checked provided low-level operation + (`FnTemplate.provides_low_level`). Substituting it makes no caller's code + depend on another procedure's source, and leaves its operation visible to + the LIR rewrites that bound list copying (`while !$done` tests the + `bool_not` low-level that known-tag jump threading reads). Like `.none`, it runs only iterator fusion, without general SpecConstr normalization, specialization, or procedure-usage collection for an empty inline plan. The LIR passes that bound list copying still run. @@ -9911,6 +9917,7 @@ consumes it, and a list in it is copied each iteration. The rewrite is exact: every statement in the run has exactly one structural predecessor; - each join it passes through has ordinary parameters only, and its body consists of local aliases, discriminant reads, field reads, literals, + descriptor-free struct builds, `bool_not` of a known two-variant tag (which selects the other variant; `!` is `Bool.not`, which is the `bool_not` low-level, so `while !$done` tests this), join declarations, and writes of the next join's parameters before @@ -9926,8 +9933,13 @@ consumes it, and a list in it is copied each iteration. The rewrite is exact: - that exit join (or, for an arm, its switch) lexically encloses the edge, so the new jump is in scope; - nothing the exit executes, following its jumps into join bodies, reads a - definition the threaded path skips or a parameter the edge would have - changed. Parameters the edge passes their own current value are unchanged. + definition the threaded path skips or observes a value that differs because + a parameter the edge would have changed does. Parameters the edge passes + their own current value are unchanged. A pure alias, field read, or + descriptor-free struct build only carries a differing value into its + target, field by field for a struct, so an exit that packs the loop state + into a tuple and reads back the unchanged field observes nothing; every + other statement observes what it reads. The rewritten edge keeps its statements except the parameter writes and the definitions no remaining statement reads, then jumps to the exit. Any other @@ -21080,9 +21092,24 @@ write is a use of its value, so when an entry passes the same value to a consuming call first, ARC must retain it for the call and the call copies a value that is never read again. -Between direct LIR lowering and ARC insertion, in every optimization mode, one -normalization removes each join parameter that no reachable statement reads -and whose only writes are explicit `set_local` statements. The parameter +Between direct LIR lowering and ARC insertion, in every optimization mode and +after join scalarization, one normalization removes each join parameter that +no live statement reads and whose only writes are explicit `set_local` +statements. Lowering often carries the variables and the construct's result +together in one struct-typed join parameter; scalarization has by then split +it into one parameter per field, so each variable is a parameter of its own. +Join scalarization therefore runs in every mode: it is what lets an unread +variable be removed, which bounds list copying rather than shaving constant +factors. + +A pure definition (`assign_ref`, `assign_literal`, `assign_list`, or a +descriptor-free `assign_struct` or `assign_tag`) whose target has exactly one +definition and no live reader is dead, and its own reads are not live. A join +body that binds an unread variable's value to a local nothing uses leaves the +variable unread. In a procedure with join parameters, every round deletes the +dead definitions, whether or not a parameter qualifies: a dead alias of a value is still a read ARC keeps the +value alive for, so left in place it keeps a list shared across a consuming +call exactly as a write would. The parameter leaves its join, and each of its writes is deleted by routing the write's incoming edges to its successor; copying a successor over a deleted statement would duplicate any join it copied. Reads outside operand positions keep a @@ -21104,9 +21131,6 @@ non-operand reads that keep a parameter keep a definition. A written value that is itself a join parameter becomes a candidate for the next round, so the pass repeats to a fixed point. Only procedures with join parameters change. -Where lowering carries the variables and the construct's result together in -one struct-typed join parameter, the unread variable is a struct field rather -than a parameter, and this pass does not remove it. ### List.prefetched diff --git a/src/cli/main.zig b/src/cli/main.zig index c848b5a152c..0fc51c91f61 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -14557,7 +14557,6 @@ fn checkedRuntimeLoweringConfig( .owned_proc_signatures = opt == .dev, .tag_reachability = tagReachabilityForOpt(opt), .prove_ranges = proveRangesForOpt(opt), - .scalarize_joins = optimizeLirForOpt(opt), .reuse_boxes = optimizeLirForOpt(opt), .thread_confined_rc = optimizeLirForOpt(opt), .proc_debug_names = proc_debug_names or dev_diagnostics.isSet(.pack_trace), diff --git a/src/compile/test/ctfe_code_identity_test.zig b/src/compile/test/ctfe_code_identity_test.zig index e51834fa07e..fbc71b53be0 100644 --- a/src/compile/test/ctfe_code_identity_test.zig +++ b/src/compile/test/ctfe_code_identity_test.zig @@ -168,7 +168,6 @@ test "every procedure compile-time evaluation shares with a dev build is the bui .site_code_keys = true, .tag_reachability = false, .prove_ranges = false, - .scalarize_joins = false, .reuse_boxes = false, .thread_confined_rc = false, .proc_debug_names = true, diff --git a/src/eval/compile_time_finalization.zig b/src/eval/compile_time_finalization.zig index 0210bcb57ee..9c5fd77d544 100644 --- a/src/eval/compile_time_finalization.zig +++ b/src/eval/compile_time_finalization.zig @@ -1125,7 +1125,6 @@ fn compileTimeTarget(options: Options, solved_policy: lir.CheckedPipeline.Solved // callables reserve the hot-reload prefix; an interpreted evaluation // reserves none. .erased_capture_prefix = if (compilerHostMustUseInterpreterForCtfe()) 0 else builtins.erased_callable.hot_reload_capture_prefix_size, - .scalarize_joins = false, .reuse_boxes = false, // Specialization records procedure names for every consumer of the // program; a runtime consumer's diagnostics read them. diff --git a/src/lir/arc_certify.zig b/src/lir/arc_certify.zig index 7066baf38ab..6058a8aebed 100644 --- a/src/lir/arc_certify.zig +++ b/src/lir/arc_certify.zig @@ -2986,22 +2986,29 @@ const Certifier = struct { if (position >= receipts.len or receipts[position].value == no_value) { return self.fail("outcome refinement lacked argument receipt {d}", .{position}); } + // Each mutation is undone as a change, not a snapshot: a later + // argument of the same call can claim a sibling field of the + // same container, and those changes commute with this one's. const mutations = receipts[position].mutations; var mutation_index = mutations.len; while (mutation_index > 0) { mutation_index -= 1; switch (mutations[mutation_index]) { .balance => |mutation| { - if (state.balanceOf(mutation.value) != mutation.after) { + if (state.balanceOf(mutation.value) + mutation.before - mutation.after < 0) { return self.fail("outcome restitution argument {d} balance changed before refinement", .{position}); } try state.addBalance(mutation.value, mutation.before - mutation.after); }, .claims => |mutation| { - if (!state.claimsOf(mutation.value).eql(mutation.after)) { + const arena = self.state_arena.allocator(); + const added = try mutation.after.withoutAll(arena, mutation.before); + const removed = try mutation.before.withoutAll(arena, mutation.after); + const current = state.claimsOf(mutation.value); + if (!current.containsAll(added) or current.intersects(removed)) { return self.fail("outcome restitution argument {d} field claims changed before refinement", .{position}); } - try state.setClaims(mutation.value, mutation.before); + try state.setClaims(mutation.value, try (try current.withoutAll(arena, added)).withAll(arena, removed)); }, } } @@ -4062,7 +4069,10 @@ const Certifier = struct { try stack.append(self.allocator, .{ .stmt = join_stmt.remainder, .region = region }); continue; }, - .ret => |ret_stmt| try self.noteProcLocal(ret_stmt.value), + .ret => |ret_stmt| { + try self.noteProcLocal(ret_stmt.value); + try self.noteRestitutedParams(); + }, .crash => |crash_stmt| if (crash_stmt.msg.localId()) |message| try self.noteProcLocal(message), .jump, .runtime_error, .comptime_exhaustiveness_failed => {}, } @@ -4305,6 +4315,14 @@ const Certifier = struct { const current = self.store.getCFStmtImmediate(stmt); var exposed: ExposedReads = .{ .certifier = self, .reads = &node_reads }; try body_clone.tryForEachStmtRead(self.store, current.*, &exposed, ExposedReads.note); + if (current.* == .ret) { + const params = self.store.getLocalSpan(self.store.getProcSpecImmediate(self.current_proc).args); + const restituted = self.restitutableParams(); + for (0..GuardedList.borrowLen(params)) |position| { + const bit = arc_sig.paramBit(position) orelse break; + if ((restituted & bit) != 0) try exposed.note(GuardedList.at(params, position)); + } + } if (body_clone.definedTarget(current.*)) |target| self.setReadBeforeRebindDef(&graph, node_index, target); switch (current.*) { .assign_call_erased => |inline_s| blk_s: { @@ -5046,6 +5064,26 @@ const Certifier = struct { return mentions[0] >= self.region_enter.items[slot] and mentions[mentions.len - 1] <= self.region_exit.items[slot]; } + /// The parameters some outcome of the current procedure restitutes. A + /// return hands each back to the caller, so it reads them: their units + /// stay live up to it even when nothing else names them. Every outcome's + /// parameters count, since which outcome a return takes is not known + /// where reads are collected. + fn restitutableParams(self: *const Certifier) arc_sig.ParamMask { + var restituted: arc_sig.ParamMask = 0; + for (self.sigs.outcomesOf(self.current_sig)) |outcome| restituted |= outcome.restituted_params; + return restituted; + } + + fn noteRestitutedParams(self: *Certifier) Allocator.Error!void { + const params = self.store.getLocalSpan(self.store.getProcSpecImmediate(self.current_proc).args); + const restituted = self.restitutableParams(); + for (0..GuardedList.borrowLen(params)) |position| { + const bit = arc_sig.paramBit(position) orelse break; + if ((restituted & bit) != 0) try self.noteProcLocal(GuardedList.at(params, position)); + } + } + fn noteProcLocalSpan(self: *Certifier, span: LIR.LocalSpan) Allocator.Error!void { const locals = self.store.getLocalSpan(span); for (0..GuardedList.borrowLen(locals)) |index| { diff --git a/src/lir/arc_claims.zig b/src/lir/arc_claims.zig index b643b81c4b0..273382262aa 100644 --- a/src/lir/arc_claims.zig +++ b/src/lir/arc_claims.zig @@ -92,6 +92,35 @@ pub const Set = struct { return result; } + /// The fields of `self` that `other` lacks. Use an arena that outlives + /// every snapshot sharing the result. + pub fn withoutAll(self: Set, allocator: std.mem.Allocator, other: Set) std.mem.Allocator.Error!Set { + var result: Set = .{ .low = self.low & ~other.low }; + if (self.high) |high| { + var entries = high.words.iterator(); + while (entries.next()) |entry| { + const removed = if (other.high) |other_high| other_high.words.get(entry.index) else 0; + var bits = entry.value & ~removed; + while (bits != 0) : (bits &= bits - 1) { + result = try result.withField(allocator, @intCast(@as(u64, entry.index) * 64 + @ctz(bits))); + } + } + } + return result; + } + + /// Whether every field of `other` is in `self`. + pub fn containsAll(self: Set, other: Set) bool { + if (other.low & ~self.low != 0) return false; + const high = other.high orelse return true; + var entries = high.words.iterator(); + while (entries.next()) |entry| { + const present = if (self.high) |self_high| self_high.words.get(entry.index) else 0; + if (entry.value & ~present != 0) return false; + } + return true; + } + pub fn isSingleton(self: Set, field: u32) bool { const count: usize = @as(usize, @popCount(self.low)) + if (self.high) |high| high.count else @as(usize, 0); return count == 1 and self.contains(field); diff --git a/src/lir/checked_pipeline.zig b/src/lir/checked_pipeline.zig index c387be4117c..b6b31a794b1 100644 --- a/src/lir/checked_pipeline.zig +++ b/src/lir/checked_pipeline.zig @@ -241,10 +241,8 @@ pub const TargetConfig = struct { /// other arms keep is live across the producer, so a list the producer /// appends to is copied once per loop iteration. fuse_tag_cases: bool = true, - /// The rewrites that exist only to make the produced program faster: - /// join scalarization and box reuse. `--opt=dev` skips them, since it - /// trades program speed for compile speed. - scalarize_joins: bool = true, + /// Box reuse exists only to make the produced program faster, so + /// `--opt=dev` skips it, since it trades program speed for compile speed. reuse_boxes: bool = true, /// Let ARC prove allocations the host can never touch and give them /// single-threaded count updates. Optimized builds enable this; dev @@ -1144,7 +1142,6 @@ pub const LirPolicy = struct { keep_specialization_procs: bool, promote_loop_appends: base.TestSwitch(true), fuse_tag_cases: bool, - scalarize_joins: bool, reuse_boxes: bool, thread_confined_rc: bool, layout_request_const_plans: bool, @@ -2022,11 +2019,11 @@ fn finishLoweredOutput( } } // Every mode: a join parameter nothing reads still keeps its written - // value alive past any consuming call on the entry path. + // value alive past any consuming call on the entry path. Scalarization + // first gives each variable carried out of a construct with its result + // its own parameter, so pruning can remove it. + try runProcedurePass(allocator, &lowered.lir_result, pass_target, .scalarize); try runProcedurePass(allocator, &lowered.lir_result, pass_target, .prune_join_params); - if (target.scalarize_joins) { - try runProcedurePass(allocator, &lowered.lir_result, pass_target, .scalarize); - } if (target.promote_loop_appends.enabled()) { try runProcedurePass(allocator, &lowered.lir_result, pass_target, .loop_append); } diff --git a/src/lir/known_tag_jump.zig b/src/lir/known_tag_jump.zig index c9dcf505fc1..2e02abdcb6d 100644 --- a/src/lir/known_tag_jump.zig +++ b/src/lir/known_tag_jump.zig @@ -18,7 +18,7 @@ //! declared shapes; the exit continuation must lexically enclose the edge //! (a selected arm that is not a bare jump becomes the body of a fresh join //! declared around its switch, which encloses an edge inside that switch); -//! and nothing the exit executes may read a local whose value the rewrite +//! and nothing the exit executes may observe a local whose value the rewrite //! would change. Any other shape is left unchanged. const std = @import("std"); @@ -170,10 +170,11 @@ const Analysis = struct { } } - // Locals whose value on the threaded path differs from the original - // path: every forwarded parameter the edge does not pass unchanged, - // and every definition the threaded path skips. + // Forwarded parameters the edge does not pass unchanged, which hold + // a different value on the threaded path, and the definitions the + // threaded path skips, which hold none. var changed = collections.DenseMap(LocalId, void).init(self.allocator); + var skipped = collections.DenseMap(LocalId, void).init(self.allocator); var target = first_target; var visited = collections.DenseMap(LIR.JoinPointId, void).init(self.allocator); const exit = while (true) { @@ -211,14 +212,14 @@ const Analysis = struct { .tag_payload, .tag_payload_struct, .list_reinterpret, .nominal => return null, }; try env.put(assign.target, value); - try changed.put(assign.target, {}); + try skipped.put(assign.target, {}); cursor = assign.next; } else if (stmt.* == .assign_literal) { try env.put(stmt.assign_literal.target, if (boolLiteralDiscriminant(self.store, stmt.assign_literal.value)) |discriminant| .{ .tag = discriminant } else .opaque_value); - try changed.put(stmt.assign_literal.target, {}); + try skipped.put(stmt.assign_literal.target, {}); cursor = stmt.assign_literal.next; } else if (stmt.* == .assign_low_level and stmt.assign_low_level.op == .bool_not) { const assign = stmt.assign_low_level; @@ -232,7 +233,12 @@ const Analysis = struct { }, .discriminant, .fields, .local, .opaque_value => .opaque_value, }); - try changed.put(assign.target, {}); + try skipped.put(assign.target, {}); + cursor = assign.next; + } else if (stmt.* == .assign_struct and stmt.assign_struct.contents_desc == .none) { + const assign = stmt.assign_struct; + try env.put(assign.target, .{ .fields = try self.resolveSpan(&env, assign.fields) }); + try skipped.put(assign.target, {}); cursor = assign.next; } else if (stmt.* == .join) { cursor = stmt.join.remainder; @@ -271,11 +277,11 @@ const Analysis = struct { const exit_join = self.store.getCFStmtImmediate(exit_stmt).join; if (!plainJoin(self.store, exit_join) or exit_join.params.len != 0) return null; if (try self.reachableOutside(chain[0], exit_stmt)) return null; - if (try self.exitReads(exit_join.body, &changed)) return null; + if (try self.exitObserves(exit_join.body, &changed, &skipped)) return null; }, .arm => |arm| { if (try self.reachableOutside(chain[0], arm.switch_stmt)) return null; - if (try self.exitReads(arm.arm, &changed)) return null; + if (try self.exitObserves(arm.arm, &changed, &skipped)) return null; }, } return .{ .chain = chain, .exit = exit }; @@ -350,25 +356,33 @@ const Analysis = struct { } /// Whether anything the exit continuation executes, following jumps into - /// join bodies, reads a local in `changed`. - fn exitReads(self: *Analysis, exit_body: CFStmtId, changed: *const collections.DenseMap(LocalId, void)) ResourceError!bool { - const Probe = struct { - changed: *const collections.DenseMap(LocalId, void), - hit: bool = false, - - fn note(probe: *@This(), local: LocalId) void { - if (probe.changed.contains(local)) probe.hit = true; - } - }; - var probe: Probe = .{ .changed = changed }; + /// join bodies, reads a definition in `skipped` or observes a value that + /// differs because a parameter in `changed` does. A pure definition only + /// carries a differing value to its target, and a struct built from one + /// differs only in that field, so a read of a field the struct took + /// unchanged observes nothing. Every other statement observes what it + /// reads. + fn exitObserves( + self: *Analysis, + exit_body: CFStmtId, + changed: *const collections.DenseMap(LocalId, void), + skipped: *const collections.DenseMap(LocalId, void), + ) ResourceError!bool { + var probe: ExitProbe = .{ .analysis = self, .skipped = skipped, .differs = collections.DenseMap(LocalId, Difference).init(self.allocator) }; + var params = changed.constIterator(); + while (params.next()) |entry| try probe.differs.put(entry.key_ptr.*, .whole); var seen = collections.DenseMap(CFStmtId, void).init(self.allocator); var work = std.ArrayList(CFStmtId).empty; try work.append(self.allocator, exit_body); while (work.pop()) |current| { if ((try seen.getOrPut(current)).found_existing) continue; const stmt = self.store.getCFStmtImmediate(current); - body_clone.forEachStmtRead(self.store, stmt.*, &probe, Probe.note); + body_clone.forEachStmtRead(self.store, stmt.*, &probe, ExitProbe.noteSkipped); if (probe.hit) return true; + if (!try probe.carry(stmt.*)) { + body_clone.forEachStmtRead(self.store, stmt.*, &probe, ExitProbe.noteObserved); + if (probe.hit) return true; + } if (stmt.* == .jump) { const target = self.joins.get(stmt.jump.target) orelse return true; try work.append(self.allocator, self.store.getCFStmtImmediate(target).join.body); @@ -444,6 +458,105 @@ const Analysis = struct { } }; +/// How a local's value on the threaded path differs from the original path. +const Difference = union(enum) { + whole, + /// A struct built in the exit; per field, how it differs, or null when the + /// field is the same. + fields: []const ?Difference, +}; + +const ExitProbe = struct { + analysis: *Analysis, + skipped: *const collections.DenseMap(LocalId, void), + differs: collections.DenseMap(LocalId, Difference), + hit: bool = false, + + fn noteSkipped(probe: *ExitProbe, local: LocalId) void { + if (probe.skipped.contains(local)) probe.hit = true; + } + + fn noteObserved(probe: *ExitProbe, local: LocalId) void { + if (probe.differs.contains(local)) probe.hit = true; + } + + /// Record what a pure definition's target differs by and report true, + /// or report false for a statement that observes its reads. + fn carry(probe: *ExitProbe, stmt: LIR.CFStmt) ResourceError!bool { + const store = probe.analysis.store; + switch (stmt) { + .assign_ref => |assign| { + if (store.stmtRowOrDefault(assign.extra).take_kind != .none) return false; + const source = probe.differs.get(assign.op.source) orelse return true; + const target: ?Difference = switch (assign.op.kind) { + .local => source, + .field => switch (source) { + .whole => .whole, + .fields => |fields| if (assign.op.index < fields.len) fields[assign.op.index] else .whole, + }, + .discriminant, .tag_payload, .tag_payload_struct, .list_reinterpret, .nominal => .whole, + }; + if (target) |difference| try probe.differs.put(assign.target, difference); + return true; + }, + .assign_literal => return true, + .assign_struct => |assign| { + if (assign.contents_desc != .none) return false; + const locals = store.getLocalSpan(assign.fields); + const fields = try probe.analysis.allocator.alloc(?Difference, locals.len); + var any = false; + for (fields, 0..) |*field, index| { + field.* = probe.differs.get(GuardedList.at(locals, index)); + if (field.* != null) any = true; + } + if (any) try probe.differs.put(assign.target, .{ .fields = fields }); + return true; + }, + .init_uninitialized, + .set_local, + .join, + .assign_call, + .assign_call_erased, + .assign_packed_erased_fn, + .assign_boxy_desc_ref, + .assign_boxy_dict_ref, + .assign_boxy_box, + .assign_boxy_record_update, + .assign_boxy_unbox, + .assign_boxy_adapt, + .assign_boxy_inspect, + .assign_boxy_eq, + .assign_boxy_hash, + .assign_boxy_tag, + .assign_boxy_tag_payload, + .boxy_tag_match, + .assign_call_dict, + .assign_low_level, + .assign_list, + .assign_tag, + .store_struct, + .store_tag, + .debug, + .expect, + .expect_err, + .runtime_error, + .comptime_exhaustiveness_failed, + .incref, + .decref, + .decref_if_initialized, + .free, + .switch_stmt, + .switch_initialized_payload, + .str_match, + .str_match_set, + .jump, + .ret, + .crash, + => return false, + } + } +}; + fn releaseRead(remaining: *collections.DenseMap(LocalId, u32), local: LocalId) void { if (remaining.getPtr(local)) |count| count.* -= 1; } @@ -544,8 +657,11 @@ const FlagLoop = struct { }; /// `while !$done { match step($state) { Done => $done = True, Next(s) => { $state = s; $steps += 1 } } }` -/// lowered with the loop exit reading either `$steps` or `$done`. -fn flagLoop(exit_reads: enum { steps, done }, true_flag: enum { tag, bool_literal }, header_test: enum { discriminant, bool_not, bool_not_inline_exit }) Allocator.Error!FlagLoop { +/// lowered with the loop exit reading either `$steps` or `$done`, directly or +/// as a field of the `($done, $state, $steps)` tuple the exit packs. The tuple +/// shapes also give the merge body the unit value a statement-position +/// assignment produces. +fn flagLoop(exit_reads: enum { steps, done, tuple_steps, tuple_done }, true_flag: enum { tag, bool_literal }, header_test: enum { discriminant, bool_not, bool_not_inline_exit }) Allocator.Error!FlagLoop { var store = LirStore.init(testing.allocator); errdefer store.deinit(); var joins = body_clone.JoinParamIndex.init(testing.allocator); @@ -568,13 +684,30 @@ fn flagLoop(exit_reads: enum { steps, done }, true_flag: enum { tag, bool_litera const merge_steps = try local(&store, .u64); // Loop exit. - const exit_value = try local(&store, if (exit_reads == .steps) .u64 else .bool); + const reads_steps = exit_reads == .steps or exit_reads == .tuple_steps; + const exit_value = try local(&store, if (reads_steps) .u64 else .bool); const ret = try store.addCFStmt(.{ .ret = .{ .value = exit_value } }, .test_fixture); - const exit_body = try store.addCFStmt(.{ .assign_ref = .{ - .target = exit_value, - .op = .local(if (exit_reads == .steps) steps else done), - .next = ret, - } }, .test_fixture); + const exit_body = switch (exit_reads) { + .steps, .done => try store.addCFStmt(.{ .assign_ref = .{ + .target = exit_value, + .op = .local(if (reads_steps) steps else done), + .next = ret, + } }, .test_fixture), + .tuple_steps, .tuple_done => blk: { + // The pass reads no layouts, so the tuple's local takes any. + const tuple = try local(&store, .u64); + const read = try store.addCFStmt(.{ .assign_ref = .{ + .target = exit_value, + .op = .field(tuple, if (reads_steps) 2 else 0), + .next = ret, + } }, .test_fixture); + break :blk try store.addCFStmt(.{ .assign_struct = .{ + .target = tuple, + .fields = try store.addLocalSpan(&.{ done, state, steps }), + .next = read, + } }, .test_fixture); + }, + }; // Merge join body: forward every parameter to the header. const back_edge = try store.addCFStmt(.{ .jump = .{ .target = header } }, .test_fixture); @@ -584,6 +717,10 @@ fn flagLoop(exit_reads: enum { steps, done }, true_flag: enum { tag, bool_litera forward = try store.addCFStmt(.{ .set_local = .{ .target = header_param, .value = copy, .mode = .initialize_join_param, .next = forward } }, .test_fixture); forward = try store.addCFStmt(.{ .assign_ref = .{ .target = copy, .op = .local(merge_param), .next = forward } }, .test_fixture); } + if (exit_reads == .tuple_steps or exit_reads == .tuple_done) { + const unit = try local(&store, .zst); + forward = try store.addCFStmt(.{ .assign_struct = .{ .target = unit, .fields = .empty(), .next = forward } }, .test_fixture); + } // `Done`: the flag becomes True, the rest of the state is unchanged. const done_jump = try store.addCFStmt(.{ .jump = .{ .target = merge } }, .test_fixture); @@ -749,6 +886,24 @@ test "known tag jump keeps a bool_not header edge when the exit reads the flag i try testing.expect(edge.writes_params); } +test "known tag jump threads past an exit that reads an unchanged field of a tuple holding the flag" { + var fixture = try flagLoop(.tuple_steps, .tag, .bool_not); + defer fixture.deinit(); + try fixture.run(); + const edge = fixture.doneEdge(); + try testing.expectEqual(fixture.exit, edge.target); + try testing.expect(!edge.writes_params); +} + +test "known tag jump keeps the edge when the exit reads the flag's field of a tuple" { + var fixture = try flagLoop(.tuple_done, .tag, .bool_not); + defer fixture.deinit(); + try fixture.run(); + const edge = fixture.doneEdge(); + try testing.expectEqual(fixture.merge, edge.target); + try testing.expect(edge.writes_params); +} + test "known tag jump outlines an inline exit arm into a join the edge jumps to" { var fixture = try flagLoop(.steps, .tag, .bool_not_inline_exit); defer fixture.deinit(); diff --git a/src/lir/proc_passes_test.zig b/src/lir/proc_passes_test.zig index b4f0965fc85..4b33b0d7bfd 100644 --- a/src/lir/proc_passes_test.zig +++ b/src/lir/proc_passes_test.zig @@ -903,6 +903,55 @@ test "LIR proc pass deletes pure definitions that only fed pruned join parameter try testing.expectEqual(jump, store.getCFStmtImmediate(define_kept).assign_ref.next); } +test "LIR proc pass prunes a join parameter whose only reader is a dead definition" { + const LIR = core.LIR; + var store = core.LirStore.init(testing.allocator); + defer store.deinit(); + var layouts = try layout.Store.init(testing.allocator, .u64); + defer layouts.deinit(); + var join_ids = body_clone.JoinParamIndex.init(testing.allocator); + defer join_ids.deinit(); + const join_id = join_ids.freshJoinPoint(); + + const value = try store.addLocal(.{ .layout_idx = .u64 }); + const unread = try store.addLocal(.{ .layout_idx = .u64 }); + const kept = try store.addLocal(.{ .layout_idx = .u64 }); + const alias = try store.addLocal(.{ .layout_idx = .u64 }); + + // The join body binds `unread` to `alias`, which nothing reads, so + // `unread` is unread too, and both its write and the alias go. + const jump = try store.addCFStmt(.{ .jump = .{ .target = join_id } }, .test_fixture); + const write_kept = try store.addCFStmt(.{ .set_local = .{ .target = kept, .value = value, .mode = .initialize_join_param, .next = jump } }, .test_fixture); + const write_unread = try store.addCFStmt(.{ .set_local = .{ .target = unread, .value = value, .mode = .initialize_join_param, .next = write_kept } }, .test_fixture); + const ret = try store.addCFStmt(.{ .ret = .{ .value = kept } }, .test_fixture); + const define_alias = try store.addCFStmt(.{ .assign_ref = .{ .target = alias, .op = LIR.RefOp.local(unread), .next = ret } }, .test_fixture); + const join = try store.addCFStmt(.{ .join = .{ + .id = join_id, + .params = try store.addLocalSpan(&.{ unread, kept }), + .body = define_alias, + .remainder = write_unread, + } }, .test_fixture); + const proc = try store.addProcSpec(.{ + .name = store.freshSyntheticSymbol(), + .identity = LIR.ProcIdentity.forTest(0), + .args = try store.addLocalSpan(&.{value}), + .frame_locals = try store.addLocalSpan(&.{ value, unread, kept, alias }), + .body = join, + .ret_layout = .u64, + }, .none); + + var metrics: passes.ParallelMetrics = .{}; + try passes.run(testing.allocator, &store, &layouts, .prune_join_params, null, &metrics); + + try testing.expectEqual(join, store.getProcSpecImmediate(proc).body.?); + const pruned = store.getCFStmtImmediate(join).join; + const params = store.getLocalSpan(pruned.params); + try testing.expectEqual(@as(usize, 1), params.len); + try testing.expectEqual(kept, core.LirStore.GuardedList.at(params, 0)); + try testing.expectEqual(write_kept, pruned.remainder); + try testing.expectEqual(ret, pruned.body); +} + test "LIR proc pass keeps join parameters that are read or are procedure arguments" { const LIR = core.LIR; var store = core.LirStore.init(testing.allocator); diff --git a/src/lir/prune_join_params.zig b/src/lir/prune_join_params.zig index 6bc72b46914..0ca7ca1910c 100644 --- a/src/lir/prune_join_params.zig +++ b/src/lir/prune_join_params.zig @@ -9,10 +9,16 @@ //! has a value to store, and the call sees a shared value it must copy. //! //! This pass runs after direct LIR lowering and before ARC insertion. A join -//! parameter qualifies when no reachable statement reads it and every write -//! to it is an explicit `set_local`: the parameter is removed from its join, -//! and each of its writes is deleted by routing the write's incoming edges to -//! the write's successor. +//! parameter qualifies when no live statement reads it and every write to it +//! is an explicit `set_local`: the parameter is removed from its join, and +//! each of its writes is deleted by routing the write's incoming edges to the +//! write's successor. A pure definition whose target nothing live reads is +//! dead, and its reads are not live: a join body that binds an unread +//! variable's value to a local nothing uses still leaves the variable unread. +//! Every round in a procedure with join parameters deletes the dead +//! definitions, whether or not a parameter qualifies, since a dead alias is +//! still a read ARC keeps its value alive for. A procedure without join +//! parameters is left unchanged. //! //! Deleting a write can leave the written value unread. When that value is //! defined once, by a statement that only computes it from its operands, the @@ -62,9 +68,10 @@ const Pass = struct { /// Locals read somewhere other than an operand position. observed: LocalSet, /// Each defined local's defining statement when it has exactly one - /// definition and that statement is pure, else null. Collected only in - /// rounds that delete writes. + /// definition and that statement is pure, else null. defs: collections.DenseMap(LocalId, ?CFStmtId), + /// Pure definitions whose targets nothing live reads. + dead: std.ArrayList(CFStmtId) = .empty, /// Locals that lost a read to a deleted statement, one entry per read. orphans: std.ArrayList(LocalId) = .empty, /// Parameters this round removes. @@ -95,11 +102,13 @@ const Pass = struct { self.observed.deinit(); self.defs.deinit(); self.orphans.deinit(self.allocator); + self.dead.deinit(self.allocator); self.pruned.deinit(); self.deleted.deinit(); } - /// Prune every parameter that qualifies now; report whether any did. + /// Prune every parameter that qualifies now and delete every dead + /// definition; report whether anything changed. fn round(self: *Pass) Allocator.Error!bool { self.stmts.clearRetainingCapacity(); self.reads.clearRetainingCapacity(); @@ -108,6 +117,7 @@ const Pass = struct { self.observed.clearRetainingCapacity(); self.defs.clearRetainingCapacity(); self.orphans.clearRetainingCapacity(); + self.dead.clearRetainingCapacity(); self.pruned.clearRetainingCapacity(); self.deleted.clearRetainingCapacity(); @@ -183,6 +193,13 @@ const Pass = struct { } } + if (self.params.count() == 0) return false; + for (self.stmts.items) |stmt_id| { + var defs: DefCollector = .{ .pass = self, .stmt = stmt_id }; + try BodyClone.tryForEachStmtDef(store, store.getCFStmt(stmt_id), &defs, DefCollector.note); + } + try self.markDeadDefs(); + var params = self.params.iterator(); while (params.next()) |entry| { const param = entry.key_ptr.*; @@ -190,8 +207,9 @@ const Pass = struct { if ((self.reads.get(param) orelse 0) != 0) continue; try self.pruned.put(param, {}); } - if (self.pruned.count() == 0) return false; + if (self.pruned.count() == 0 and self.dead.items.len == 0) return false; + for (self.dead.items) |def_id| try self.deleted.put(def_id, pureNext(store.getCFStmt(def_id)).?); for (self.stmts.items) |stmt_id| { const stmt = store.getCFStmt(stmt_id); if (stmt == .join) try self.pruneJoinParams(stmt_id, stmt.join.params); @@ -210,14 +228,31 @@ const Pass = struct { return true; } + /// Collect every pure definition nothing live reads, withdrawing its own + /// reads so the definitions it alone fed are dead in turn. + fn markDeadDefs(self: *Pass) Allocator.Error!void { + var defs = self.defs.iterator(); + while (defs.next()) |entry| { + if ((self.reads.get(entry.key_ptr.*) orelse 0) == 0) try self.markDead(entry.key_ptr.*); + } + while (self.orphans.pop()) |local| { + const reads = self.reads.getPtr(local).?; + reads.* -= 1; + if (reads.* == 0) try self.markDead(local); + } + } + + fn markDead(self: *Pass, local: LocalId) Allocator.Error!void { + if (self.observed.contains(local) or self.params.contains(local)) return; + const def_id = (self.defs.get(local) orelse return) orelse return; + try self.dead.append(self.allocator, def_id); + try BodyClone.tryForEachStmtRead(self.store, self.store.getCFStmt(def_id), self, Pass.noteOrphan); + } + /// Delete each pure definition whose last read was a deleted statement, /// following the deleted definitions' own operands. fn deleteOrphanedDefs(self: *Pass) Allocator.Error!void { const store = self.store; - for (self.stmts.items) |stmt_id| { - var defs: DefCollector = .{ .pass = self, .stmt = stmt_id }; - try BodyClone.tryForEachStmtDef(store, store.getCFStmt(stmt_id), &defs, DefCollector.note); - } while (self.orphans.pop()) |local| { const reads = self.reads.getPtr(local).?; reads.* -= 1; diff --git a/src/postcheck/monotype/ast.zig b/src/postcheck/monotype/ast.zig index 34bcb6a2600..9e3ab826200 100644 --- a/src/postcheck/monotype/ast.zig +++ b/src/postcheck/monotype/ast.zig @@ -341,6 +341,12 @@ pub const FnTemplate = struct { /// whose closed types commit the same layouts share one procedure /// (design.md "Layout-Keyed Builtin Procedures"). procedure_keyed_by_layout: bool = false, + /// Set for a specialization of a Builtin template whose body is its + /// checked provided low-level operation + /// (`CheckedProcedureTemplate.provided_low_level`). The compiler defines + /// that body, so a caller that substitutes it at a call depends on no + /// source but its own. + provides_low_level: bool = false, /// The checked template is called at exactly one site of its module's /// source and from nowhere else (`templateHasSingleSourceCall`), stamped /// when Monotype finalizes the program. Dev inline analysis decides diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index 416ae42b029..c193b9e8137 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -42,6 +42,29 @@ fn invalidSummary(comptime line: u32) error{InvalidSummary} { /// Encode only the immutable graph reachable from the settled relation. The /// context supplies exact checked module addresses for runtime defaults. +/// Whether every placeholder open node is the slot of a field kind the +/// summary records. An undetermined field kind's slot stays a placeholder +/// until relation freeze selects its kind, and instantiation recreates it as +/// one, exactly as an in-memory summary does; any other placeholder is +/// unfinished solver state. +fn placeholdersAreFieldKindSlots(allocator: Allocator, c: Constraints) Allocator.Error!bool { + var slots = try std.DynamicBitSetUnmanaged.initEmpty(allocator, c.open_nodes.len); + defer slots.deinit(allocator); + for (c.kinds) |kind| { + const cells = kind.cells orelse continue; + const index: usize = @backingInt(cells.slot); + if (index >= c.nodes.len) continue; + switch (c.nodes[index]) { + .open => |open| if (open < c.open_nodes.len) slots.set(open), + .mono => {}, + } + } + for (c.open_nodes, 0..) |node, index| { + if (node.content == .unresolved and node.content.unresolved.origin == .placeholder and !slots.isSet(index)) return false; + } + return true; +} + pub fn encode(allocator: Allocator, types: *Type.Store, ns: *const names.NameStore, leaves: []const Type.TypeId, summary: Summary, context: anytype) EncodeError![]u8 { return encodeWithStats(allocator, types, ns, leaves, summary, context, null); } @@ -55,6 +78,9 @@ pub fn encodeWithStats(allocator: Allocator, types: *Type.Store, ns: *const name defer writer.leaf_buckets.deinit(allocator); defer writer.leaf_next.deinit(allocator); if (summary != .unchanged) try writer.indexLeaves(); + if (summary == .constraints and !try placeholdersAreFieldKindSlots(allocator, summary.constraints)) { + invariant("settled interface contains a placeholder that is no field kind's slot", .{}); + } try writer.bytes.appendSlice(allocator, magic); const counts: [3]u32 = switch (summary) { .unchanged => .{ 0, 0, 0 }, @@ -122,6 +148,7 @@ pub fn decodeForRequest(arena: Allocator, types: *Type.Store, ns: *names.NameSto (node.content.unresolved.numeric_default_phase != null or node.content.unresolved.row_default != null)) return invalidSummary(@src().line); if (node.related_group != .none and @backingInt(node.related_group) >= c.open_nodes.len) return invalidSummary(@src().line); } + if (!try placeholdersAreFieldKindSlots(scratch.allocator(), c)) return invalidSummary(@src().line); }, } try validateTypeGraph(scratch.allocator(), leaves.len, summary, nodes); @@ -636,9 +663,8 @@ fn Writer(comptime Context: type) type { return self.write(u32, try self.reference(value)); } if (T == Solve.InstNode) { - // An undetermined field kind's slot is a placeholder until its - // relation is frozen; replay instantiates it as one, exactly as - // an in-memory summary does. + // A placeholder is checked against the summary's field kinds + // before writing (`placeholdersAreFieldKindSlots`). if (value == .redirect or value == .leaf) invariant("settled interface contains unfinished solver state", .{}); } if (T == Type.MaybeTypeId) return self.write(?Type.TypeId, value.get()); diff --git a/src/postcheck/monotype/lower.zig b/src/postcheck/monotype/lower.zig index 90a2ccbb051..ab0a3fb0a75 100644 --- a/src/postcheck/monotype/lower.zig +++ b/src/postcheck/monotype/lower.zig @@ -9147,6 +9147,7 @@ const Builder = struct { } std.debug.print("CENSUS_UNKEYED\t{s}\t{s}{d}\t{s}\n", .{ name, where, position, @tagName(spec_identity.callable) }); } + fn_template.provides_low_level = template.provided_low_level != null; if (template.target != .hosted and !try self.monoFnTypeMentionsFunction(lower_fn_ty)) { const key = Ast.specIdentityKey(spec_identity); fn_template.spec_key = key; diff --git a/src/postcheck/solved_inline.zig b/src/postcheck/solved_inline.zig index 8ed7f149dde..e9622b40bee 100644 --- a/src/postcheck/solved_inline.zig +++ b/src/postcheck/solved_inline.zig @@ -13,7 +13,10 @@ const GuardedList = collections.GuardedList; /// Post-check inline analysis mode. pub const Mode = enum { none, - /// Retain standalone procedures, with iterator fusion in SpecConstr. + /// Retain standalone procedures, with iterator fusion in SpecConstr. Only + /// wrappers whose bodies the compiler provides (`provides_low_level`) are + /// inlined, since substituting them makes no caller's code depend on + /// another procedure's source. procedures, /// Inline wrappers, and bodies the whole program calls exactly once. wrappers, @@ -110,7 +113,8 @@ pub fn analyze( keep_keyed_specializations: bool, ) std.mem.Allocator.Error!OwnedPlan { return switch (mode) { - .none, .procedures => OwnedPlan.empty(allocator), + .none => OwnedPlan.empty(allocator), + .procedures => try InlineAnalyzer.run(allocator, procedure_usage, solved, keep_keyed_specializations, .provided_low_level), .wrappers => try InlineAnalyzer.run(allocator, procedure_usage, solved, keep_keyed_specializations, .program), .wrappers_and_source_single_use => try InlineAnalyzer.run(allocator, procedure_usage, solved, keep_keyed_specializations, .source), }; @@ -127,7 +131,10 @@ const Candidate = struct { kind: InlineKind, }; -const SingleUseRule = enum { program, source }; +/// Which bodies are candidates: wrappers and bodies used once by the whole +/// program's calls or by their module's source, or only wrappers of +/// compiler-provided low-level templates, which needs no use information. +const CandidateRule = enum { program, source, provided_low_level }; const MaterializationState = enum { unknown, @@ -143,18 +150,16 @@ const InlineAnalyzer = struct { solved_types: SolvedType.Store.View, decisions: []Decision, keep_keyed_specializations: bool, - /// How a body qualifies as used once: by the whole program's calls, or - /// by its module's source. - single_use: SingleUseRule, + rule: CandidateRule, fn run( allocator: std.mem.Allocator, procedure_usage: SpecConstr.ProcedureUsage, solved: *const Solved.Program, keep_keyed_specializations: bool, - single_use: SingleUseRule, + rule: CandidateRule, ) std.mem.Allocator.Error!OwnedPlan { - if (procedure_usage.items.len != solved.lifted.fnCount()) { + if (rule != .provided_low_level and procedure_usage.items.len != solved.lifted.fnCount()) { Common.invariant("optimized inline analysis requires exact use information for every lifted function"); } const decisions = try allocator.alloc(Decision, solved.lifted.fnCount()); @@ -168,7 +173,7 @@ const InlineAnalyzer = struct { .solved_types = solved.types.view(), .decisions = decisions, .keep_keyed_specializations = keep_keyed_specializations, - .single_use = single_use, + .rule = rule, }; try analyzer.decideCandidates(); @@ -205,9 +210,13 @@ const InlineAnalyzer = struct { } fn inlineCandidate(self: *const InlineAnalyzer, fn_id: Lifted.FnId) std.mem.Allocator.Error!?Candidate { + if (self.rule == .provided_low_level) { + if (!(self.solved.lifted.getFn(fn_id).source orelse return null).provides_low_level) return null; + return if (try self.wrapperCandidate(fn_id)) |body| .{ .body = body, .kind = .wrapper } else null; + } // A pack program inlines a wrapper exactly as any program does. if (try self.wrapperCandidate(fn_id)) |body| return .{ .body = body, .kind = .wrapper }; - if (self.single_use == .source) { + if (self.rule == .source) { if (self.sourceSingleUseCandidate(fn_id)) |body| return .{ .body = body, .kind = .source_single_use }; return null; } From 79356c9b572de66cc214404d705d4db8d68a1895 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 28 Oct 2026 00:29:43 -0400 Subject: [PATCH 91/97] Keep encode's doc comment on encode --- src/postcheck/monotype/interface_summary.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/postcheck/monotype/interface_summary.zig b/src/postcheck/monotype/interface_summary.zig index c193b9e8137..160decc660a 100644 --- a/src/postcheck/monotype/interface_summary.zig +++ b/src/postcheck/monotype/interface_summary.zig @@ -40,8 +40,6 @@ fn invalidSummary(comptime line: u32) error{InvalidSummary} { return error.InvalidSummary; } -/// Encode only the immutable graph reachable from the settled relation. The -/// context supplies exact checked module addresses for runtime defaults. /// Whether every placeholder open node is the slot of a field kind the /// summary records. An undetermined field kind's slot stays a placeholder /// until relation freeze selects its kind, and instantiation recreates it as @@ -65,6 +63,8 @@ fn placeholdersAreFieldKindSlots(allocator: Allocator, c: Constraints) Allocator return true; } +/// Encode only the immutable graph reachable from the settled relation. The +/// context supplies exact checked module addresses for runtime defaults. pub fn encode(allocator: Allocator, types: *Type.Store, ns: *const names.NameStore, leaves: []const Type.TypeId, summary: Summary, context: anytype) EncodeError![]u8 { return encodeWithStats(allocator, types, ns, leaves, summary, context, null); } From f0211fc88023af6f49ed1befd7c347c2759c70b7 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 28 Oct 2026 03:02:09 -0400 Subject: [PATCH 92/97] Build loop liveness rows from their successors so a loop's rows share structure Inside a loop, only a header's row comes from the per-bit search; every other member is one equation step from its successors, built after them, so ARC's liveness memory no longer grows with the product of a loop's live values and its nodes. Inline tests describe the unspecialized loop shape pruning now leaves, and the float math snapshot records its new dev object hashes. --- src/eval/test/lir_inline_test.zig | 29 ++++++----- src/lir/arc_liveness.zig | 64 +++++++++++++++++++++---- test/snapshots/dev_object_float_math.md | 62 ++++++++++++------------ 3 files changed, 102 insertions(+), 53 deletions(-) diff --git a/src/eval/test/lir_inline_test.zig b/src/eval/test/lir_inline_test.zig index 6c1b29afd84..dddb0eea94d 100644 --- a/src/eval/test/lir_inline_test.zig +++ b/src/eval/test/lir_inline_test.zig @@ -1745,16 +1745,27 @@ fn whileRecordStateWorkerIsGeneric(shape: ProcShape) bool { shape.jump_count >= 2; } -/// The shape aggregate loop state takes without call specialization now that -/// join scalarization sees through lowered aliases: the loop carries the -/// fields as separate join parameters, while the seeded initializer builds -/// remain live outside the loop. +/// The shape aggregate loop state takes without call specialization: join +/// scalarization carries the fields as separate join parameters. Pruning then +/// deletes the initializer builds that only fed them, so the loop has the +/// specialized worker's shape. fn whileRecordStateWorkerIsScalarizedUnspecialized(shape: ProcShape) bool { return shape.self_call_count == 0 and shape.join_count >= 1 and shape.max_join_param_count >= 2 and + shape.jump_count >= 2; +} + +/// Loop state of a counter and a zero-capture callable without call +/// specialization: the callable's field is a parameter nothing reads, so +/// pruning removes it, and the loop carries only the counter and builds no +/// struct. +fn whileRecordStateWithUnreadCallableIsScalarized(shape: ProcShape) bool { + return shape.self_call_count == 0 and + shape.join_count >= 1 and + shape.max_join_param_count == 1 and shape.jump_count >= 2 and - shape.struct_assign_count >= 1; + shape.struct_assign_count == 0; } fn directTupleWorkerIsSpecialized(shape: ProcShape) bool { @@ -4267,7 +4278,6 @@ test "spec constr specializes record state carried by while loop" { try std.testing.expectEqual(@as(usize, 0), optimized_root_shape.struct_assign_count); try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWorkerIsGeneric)); - try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsSpecialized)); try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsGeneric)); try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsScalarizedUnspecialized)); } @@ -7934,7 +7944,6 @@ test "spec constr specializes primitive-start record state carried by while loop try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWorkerIsSpecialized)); try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWorkerIsGeneric)); - try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsSpecialized)); try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsGeneric)); try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsScalarizedUnspecialized)); } @@ -8018,9 +8027,7 @@ test "spec constr splits loop record state with opaque callable field" { try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, whileRecordStateWithZeroCaptureCallableIsSpecialized)); - try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsSpecialized)); - try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsGeneric)); - try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsScalarizedUnspecialized)); + try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWithUnreadCallableIsScalarized)); } test "spec constr splits loop record state with direct callable captures" { @@ -8251,7 +8258,6 @@ test "spec constr specializes if-joined record state carried by while loop" { try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, branchJoinedRecordStateWorkerIsSpecialized)); try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, branchJoinedRecordStateWorkerIsGeneric)); - try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, branchJoinedRecordStateWorkerIsSpecialized)); try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, branchJoinedRecordStateWorkerIsGeneric)); try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsScalarizedUnspecialized)); } @@ -8292,7 +8298,6 @@ test "spec constr specializes match-joined record state carried by while loop" { try std.testing.expect(try reachableProcShape(allocator, &optimized.lowered, branchJoinedRecordStateWorkerIsSpecialized)); try std.testing.expect(!try reachableProcShape(allocator, &optimized.lowered, branchJoinedRecordStateWorkerIsGeneric)); - try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, branchJoinedRecordStateWorkerIsSpecialized)); try std.testing.expect(!try reachableProcShape(allocator, &unoptimized.lowered, branchJoinedRecordStateWorkerIsGeneric)); try std.testing.expect(try reachableProcShape(allocator, &unoptimized.lowered, whileRecordStateWorkerIsScalarizedUnspecialized)); } diff --git a/src/lir/arc_liveness.zig b/src/lir/arc_liveness.zig index 796c1fbf23a..a9bb2575534 100644 --- a/src/lir/arc_liveness.zig +++ b/src/lir/arc_liveness.zig @@ -9,10 +9,10 @@ //! hold quadratic content in the nest depth. They are therefore never built //! per node: inside a cyclic component, a bit that a whole loop carries (the //! loop defines it nowhere, and it is exposed somewhere in the loop) is set -//! once on that loop's carried row, every row of the loop shares that row's -//! structure, and only the bits exposed at a node itself are added to the -//! node's row. Acyclic nodes are one equation step from their successors' -//! finished rows and share their structure. +//! once on that loop's carried row, and a loop header's row is that row plus +//! the bits exposed at the header itself. Every other node, in a loop or not, +//! is one equation step from its successors' finished rows and shares their +//! structure. const std = @import("std"); const builtin = @import("builtin"); @@ -241,6 +241,12 @@ pub fn equationRow( return row; } +/// Whether `node` is the header of its innermost loop. +fn isHeader(forest: *const LoopForest, node: u32) bool { + const loop = forest.innermost[node]; + return loop != LoopForest.none and forest.loops[loop].header == node; +} + fn rowAt(rows: []const Row, node: u32) *const Row { return &rows[node]; } @@ -434,6 +440,10 @@ pub fn solve(allocator: Allocator, scratch_allocator: Allocator, graph: *const G @memset(seed_heads, no_link); var seeds = std.ArrayList(SeedLink).empty; var seed_bits = std.ArrayList(u32).empty; + // A component's non-header members, by how many of their successors in + // the component that are not headers still lack a row. + var pending = collections.DenseMap(u32, u32).init(scratch); + var ready = std.ArrayList(u32).empty; var component_cursor = components.count(); while (component_cursor > 0) { @@ -486,7 +496,9 @@ pub fn solve(allocator: Allocator, scratch_allocator: Allocator, graph: *const G if (graph.passes(entry.predecessor, entry.target, search.bit)) try search.reach(entry.predecessor); } } else { - try search.note(search.node_heads, &search.node_bits, unit.index); + // Only a loop header's row is built from its exposed bits; + // every other member follows from its successors' rows. + if (isHeader(&forest, unit.index)) try search.note(search.node_heads, &search.node_bits, unit.index); try search.reachPredecessorsOf(unit.index, null); } } @@ -505,14 +517,46 @@ pub fn solve(allocator: Allocator, scratch_allocator: Allocator, graph: *const G } carried[loop] = row; } + // A header's row is the bits its loops carry plus the bits exposed + // at the header. Every other member's row is one equation step from + // its successors' rows, which the least solution satisfies, so it is + // built after them and shares their structure: removing the edges + // into headers leaves the component acyclic, since every cycle + // passes through a depth-first back edge and every back edge enters + // a header. Building each member's row from its own exposed bits + // instead would share nothing between neighboring rows, and a loop + // holding many values live across many nodes would allocate the + // product of the two. + pending.clearRetainingCapacity(); + ready.clearRetainingCapacity(); for (members) |member| { - var row = carried[forest.innermost[member]]; - var cursor = search.node_heads[member]; - while (cursor != no_link) : (cursor = search.node_bits.items[cursor].next) { - try row.set(allocator, search.node_bits.items[cursor].bit); + if (isHeader(&forest, member)) { + var row = carried[forest.innermost[member]]; + var cursor = search.node_heads[member]; + while (cursor != no_link) : (cursor = search.node_bits.items[cursor].next) { + try row.set(allocator, search.node_bits.items[cursor].bit); + } + rows[member] = row; + continue; + } + var waiting: u32 = 0; + for (graph.successorsOf(member)) |successor| { + if (component_of[successor] == component and !isHeader(&forest, successor)) waiting += 1; + } + try pending.put(member, waiting); + if (waiting == 0) try ready.append(scratch, member); + } + var built: usize = 0; + while (ready.pop()) |member| { + rows[member] = try equationRow(graph, member, allocator, @as([]const Row, rows), rowAt); + built += 1; + for (graph.predecessorsOf(member)) |predecessor| { + const waiting = pending.getPtr(predecessor) orelse continue; + waiting.* -= 1; + if (waiting.* == 0) try ready.append(scratch, predecessor); } - rows[member] = row; } + if (built != pending.count()) invariant("ARC liveness component held a cycle that enters no loop header", .{}); for (search.loop_bits.items) |link| search.loop_heads[link.index] = no_link; for (search.node_bits.items) |link| search.node_heads[link.index] = no_link; } diff --git a/test/snapshots/dev_object_float_math.md b/test/snapshots/dev_object_float_math.md index 0377c438b9e..7769ffc6ca0 100644 --- a/test/snapshots/dev_object_float_math.md +++ b/test/snapshots/dev_object_float_math.md @@ -51,37 +51,37 @@ main = |x, y, n| { ~~~ # DEV OUTPUT ~~~ini -x64mac=6fd5136f3792850017b66446ecf13dc17e6e079b8f64c80892afd438973dac06 -x64win=efe16a28d32e1f2b4cef3e899999ff76194a48ed8c0ec8f228b766dbe5475098 -x64mingw=efe16a28d32e1f2b4cef3e899999ff76194a48ed8c0ec8f228b766dbe5475098 -x64freebsd=115b22e345ac30f0236504b531fcec95eac7be53ceb4f3b472e6c4289ad7b6f9 -x64openbsd=815ff1c8c357a7619d79d34e497e516c74c2bb7218c77e720a86b43062279440 -x64netbsd=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -x64musl=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -x64glibc=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -x64linux=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -x64elf=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -x64v1mac=6fd5136f3792850017b66446ecf13dc17e6e079b8f64c80892afd438973dac06 -x64v1win=efe16a28d32e1f2b4cef3e899999ff76194a48ed8c0ec8f228b766dbe5475098 -x64v1mingw=efe16a28d32e1f2b4cef3e899999ff76194a48ed8c0ec8f228b766dbe5475098 -x64v1freebsd=115b22e345ac30f0236504b531fcec95eac7be53ceb4f3b472e6c4289ad7b6f9 -x64v1openbsd=815ff1c8c357a7619d79d34e497e516c74c2bb7218c77e720a86b43062279440 -x64v1netbsd=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -x64v1musl=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -x64v1glibc=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -x64v1linux=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -x64v1elf=d25a35d7e048dd89e17d5ce6790b564fbe4b3e99133e08fca0a4f27f4bca07f1 -arm64mac=7390125e5b782fb22c05cbe2e2de0a8b8df85b8c10f6dfcb94968ab6ec94ba74 -arm64win=1c4d0360a4a6122cebf7d34c3fabc34de2fd555393a90e0188ec33d4bfbf68c2 -arm64mingw=1c4d0360a4a6122cebf7d34c3fabc34de2fd555393a90e0188ec33d4bfbf68c2 -arm64linux=f0a0638e0cc6499f44ec49d0506e5d451a3578dcd4d5131e88aa5b2824a1144c -arm64musl=f0a0638e0cc6499f44ec49d0506e5d451a3578dcd4d5131e88aa5b2824a1144c -arm64glibc=f0a0638e0cc6499f44ec49d0506e5d451a3578dcd4d5131e88aa5b2824a1144c -arm64v1win=1c4d0360a4a6122cebf7d34c3fabc34de2fd555393a90e0188ec33d4bfbf68c2 -arm64v1mingw=1c4d0360a4a6122cebf7d34c3fabc34de2fd555393a90e0188ec33d4bfbf68c2 -arm64v1linux=f0a0638e0cc6499f44ec49d0506e5d451a3578dcd4d5131e88aa5b2824a1144c -arm64v1musl=f0a0638e0cc6499f44ec49d0506e5d451a3578dcd4d5131e88aa5b2824a1144c -arm64v1glibc=f0a0638e0cc6499f44ec49d0506e5d451a3578dcd4d5131e88aa5b2824a1144c +x64mac=2974cd8b80f24d070b1ede8204dd7a9b4fa96ae638b1f38d342bfc4216e2b53f +x64win=38a39dbe92a9807073bcb2656d525973013c6efa3f3313b71dd39af257276693 +x64mingw=38a39dbe92a9807073bcb2656d525973013c6efa3f3313b71dd39af257276693 +x64freebsd=b163cfa13afe2e00b77ac9a89ddf9033ca6754addef8128d80cbef4e89637205 +x64openbsd=f657d0ffa61cf2b97bd8b872bc481dfa9f9a5786afb0bf692903c1a3c53f1a20 +x64netbsd=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +x64musl=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +x64glibc=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +x64linux=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +x64elf=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +x64v1mac=2974cd8b80f24d070b1ede8204dd7a9b4fa96ae638b1f38d342bfc4216e2b53f +x64v1win=38a39dbe92a9807073bcb2656d525973013c6efa3f3313b71dd39af257276693 +x64v1mingw=38a39dbe92a9807073bcb2656d525973013c6efa3f3313b71dd39af257276693 +x64v1freebsd=b163cfa13afe2e00b77ac9a89ddf9033ca6754addef8128d80cbef4e89637205 +x64v1openbsd=f657d0ffa61cf2b97bd8b872bc481dfa9f9a5786afb0bf692903c1a3c53f1a20 +x64v1netbsd=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +x64v1musl=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +x64v1glibc=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +x64v1linux=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +x64v1elf=55752b4e3c2ce17f895709ee41c9a28c77ced478ef00de3cba1f2d4476b08f43 +arm64mac=2ba0801c28aae081411e76379f7e23a9c37dbfbf3a290aa0b71df10dd0b4762d +arm64win=3f167f7e302622215cdd09891a57dfe498aac576dc9ed8d0b7d5ed10b2c4005f +arm64mingw=3f167f7e302622215cdd09891a57dfe498aac576dc9ed8d0b7d5ed10b2c4005f +arm64linux=2c26e5e37d4250b70dc11c5878e3be2baa68a527be354f136b9062297c88162c +arm64musl=2c26e5e37d4250b70dc11c5878e3be2baa68a527be354f136b9062297c88162c +arm64glibc=2c26e5e37d4250b70dc11c5878e3be2baa68a527be354f136b9062297c88162c +arm64v1win=3f167f7e302622215cdd09891a57dfe498aac576dc9ed8d0b7d5ed10b2c4005f +arm64v1mingw=3f167f7e302622215cdd09891a57dfe498aac576dc9ed8d0b7d5ed10b2c4005f +arm64v1linux=2c26e5e37d4250b70dc11c5878e3be2baa68a527be354f136b9062297c88162c +arm64v1musl=2c26e5e37d4250b70dc11c5878e3be2baa68a527be354f136b9062297c88162c +arm64v1glibc=2c26e5e37d4250b70dc11c5878e3be2baa68a527be354f136b9062297c88162c arm32linux=NOT_IMPLEMENTED arm32musl=NOT_IMPLEMENTED wasm32=NOT_IMPLEMENTED From 414aa42ce633d62ec2bbe98618038475ff5fa32d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 28 Oct 2026 06:10:16 -0400 Subject: [PATCH 93/97] Match main.roc by exact spelling and fix host-specific cache test assumptions A case-insensitive filesystem reports Main.roc as main.roc, so a module named Main.roc was treated as its own owning app. Cache tests now compare store paths with the store's separators, skip the truncation Windows forbids, and report the failing step of a concurrent store. --- src/cli/pack_store.zig | 5 +++- src/compile/cache_index.zig | 3 ++ src/compile/compile_build.zig | 28 ++++++++++++++++++- src/compile/test/cache_test.zig | 3 +- .../test/released_solver_types_test.zig | 4 ++- 5 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 17bc79f3d9a..01c88f1a5fe 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -3431,7 +3431,10 @@ test "native publication allocation failures release temporary records and retai return err; }; try fixture.publish(&.{F.excerpt(1, "code")}, &.{F.spec(1, 0)}, &.{}); - try std.testing.checkAllAllocationFailures(F.allocator, Check.load, .{&fixture.store}); + std.testing.checkAllAllocationFailures(F.allocator, Check.load, .{&fixture.store}) catch |err| { + std.debug.print("load allocation check: {}\n", .{err}); + return err; + }; var loaded = try fixture.loader(); defer loaded.deinit(); _ = loaded.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; diff --git a/src/compile/cache_index.zig b/src/compile/cache_index.zig index 5e7d1836b66..861976789bf 100644 --- a/src/compile/cache_index.zig +++ b/src/compile/cache_index.zig @@ -762,6 +762,9 @@ test "mapped catalog validates growth and rejects truncation before reading reta try std.testing.expectEqual(@as(Value, @splat(7)), (try tx.find(.artifacts, @splat(23))).?.value); try std.testing.expect(index.mapped.?.memory.len > old_len); } + // Windows refuses to truncate a file below a mapped view, so no other + // process can shrink the catalog under this mapping there. + if (@import("builtin").os.tag == .windows) return; try index.file.setLength(io, record_size); try std.testing.expectError(error.CorruptCatalog, index.refresh()); try std.testing.expect(index.mapped == null); diff --git a/src/compile/compile_build.zig b/src/compile/compile_build.zig index dee656a36f8..dba4523aebc 100644 --- a/src/compile/compile_build.zig +++ b/src/compile/compile_build.zig @@ -843,7 +843,7 @@ pub const BuildEnv = struct { const candidate = try std.fs.path.join(gpa, &.{ current, "main.roc" }); defer gpa.free(candidate); - if (filesystem.fileExists(candidate)) { + if (filesystem.fileExists(candidate) and try isExactlyMainRoc(gpa, filesystem, current, candidate)) { const abs = try std.fs.path.resolve(gpa, &.{candidate}); gpa.free(current); return abs; @@ -863,6 +863,32 @@ pub const BuildEnv = struct { } } + /// A case-insensitive filesystem also reports `Main.roc` as `main.roc`. + /// Only an entry spelled exactly `main.roc` owns its directory. + fn isExactlyMainRoc(gpa: Allocator, filesystem: CoreCtx, dir: []const u8, candidate: []const u8) Allocator.Error!bool { + const canonical = filesystem.canonicalize(candidate, gpa) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + // A file whose stored spelling cannot be read, such as a virtual + // file, answers only to the name it was asked for. + error.FileNotFound, error.AccessDenied, error.IoError => return true, + }; + defer gpa.free(canonical); + if (std.mem.eql(u8, std.fs.path.basename(canonical), "main.roc")) return true; + // A symlink canonicalizes to its target's name; the entry itself decides. + const entries = filesystem.listDirTop(dir, gpa) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.FileNotFound, error.AccessDenied, error.IoError => return false, + }; + defer { + for (entries) |entry| gpa.free(entry.path); + gpa.free(entries); + } + for (entries) |entry| { + if (std.mem.eql(u8, std.fs.path.basename(entry.path), "main.roc")) return true; + } + return false; + } + /// Set or clear the request that cancels this environment's builds (see /// `cancel_request`). pub fn setCancelRequest(self: *BuildEnv, request: ?*const std.atomic.Value(bool)) void { diff --git a/src/compile/test/cache_test.zig b/src/compile/test/cache_test.zig index 75c2f205b90..5ba72c4ffe0 100644 --- a/src/compile/test/cache_test.zig +++ b/src/compile/test/cache_test.zig @@ -96,7 +96,8 @@ test "concurrent cache stores of one key use separate staging files" { defer allocator.free(tmp_path); const filesystem = CoreCtx.os(std.heap.page_allocator, std.heap.page_allocator, std.testing.io); - const config = CacheConfig{ .cache_dir = tmp_path, .roc_ctx = filesystem }; + // Verbose output names the failing step and error if a store fails. + const config = CacheConfig{ .cache_dir = tmp_path, .roc_ctx = filesystem, .verbose = true }; var first = CacheManager.init(std.heap.page_allocator, config, filesystem); var second = CacheManager.init(std.heap.page_allocator, config, filesystem); diff --git a/src/compile/test/released_solver_types_test.zig b/src/compile/test/released_solver_types_test.zig index 8a4db0c00d8..f810f99b927 100644 --- a/src/compile/test/released_solver_types_test.zig +++ b/src/compile/test/released_solver_types_test.zig @@ -221,8 +221,10 @@ fn countUnregisteredStagingFiles(gpa: std.mem.Allocator, io: std.Io, dir: std.Io var count: usize = 0; while (try walker.next(io)) |entry| { if (entry.kind == .file and std.mem.endsWith(u8, entry.basename, ".pending")) { - const path = try std.fs.path.join(gpa, &.{ root, entry.path }); + // Store payload paths separate components with `/` on every host. + const path = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ root, entry.path }); defer gpa.free(path); + std.mem.replaceScalar(u8, path[root.len..], '\\', '/'); if (!try registeredPending(&store, path, null)) count += 1; } } From 3dcf38ea76c41c9b5cdd122d4048da5d7af8e18d Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 28 Oct 2026 08:01:38 -0400 Subject: [PATCH 94/97] Release a staging lease at publication so readers never see a published slot as busy, and run native publication allocation checks over an allocator that refuses in-place growth --- src/cli/pack_store.zig | 9 ++++++--- src/compile/artifact_store.zig | 13 +++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/cli/pack_store.zig b/src/cli/pack_store.zig index 01c88f1a5fe..73c5199c6b1 100644 --- a/src/cli/pack_store.zig +++ b/src/cli/pack_store.zig @@ -3426,19 +3426,22 @@ test "native publication allocation failures release temporary records and retai try std.testing.expectEqual(set.excerpts[0].payload_record, owned.excerpts[0].payload_record); } }; - std.testing.checkAllAllocationFailures(F.allocator, Check.publish, .{}) catch |err| { + // In-place growth under the testing allocator depends on the heap's + // layout, so the failure checks run over an allocator that refuses it. + var deterministic = @import("base").DeterministicAllocator.init(F.allocator); + std.testing.checkAllAllocationFailures(deterministic.allocator(), Check.publish, .{}) catch |err| { std.debug.print("publication allocation check: {}\n", .{err}); return err; }; try fixture.publish(&.{F.excerpt(1, "code")}, &.{F.spec(1, 0)}, &.{}); - std.testing.checkAllAllocationFailures(F.allocator, Check.load, .{&fixture.store}) catch |err| { + std.testing.checkAllAllocationFailures(deterministic.allocator(), Check.load, .{&fixture.store}) catch |err| { std.debug.print("load allocation check: {}\n", .{err}); return err; }; var loaded = try fixture.loader(); defer loaded.deinit(); _ = loaded.specCacheLookup().lookup(@splat(1), .dev_code, &.{}) orelse return error.TestUnexpectedResult; - std.testing.checkAllAllocationFailures(F.allocator, Check.copy, .{loaded.excerpts.get(@splat(1)).?.set}) catch |err| { + std.testing.checkAllAllocationFailures(deterministic.allocator(), Check.copy, .{loaded.excerpts.get(@splat(1)).?.set}) catch |err| { std.debug.print("copy allocation check: {}\n", .{err}); return err; }; diff --git a/src/compile/artifact_store.zig b/src/compile/artifact_store.zig index 0938b710915..bcfea864092 100644 --- a/src/compile/artifact_store.zig +++ b/src/compile/artifact_store.zig @@ -751,12 +751,15 @@ pub const Staging = struct { generation: u64, lease: Io.File, path: []u8, + /// Publication releases the lease while the shard lock still excludes + /// readers, so no reader ever finds a published slot leased exclusively. + published: bool = false, /// Closing a failed staging operation leaves a registered, abandoned slot. /// Maintenance can reclaim it after the staging window; no finalizer must /// run for its ownership to become recoverable. pub fn deinit(self: *Staging) void { - self.lease.close(self.store.io); + if (!self.published) self.lease.close(self.store.io); self.store.allocator.free(self.path); } @@ -792,6 +795,8 @@ pub const Staging = struct { @memcpy(value[0..64], &self.key); _ = try tx.putValue(.recency, metadata.recency(self.slot), value); try tx.commit(); + self.lease.close(self.store.io); + self.published = true; } }; @@ -1094,11 +1099,11 @@ test "interleaved publication and retirement preserve generations and acquired s try std.testing.expectError(error.InvalidArtifact, first.publish()); try Dir.cwd().writeFile(io, .{ .sub_path = second.path, .data = "new" }); try second.publish(); - // Publication ownership is exclusive until the producer closes it. - second.deinit(); - second_open = false; + // Readers accept a published generation while its producer is still open. var accepted = (try store.acquire(id, 2)).?; defer accepted.deinit(); + second.deinit(); + second_open = false; try Dir.cwd().writeFile(io, .{ .sub_path = first.path, .data = "old" }); try first.publish(); const bytes = try accepted.read(allocator); From 46736bd4796d5c2d52e1a65e51e6534bc2fc58c5 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 28 Oct 2026 08:51:23 -0400 Subject: [PATCH 95/97] Give the macOS MiniCI build the same headroom as the Windows build --- .github/workflows/ci_manager.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci_manager.yml b/.github/workflows/ci_manager.yml index 2262d20c876..c0265bd0248 100644 --- a/.github/workflows/ci_manager.yml +++ b/.github/workflows/ci_manager.yml @@ -434,7 +434,9 @@ jobs: if: needs.check-changes.outputs.heavy == 'true' && !github.event.pull_request.draft name: minici-build (macos-15) runs-on: macos-15 - timeout-minutes: 120 + # The Zig 0.17 build takes about 110 to 120 minutes here, so 120 leaves no + # margin. + timeout-minutes: 180 steps: *minici_build_steps minici-build-windows: From 798c5ddf9101b4e2088731ad2db04fb105df8717 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 28 Oct 2026 11:22:51 -0400 Subject: [PATCH 96/97] Create each shard's coordination file exclusively before locking it, since macOS can fail concurrent non-exclusive creations of one name --- src/compile/artifact_store.zig | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/compile/artifact_store.zig b/src/compile/artifact_store.zig index bcfea864092..7bc57f7d38f 100644 --- a/src/compile/artifact_store.zig +++ b/src/compile/artifact_store.zig @@ -682,7 +682,16 @@ pub const Shard = struct { else => return error.IoFailure, }; errdefer dir.close(store.io); - const lock = dir.createFile(store.io, "coordination", .{ .read = true, .truncate = false, .lock = .exclusive, .lock_nonblocking = !required }) catch |err| switch (err) { + // macOS can fail concurrent non-exclusive creations of one name with + // FileNotFound. Exactly one exclusive creation succeeds; every opener + // then locks the existing file without creating it. + if (dir.createFile(store.io, "coordination", .{ .exclusive = true })) |created| { + created.close(store.io); + } else |err| switch (err) { + error.PathAlreadyExists => {}, + else => return error.IoFailure, + } + const lock = dir.openFile(store.io, "coordination", .{ .mode = .read_write, .lock = .exclusive, .lock_nonblocking = !required }) catch |err| switch (err) { error.WouldBlock => return error.Busy, else => return error.IoFailure, }; From 773d11537f1c22a407cee86d9751a3dca9718c74 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Wed, 28 Oct 2026 13:31:46 -0400 Subject: [PATCH 97/97] Build expected staging paths from the store's own root so Windows separators match --- src/compile/test/released_solver_types_test.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/compile/test/released_solver_types_test.zig b/src/compile/test/released_solver_types_test.zig index f810f99b927..4a357862212 100644 --- a/src/compile/test/released_solver_types_test.zig +++ b/src/compile/test/released_solver_types_test.zig @@ -221,10 +221,12 @@ fn countUnregisteredStagingFiles(gpa: std.mem.Allocator, io: std.Io, dir: std.Io var count: usize = 0; while (try walker.next(io)) |entry| { if (entry.kind == .file and std.mem.endsWith(u8, entry.basename, ".pending")) { - // Store payload paths separate components with `/` on every host. - const path = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ root, entry.path }); + // Payload paths join the store root and the components below it + // with `/` on every host. + const below_root = entry.path[artifacts.format_directory.len + 1 ..]; + const path = try std.fmt.allocPrint(gpa, "{s}/{s}", .{ store.root, below_root }); defer gpa.free(path); - std.mem.replaceScalar(u8, path[root.len..], '\\', '/'); + std.mem.replaceScalar(u8, path[store.root.len..], '\\', '/'); if (!try registeredPending(&store, path, null)) count += 1; } }