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: diff --git a/.github/workflows/ci_zig.yml b/.github/workflows/ci_zig.yml index 8e3a942c412..2c74785d334 100644 --- a/.github/workflows/ci_zig.yml +++ b/.github/workflows/ci_zig.yml @@ -162,6 +162,9 @@ jobs: - name: Run CLI tests (LLVM size/speed backends) run: zig build run-test-cli -- --llvm-only + - name: Run focused test outcome cache regressions + 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/build.zig b/build.zig index cc8e0331b8e..b7a5ffe8a76 100755 --- a/build.zig +++ b/build.zig @@ -1933,7 +1933,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 @@ -2477,6 +2477,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. @@ -2780,6 +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"), .imports = &.{.{ .name = "base", .module = roc_modules.base }} }) }, }, }), }); @@ -4165,6 +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"), .imports = &.{.{ .name = "base", .module = roc_modules.base }} }) }, }, }), .filters = test_filters, diff --git a/ci/test_outcome_cache.py b/ci/test_outcome_cache.py new file mode 100644 index 00000000000..599472c7fa9 --- /dev/null +++ b/ci/test_outcome_cache.py @@ -0,0 +1,341 @@ +#!/usr/bin/env python3 +"""Focused individual-outcome and reporting-relocation checks (#12593/#12615).""" + +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_V3')) == b'ROC_TEST_OUTCOMES_V3' + + +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), ROC_TRACE_BOXY_WORK="1") + 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", work=False): + 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") + env = dict(self.env, ROC_TEST_COMMAND_WORK="1") if work else self.env + 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"): + 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")) + if 'Completed test command' in text: + return cached + 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 relocation_checks(f): + def edit(source, hits, executed=0, name="Main.roc"): + f.write(source, name) + run = f.run(timings=True, work=True) + f.hits(run, hits) + counter = re.search(rb"test-work test_root_execution=(\d+)", run.stderr) + assert counter and int(counter[1]) == executed, run.stderr.decode(errors="replace") + f.check() + + passing = 'expect { dbg "Main.roc:123: user bytes"\n 1 == 1 }\n' + failing = 'expect { dbg "failed"\n 2 == 3 }\n' + base = passing + failing + f.write(base) + f.check(2) + edit('# inserted comment\n\n' + base, 2) + edit(base, 2) + edit(failing + passing, 2) + edit(failing + passing.replace('1 == 1', '100 == 100'), 1, 1) + edit(passing.replace('1 == 1', '1000 == 1000') + failing, 1, 1) + edit('expect 42 == 42\n' + passing + failing, 2, 1) + edit(passing * 3 + failing * 2, 5) + edit('\n# duplicate occurrences move together\n' + failing * 2 + passing * 3, 5) + + duplicate_inline = 'expect { expect False\n dbg "nested duplicate"\n True }\n' + f.write(duplicate_inline * 2) + f.check(2) + edit('# before duplicate inline sites\n' + duplicate_inline + '\n' + duplicate_inline, 2) + + helper = ('observe = |x| { dbg "Helper.roc:5: user text"\n' + ' expect x > 0\n x }\n') + roots = 'expect observe(1) == 1\nexpect observe(0) == 0\n' + f.write(helper + roots) + f.check(2) + edit('# move helper and roots\n\n' + helper + roots, 2) + edit(roots + '\n# helper moved independently\n' + helper, 2) + edit(roots + helper.replace('x > 0', 'x > 1'), 0, 2) + + imported = 'module [observe]\n' + helper + f.write(imported, 'Observer.roc') + f.write('import Observer\nexpect Observer.observe(0) == 0\n') + f.check(1) + edit('module [observe]\n# helper moved\n\n' + helper, 1, name='Observer.roc') + edit(imported.replace('x > 0', 'x > 1'), 0, 1, name='Observer.roc') + + typed = ('module [make]\nThing := { value : U64 }\n' + 'make = || Thing.{ value: 1 }\n') + f.write(typed, 'Typed.roc') + f.write('import Typed\nhelper = || Typed.make()\nexpect helper().value == 1\n') + f.check(1) + edit('# moved imported nominal and helper\n' + typed, 1, name='Typed.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') + f.write(defaults) + f.check(1) + edit('# before a defaulted type\n' + defaults, 1) + edit(defaults.replace('?? 1', '?? 2'), 0, 1) + + scoped = ('left = || { value = 1\n expect value == 1\n True }\n' + 'right = || { value = 2\n expect value == 1\n True }\n' + 'expect left()\nexpect right()\n') + f.write(scoped) + f.check(2) + edit('# before distinct binding scopes\n' + scoped, 2) + edit(scoped.replace('value = 2', 'value = 3'), 1, 1) + + asset = 'import "asset.txt" as data : Str\nexpect data == "before"\n' + f.write('before', 'asset.txt') + f.write(asset) + f.check(1) + edit('# before a file import\n' + asset, 1) + edit('after', 0, 1, name='asset.txt') + + # Failure details include current binding excerpts; user messages are exact. + crashing = 'expect { dbg "unchanged crash output"\n crash "Main.roc:99" }\n' + f.write(crashing) + f.check(1) + edit('# before a crash\n' + crashing, 1) + question = 'expect { value : Try(U64, [Oops])\n value = Err(Oops)\n value? == 1 }\n' + f.write(question) + f.check(1) + edit('\n# before an extended failure span\n' + question, 1) + + +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: + fixture = Fixture(Path(temp), args.roc.resolve()) + exercise(fixture) + relocation_checks(fixture) + print('issues 12593/12615: focused test-outcome cache checks passed') + + +if __name__ == '__main__': + main() diff --git a/design.md b/design.md index c37212639ac..335e715918d 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 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 +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 @@ -646,7 +656,7 @@ and source frames from it. ### Native dev procedure compatibility -`backend.dev.NativeProcDomain` declares the artifact contract separately from +`backend.dev.NativeProcDomain` declares the excerpt contract separately from final-link targets. Only specialized x64 Linux musl/glibc procedures share a domain, at matching CPU floors; default and v1 remain separate. Other target pairs and Boxy retain separate domains. The domain names the compiler @@ -666,10 +676,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 entry store. Program outputs use +`-dev`, and optimized packs retain their exact target and optimization +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. @@ -679,24 +689,505 @@ 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 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 +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. 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 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 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, +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 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; checked +equality alone never erases an alias or generated backing. + +A field default's durable address is its declaring module content identity, exact +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 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. + +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 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 commit 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 +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 +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 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 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 +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 +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 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. +`ROC_DISABLE_PERSISTENT_INTERFACES` is a developer-diagnostic-only comparison +switch, read solely through `base.dev_diagnostics`. + +### Indexed cache storage and maintenance + +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 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 +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 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, 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, 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 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 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 +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. + +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 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. 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 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 +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 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. + + +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 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 +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. Entry family and eligibility +are explicit metadata. Cache use is recorded on acquisition, independently of +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: commit can outpace +maintenance indefinitely, and pinned files, metadata, scratch, external changes, +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 +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. + +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 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 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. +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 Boxy runtime references remain ineligible for packs. Only the +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 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. 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, +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 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 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 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. 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 entry catalog generation lease, and +cache maintenance respects that lease before removing these records. A +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 commit failures or lookup misses. + +Splicing records the immutable payload on the assembled code region. Extraction +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 normalized assembly retain +the acquisition owner explicitly. This contract never identifies code by +inspecting instructions or equating paths, lengths or timestamps. + ### 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. +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 +glibc build at the same CPU floor share these records. Recorded program +outputs, immutable raw link objects and optimized object packs retain the +exact final-link target. Compiler compatibility, +procedure domain (pack format, target contract, CPU floor, specialization +strategy, representation revision and execution capability), optimization +policy and schema isolation are 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, indexed +metadata admission rejects runtime references too, and Boxy has its own +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 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, +payloads, completion markers and interface summaries. Offers are grouped by +specialization key and keyed by the digest of their complete record. Each +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 advertised origin, filters by provider and sorts by rank +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 commit. An accepted +advertisement remains valid through its retained generation lease, including +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 commit independent immutable records. + +The exact native splice graph is partitioned into strongly connected +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 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 +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 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 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. + +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 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; +commit to another namespace uses its validated bytes and establishes +availability there independently. + +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 commit protocol, including corruption handling. +Payloads become available before metadata, metadata before offers, and the +`ROCINDEX01` producer completion marker comes last. It records successful +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. + +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 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 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. +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 +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 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 +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. 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 entries is +independent of eagerly reading them. A pack-format change selects new +namespaces without a migration scan. + +Optimized object packs use the exact-target catalog scope. Explicit pack +export/import for developer diagnostics still uses complete `PackFile` records. +All persistent records and raw objects participate in the indexed maintenance +protocol above; there is no directory sweep. Local procedure records and +program outputs use the one-day policy; package and optimized records use +thirty days. Scratch hard links retain inodes through a link while their +registered scratch lease prevents maintenance from reclaiming the directory. +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 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. 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 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 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 @@ -711,8 +1202,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 @@ -786,24 +1277,21 @@ 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 -packages' host dev packs under the host procedure domain's `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. -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. +A caching `roc check` may deliberately prepare URL-package host dev packs, +under the host procedure domain's `pkg/`, 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/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 @@ -874,10 +1362,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. @@ -910,7 +1399,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 @@ -947,23 +1436,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 @@ -971,10 +1460,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. @@ -982,14 +1471,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. @@ -1480,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. @@ -2390,9 +2879,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. @@ -2404,6 +2897,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 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 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 +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 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 results, 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 @@ -2430,6 +2948,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 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. + #### Compile-Time Object Packs A runtime build fills the object cache with packs of dev code; compile-time @@ -2448,8 +2972,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: @@ -2744,31 +3269,66 @@ 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 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 completed-outcome 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 and additional selected optimized objects by content identity, 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 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. Commit uses +a temporary file and atomic rename. Incompatible, truncated, corrupt or missing +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. Commit failure leaves the live compilation intact. Every +invocation validates current platform link inputs and links the retained +entries, even if a linked program already exists. Timings distinguish early hits, +checked-module loads and compilation, input validation time/bytes, manifest reads, +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. @@ -2785,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. @@ -2799,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 @@ -2829,6 +3389,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 checked 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 @@ -2840,8 +3404,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 @@ -2855,7 +3421,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 commit explicitly +invalidates any earlier demand summary for that declaration, and a declaration +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 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 @@ -2914,7 +3485,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. @@ -2936,6 +3507,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 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. + **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 @@ -2959,11 +3538,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 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 @@ -2988,6 +3573,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 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 +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 @@ -3029,6 +3625,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 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 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 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. + **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 @@ -4641,10 +5250,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 @@ -4946,7 +5555,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 @@ -4964,16 +5573,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 @@ -5028,6 +5637,108 @@ 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 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 meaning, +generated-method and hosted-declaration classifications, invoking-callable +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 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 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 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 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 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 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 +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 -> 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 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 +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 +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 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 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 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 +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. 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 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 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 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 +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 A serialization root is a `Serialized` type whose bytes are admitted from outside @@ -7703,7 +8414,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 @@ -8495,16 +9206,26 @@ 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 - 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. 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. + 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. @@ -8520,8 +9241,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. @@ -9196,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 @@ -9211,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 @@ -10444,6 +11171,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 checked 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 @@ -10976,7 +11714,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 @@ -11599,7 +12337,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 @@ -13199,6 +13937,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 mapping 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 @@ -13567,7 +14313,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 @@ -14985,6 +15731,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 @@ -15018,7 +15769,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 @@ -15566,6 +16323,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 @@ -15690,6 +16454,40 @@ 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 + +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 +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 commits every node's converged answer, including false. + +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 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 commit +its previous assumptions as results about the resulting graph. + +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 +truth is inferred from a compatibility proof. + ### Row, Nominal, Alias, And Opaque Authority Monotype lowering is the sole owner of turning checked type data into closed @@ -16354,9 +17152,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 @@ -16446,7 +17244,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 @@ -17620,6 +18418,90 @@ 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 compatibility proofs cross those +boundaries. + +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 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 +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 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 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 +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 results, independent of +unrelated graph writes. Path compression +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 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 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 results (3,224 accounted proof bytes; 5,608 total +query-owned bytes including scratch, across 38 initial allocations), and three +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. +`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. + +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. +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 solver checks the immutable Monotype graph for scoped argument occurrences, @@ -17628,6 +18510,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 @@ -20202,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 @@ -20226,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 @@ -20403,7 +21305,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 @@ -21165,6 +22067,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 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 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 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 deferred call `returns_pending` when its value reaches the return through @@ -21568,11 +22478,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 @@ -21583,6 +22497,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 meaning and eligibility. + ### Outcome-Conditioned Argument Restitution An owned direct-call argument may be returned to its caller as an ownership @@ -21610,6 +22532,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 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. + 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 @@ -21963,7 +22891,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 @@ -22502,12 +23430,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 @@ -22832,6 +23760,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 @@ -23228,9 +24162,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: @@ -23263,7 +24197,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 @@ -23779,16 +24713,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 @@ -23807,6 +24741,52 @@ fresh observations only during the existing deterministic reporting merge. Work counters distinguish the complete reporting plan and cache admission from the procedures and entrypoints actually emitted for 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 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 results, and the storage source +paths admitted by import resolution. Validation hashes bytes and revalidates +those storage paths, never timestamps. Resolution runs before admission, so +package sidecars, replacement requests and resolution failures retain their +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 +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 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. + +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 components are relative to the current +working directory and are rebound only after validation. Compilation diagnostics +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. + +### 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 @@ -23830,8 +24810,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 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: @@ -23847,7 +24826,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 ``` @@ -23911,7 +24890,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 @@ -23928,10 +24907,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. @@ -23963,19 +24943,92 @@ 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 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; +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 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. +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. + +Nominal and alias types in test dependency summaries use producer-authored +`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 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 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 +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 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 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 checked outcome; reads +still validate that provenance against the current sources. + +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 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 +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 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; +replay never rescans or hashes source prefixes. Multiple current regions for +one site identity are ambiguous and make that optional outcome unusable. +Root-relative binding explicitly selects the current occurrence, so duplicate +root bodies never require matching by old list position or proximity. + +The decoder validates every reference, span, counter, event and checksum before +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 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 +execution are not durable outcomes. + +Completed-command format V2 references durable individual outcomes and retains +producer-authored report maps and each occurrence's current region, order and +key under its exact validated input manifest. Both replay paths call the same +outcome decoder. After an edit the command manifest misses, and current checking +supplies fresh maps for individual reuse; a complete hit needs no checked graph. + +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. 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 transcript events, source regions, failure data, and raw or structured Roc @@ -24354,7 +25407,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 @@ -24365,21 +25418,27 @@ 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 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 +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. 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 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 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. @@ -25630,7 +26689,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 @@ -25648,6 +26707,20 @@ 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. 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 +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 @@ -26247,3 +27320,319 @@ 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 commits a checked +validation certificate under the checked producer's dependency identity, native +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 +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 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 +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 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 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 +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. 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. + +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 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 +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 +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 +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` result determines whether an occurrence +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 +recognize identical closed shapes without differences introduced solely by +whether an interface arrived from a cache or from fresh lowering. + + +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, 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 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. + + +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. +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 +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. + +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. +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. + +Native component acquisition may group the exact content addresses supplied by +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 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 +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 +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 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. + + +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 +commit, 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 +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 +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. + +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 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; +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 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. + +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 +lazy Monotype leaves, and solver-private clone-context ids are not output and +must never be persisted. The current lowered 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. + +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 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 +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 +demonstrated useful reuse on the measured applications. Ordinary builds do not +pay for its input fingerprinting, encoding, or commit. 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 commit 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/backend/dev/ProcArtifact.zig b/src/backend/dev/CodeExcerpt.zig similarity index 79% rename from src/backend/dev/ProcArtifact.zig rename to src/backend/dev/CodeExcerpt.zig index f0dca817214..24e5b6ad911 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"); @@ -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. @@ -68,26 +71,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 +103,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 +114,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 +124,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 +162,14 @@ 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 { + /// 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. + /// Offset within `code` that references to this excerpt resolve to. entry: u32, frame: ?Frame, refs: []const Reference, @@ -171,8 +178,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,23 +187,73 @@ pub const Artifact = struct { loc: base.SourceLoc = .none, }; -/// An ordered set of artifacts covering one code buffer. Order is emission +/// An excerpt'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 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 = &.{}, + 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, excerpts: []const Excerpt) Allocator.Error![]const *base.retained_cache.Owner { + var owners = std.AutoHashMap(*base.retained_cache.Owner, void).init(a); + defer owners.deinit(); + for (excerpts) |excerpt| { + if (excerpt.payload_record) |record| try owners.put(record.owner, {}); + if (excerpt.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(); } - /// The index of the artifact defining `target`, a symbolic reference's + /// 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 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 +265,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 +398,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 +410,12 @@ 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 }; + const retained = try Set.retainRecordOwners(arena.allocator(), owned); + return .{ .arena = arena, .excerpts = owned, .source_files = owned_source_files, .record_owners = retained }; } /// Deep-copy sets compiled from programs of one Solved program, which name a @@ -368,9 +426,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 +464,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 +479,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 +504,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 +512,34 @@ 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); + for (@constCast(excerpts.items[first].symbolic_refs), source.symbolic_refs) |*ref, original| if (original.binding == .local) { + ref.binding = .{ .local = map[original.binding.local] }; + }; + 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 }; + const retained = try Set.retainRecordOwners(arena.allocator(), owned_excerpts); + return .{ .arena = arena, .excerpts = owned_excerpts, .source_files = owned_source_files, .record_owners = retained }; } -/// 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 +558,51 @@ 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); + const retained = try Set.retainRecordOwners(arena.allocator(), owned); + return .{ .arena = arena, .excerpts = owned, .record_owners = retained }; } -/// 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| 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 => {}, }; - 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 +615,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 +624,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 +657,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 +863,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 +927,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 +1000,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 +1010,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 +1040,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 +1069,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 +1110,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 +1144,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,13 +1165,18 @@ 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. - const region_data = try captureData(allocator, arena_allocator, region_relocations.items, prepared); + // an excerpt carries the whole constant graph it points into. + 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,13 +1193,18 @@ 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 = 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.normalizeExcerptCode(region.start, bytes); + for (region_refs.items) |ref| CG.normalizeExcerptRefSite(bytes, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); + for (symbolic_refs.items) |ref| CG.normalizeExcerptRefSite(bytes, ref.site, codeRefForm(ref.form), if (ref.veneer) |offset| offset else null); + break :code_bytes bytes; + }; + excerpts[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, + .code = excerpt_code, .entry = @intCast(region.entry), .frame = frame, .refs = try region_refs.toOwnedSlice(arena_allocator), @@ -1136,7 +1212,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 +1224,8 @@ pub fn extractPrepared( }; } - return .{ .arena = arena, .artifacts = artifacts }; + const retained = try Set.retainRecordOwners(arena.allocator(), excerpts); + return .{ .arena = arena, .excerpts = excerpts, .record_owners = retained }; } fn refSite(ref: anytype) ?usize { @@ -1158,7 +1235,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 +1445,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 +1486,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 +1509,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 +1523,46 @@ 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 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 excerpt = set.excerpts[index]; + for (excerpt.refs) |ref| try excerpt_work.append(allocator, ref.target); + for (excerpt.symbolic_refs) |ref| { + 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); + 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 +1571,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 +1581,80 @@ 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| 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 => {}, }; - 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 }; + const retained = try Set.retainRecordOwners(arena.allocator(), excerpts); + return .{ .arena = arena, .excerpts = excerpts, .source_files = source_files, .record_owners = retained }; } -/// 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 +1668,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 +1724,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) }, @@ -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 => {}, @@ -1665,7 +1753,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 +1762,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,17 +1779,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 }, - .data = data[0..data_len], - }; + const retained = try Set.retainRecordOwners(arena.allocator(), excerpts); + return .{ .set = .{ .arena = arena, .excerpts = excerpts, .source_files = source_files, .record_owners = retained }, .data = data[0..data_len] }; } fn renamedData(a: Allocator, item: DataItem, names: *const ContentNames) Allocator.Error!DataItem { @@ -1727,11 +1813,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 +1827,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 +1842,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 +1855,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 +1866,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 +1878,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 +1903,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 +1960,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 +1972,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 +1985,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 +2010,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 +2018,45 @@ 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 { + if (excerpt.payload_record) |record| codegen.retainAssembledPayload(start, .{ .record = record, .metadata_origin = excerpt.metadata_origin, .code = excerpt.code, .data = excerpt.data }); + 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 +2072,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 +2152,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 +2192,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 +2205,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,55 +2216,81 @@ 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); + 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.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.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); 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.excerpts[first..]) |excerpt| for (excerpt.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.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 => {}, + }; + } + 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 (artifact.symbolic_refs) |ref| { - const target = switch (ref.target) { - .proc => |identity| switch (proc_symbols) { - .follow => by_proc.get(identity), - .skip => null, + for (set.excerpts[first..], first..) |excerpt, index| { + for (excerpt.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 (excerpt.symbolic_refs) |ref| { + 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); @@ -2185,19 +2298,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 +2321,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 +2329,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 +2341,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 +2386,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 +2400,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 +2408,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. @@ -2303,13 +2416,13 @@ 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); } } 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 +2430,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 +2454,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 +2478,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 +2521,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 +2534,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 +2563,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 +2597,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, @@ -2499,6 +2612,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 { @@ -2516,7 +2630,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 +2647,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 +2674,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 +2706,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 +2733,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 +2757,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 +2784,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 +2804,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 +2864,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 +2875,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 +2888,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 +2897,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 +2910,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 +2941,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 +2986,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 +3005,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 +3034,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 c0292b3e32f..5d75985d054 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 }, @@ -498,7 +498,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, @@ -545,13 +545,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 6f98202adb4..e97967a22fd 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`). @@ -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("CodeExcerpt.zig").AssembledPayload = null, start: usize, end: usize, /// Offset from `start` that references to this region resolve to. @@ -1065,7 +1066,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 +12637,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 +13095,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); @@ -14411,7 +14412,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); @@ -14447,7 +14448,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); @@ -14530,7 +14531,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 { @@ -14565,7 +14566,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", .{}); @@ -15713,66 +15714,66 @@ 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 { if (self.store.runtime_contract == .specialized) return; 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); @@ -15780,14 +15781,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); } @@ -15796,7 +15797,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), @@ -16061,7 +16062,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); @@ -16157,7 +16158,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), @@ -16481,7 +16482,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); @@ -16505,7 +16506,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); @@ -16545,7 +16546,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); @@ -16588,7 +16589,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); @@ -22352,7 +22353,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); @@ -22406,7 +22407,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); @@ -23062,6 +23063,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("CodeExcerpt.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; } @@ -23174,7 +23183,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 { @@ -23182,7 +23191,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); @@ -23198,7 +23207,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, @@ -23382,12 +23391,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, @@ -23400,7 +23409,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 @@ -23411,11 +23420,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) { @@ -23451,7 +23460,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; @@ -24360,9 +24369,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(); @@ -24384,18 +24393,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| { @@ -24416,12 +24425,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); @@ -24437,20 +24446,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)); } } } @@ -26170,8 +26179,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); @@ -26182,7 +26191,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); @@ -26205,20 +26214,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); @@ -26226,17 +26235,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); @@ -26247,7 +26256,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); @@ -26310,7 +26319,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 ce3cd546b6f..069fd3e3997 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); @@ -657,7 +657,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(); @@ -706,7 +706,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 74c449ea531..d70e049c66a 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,34 @@ //! 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. -pub const format_version: u32 = 13; +pub const format_version: u32 = 19; /// 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 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, + /// 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 = &.{}, @@ -47,7 +53,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,34 +67,40 @@ 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(); + 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 CodeExcerpt.Set, specs: []const SpecEntry, names: *const CodeExcerpt.ContentNames) Allocator.Error![]u8 { var bytes = std.ArrayList(u8).empty; errdefer bytes.deinit(allocator); var writer = Writer{ .allocator = allocator, .bytes = &bytes }; 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 +109,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 +127,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 +139,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 +152,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, @@ -149,6 +161,14 @@ pub fn write(allocator: Allocator, set: *const ProcArtifact.Set, specs: []const .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); @@ -164,21 +184,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 +234,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 +249,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 +267,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 +314,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 +331,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 +348,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 +366,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(), @@ -360,6 +380,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 >= excerpt_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() }, @@ -370,7 +400,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 +409,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 +439,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 +457,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 +502,7 @@ pub fn readOwned(owning_arena: collections.SingleThreadArena, bytes: []const u8) .relocations = data_relocations, }; } - artifact.* = .{ + excerpt.* = .{ .kind = kind, .code = code, .entry = entry, @@ -482,30 +512,41 @@ 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); + for (excerpts) |artifact| for (artifact.symbolic_refs) |ref| { + if (ref.binding != .local) continue; + 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), + .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, 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, @@ -622,6 +663,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 { @@ -635,7 +679,10 @@ 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.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); @@ -652,14 +699,29 @@ 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(), + .test_compatible = switch (try reader.byte()) { + 0 => false, + 1 => true, + else => return error.MalformedPack, + }, + .may_leave_pending = switch (try reader.byte()) { + 0 => false, + 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() }; @@ -684,7 +746,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 +825,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 +853,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 +886,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,18 +916,20 @@ 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, .rc_read_only_params = 0b100, .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 }, @@ -882,7 +946,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); @@ -890,16 +954,18 @@ test "pack bytes round-trip every artifact 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.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.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 +975,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 +995,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 +1009,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 +1023,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 +1060,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 +1093,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 +1115,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 +1138,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 61e7cae5497..c1125483932 100644 --- a/src/backend/dev/mod.zig +++ b/src/backend/dev/mod.zig @@ -42,21 +42,21 @@ 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"); +/// Per-region machine-code excerpts and their reassembly. +pub const CodeExcerpt = @import("CodeExcerpt.zig"); /// Declared compatibility of relocatable native dev procedures. pub const NativeProcDomain = @import("NativeProcDomain.zig"); -/// Shared native procedure task driver and same-program retained artifacts. +/// 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 c9b308ecf2e..f5415c79b03 100644 --- a/src/backend/llvm/MonoLlvmCodeGen.zig +++ b/src/backend/llvm/MonoLlvmCodeGen.zig @@ -386,7 +386,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 3bb08cad58b..a54dcf7b1dc 100644 --- a/src/base/dev_diagnostics.zig +++ b/src/base/dev_diagnostics.zig @@ -18,14 +18,28 @@ 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, + /// 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. 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. 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. @@ -35,28 +49,62 @@ 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, + /// 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 { 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", + .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", + .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", .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", + .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)); + +/// 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); +} + /// 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/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..a1b15daaaf1 --- /dev/null +++ b/src/base/retained_cache.zig @@ -0,0 +1,84 @@ +//! Owned, validated immutable cache records. This is an acquisition lifetime, +//! not a storage namespace: the catalog supplies the retained generation lease. +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, native_offer_domain_v1 }; + +/// 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.retention.deinit(record.retention.context); + self.allocator.free(record.bytes); + self.allocator.destroy(record); + } + self.records.deinit(self.allocator); + self.allocator.destroy(self); + } + + /// Take producer bytes and their storage lease only on successful admission. + /// The caller owns both arguments on failure. + pub fn take(self: *Owner, bytes: []const u8, expected: [32]u8, kind: Kind, storage_identity: [32]u8, retention: Resource) (Allocator.Error || error{Corrupt})!*const Record { + self.metrics.acquisitions += 1; + self.metrics.bytes_read += bytes.len; + self.metrics.bytes_hashed += bytes.len; + var hash: [32]u8 = undefined; + @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); + record.* = .{ .owner = self, .retention = retention, .bytes = bytes, .digest = hash, .storage_identity = storage_identity, .kind = kind }; + try self.records.append(self.allocator, record); + self.metrics.retained_bytes += bytes.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, + retention: Owner.Resource, + bytes: []const u8, + digest: [32]u8, + storage_identity: [32]u8, +}; diff --git a/src/canonicalize/ModuleEnv.zig b/src/canonicalize/ModuleEnv.zig index ea46fc2ef8e..0532376d040 100644 --- a/src/canonicalize/ModuleEnv.zig +++ b/src/canonicalize/ModuleEnv.zig @@ -888,6 +888,57 @@ pub const BindingScheme = extern struct { pub const SafeList = collections.SafeList(@This()); }; +/// 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, + 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) = .{}, + /// 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), .references = self.references.deserializeInto(address), .published = self.published != 0 }; + } + + pub fn deserializeWithCopy(self: *const @This(), address: usize, allocator: Allocator) Allocator.Error!CheckingDependency { + 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 }; + } + }; +}; + /// 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 +1077,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 or method 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 +1506,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 +1559,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 +1624,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 +1672,26 @@ pub fn defInvokes(self: *const Self, def_idx: CIR.Def.Idx) bool { return false; } +/// 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; + 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 callable definition 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 +4042,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 +4147,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 +4248,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 +4333,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 +4430,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/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/Check.zig b/src/check/Check.zig index 71505394991..c6e99aab4a0 100644 --- a/src/check/Check.zig +++ b/src/check/Check.zig @@ -13044,6 +13044,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(); } @@ -14091,6 +14096,72 @@ 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. +/// +/// 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 { + 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 (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); + 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 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, @@ -14107,6 +14178,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 }, @@ -14140,7 +14216,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 }; @@ -14152,6 +14228,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 callable definition", .{}); + try publication.graph.addDependency(publication.caller, callee); + return .{ .value = true }; + } const key = HoistedCallableKey{ .module_addr = @intFromPtr(callable_def.module), .def = callable_def.def, @@ -14206,7 +14291,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 }; @@ -14358,7 +14446,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: { @@ -14433,15 +14521,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); } @@ -14449,10 +14544,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 }; } } @@ -14463,7 +14558,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; @@ -14473,45 +14568,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); } } }, @@ -33211,12 +33306,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)); @@ -34062,7 +34153,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; @@ -35753,7 +35844,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..cefe6ebeba9 --- /dev/null +++ b/src/check/callable_checking.zig @@ -0,0 +1,113 @@ +//! Producer-owned stored-constant eligibility of callable definitions. +//! 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, + + /// 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); + 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 }; + } + + /// Free the graph's storage. + 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); + } + + /// 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; + try self.edges.append(self.allocator, .{ .caller = caller, .next = self.heads[@backingInt(callee)] }); + 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; + // 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; + } + } + } + + /// 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)]; + } +}; + +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(); + // 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(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 5d58183a039..9a6fd077f93 100644 --- a/src/check/checked_artifact.zig +++ b/src/check/checked_artifact.zig @@ -33854,11 +33854,21 @@ pub const CompileTimeRootReadTable = struct { /// Value identity and site of a compile-time root (design.md "Compile-Time /// Value Reuse Across Edits"). pub const CompileTimeRootIdentity = value_identity.CompileTimeRootIdentity; +/// A relocatable test observation site and its current source span. +pub const TestReportSite = value_identity.TestReportSite; /// Every compile-time root's value identity and site, dense by root id, and /// 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 = &.{}, + test_report_sites: []const value_identity.TestReportSite = &.{}, + test_declarations: []const value_identity.DeclarationIdentity = &.{}, /// The module's value context (`value_identity.ModuleIdentities.context`): /// one digest in every published module, none in a table that was never /// computed. @@ -33874,6 +33884,11 @@ 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) = .{}, + test_report_sites: SerializedSlice(value_identity.TestReportSite) = .{}, + test_declarations: SerializedSlice(value_identity.DeclarationIdentity) = .{}, context: SerializedSlice([32]u8) = .{}, exports: SerializedSlice(value_identity.ExportIdentity) = .{}, method_requires: SerializedSlice([32]u8) = .{}, @@ -33886,6 +33901,12 @@ 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; + for (self.test_report_sites) |site| if (site.start > site.end) return error.CorruptArtifact; + for (self.test_declarations, 0..) |declaration, i| { + if (i > 0 and @backingInt(self.test_declarations[i - 1].statement) >= @backingInt(declaration.statement)) return error.CorruptArtifact; + } } /// The module's value context, when it was computed. @@ -34239,8 +34260,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, definition summaries, report sites and declaration identities. + std.debug.assert(artifact_serialize.relocatablePointerCount(Serialized) == 251); } /// Every field but the inline POD `key`, `source_hash`, and @@ -37451,6 +37473,45 @@ 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, + source_pin_validation: [32]u8, + missing_public_types: []const []const u8, +) [32]u8 { + var hasher = base.Sha256.init(.{}); + hasher.update("roc-inference-inputs-v2"); + 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)); + 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; + 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, @@ -37896,13 +37957,20 @@ 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); + defer allocator.free(module_identities.declarations); var compile_time_root_identities = CompileTimeRootIdentityTable{ .rows = module_identities.roots, .exports = module_identities.exports, @@ -37911,6 +37979,38 @@ 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, + .bodies = checked_bodies, + .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_declarations = test_identities.declarations; + compile_time_root_identities.test_report_sites = test_identities.report_sites; + 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, @@ -40537,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/checking_dependencies.zig b/src/check/checking_dependencies.zig new file mode 100644 index 00000000000..94c9aa7739e --- /dev/null +++ b/src/check/checking_dependencies.zig @@ -0,0 +1,176 @@ +//! 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 = 2; + +/// 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, + imported: []const *const ModuleEnv = &.{}, + environments: std.AutoHashMapUnmanaged(Hash, *const ModuleEnv) = .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); + self.environments.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", .{}); + 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) { + 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..690df4b03d6 --- /dev/null +++ b/src/check/checking_interface.zig @@ -0,0 +1,541 @@ +//! 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(); + scope.imported = imported; + 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. + 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); + 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, .references = references, .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), + slots: collections.DenseMap(CIR.Node.Idx, u32), + references: collections.SafeList(u32) = .{}, + 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), + .slots = collections.DenseMap(CIR.Node.Idx, u32).init(env.gpa), + }; + result.hasher.update("roc-checking-interface-v2"); + 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.slots.deinit(); + self.references.deinit(self.env.gpa); + 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 == 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)) { + .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 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 { + 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, + ); + dependencies.hashU32(&self.hasher, @backingInt(copied)); + } + + fn scheme(self: *Publication, node_id: CIR.Node.Idx) Allocator.Error!void { + 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); + 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 { + 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); + 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)); + 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 { + 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); + 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| { + try self.node(annotation); + try self.annos.append(self.env.gpa, annotation); + } + } + + fn anno(self: *Publication, annotation: CIR.TypeAnno.Idx) Allocator.Error!void { + try 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; + try 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..) |*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; + 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); + 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); + } + 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); + 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); + } + 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 != 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"); + 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 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); + 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(); + } +}; + +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..f5844f6659c --- /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..@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, @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 + // 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/const_store.zig b/src/check/const_store.zig index b10fbc0da9d..992a3fc5f5e 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 => {}, @@ -1447,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, @@ -1761,7 +1781,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 +1800,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 +2404,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(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) })); + 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 = try type_names.internModuleIdentity(&@as([32]u8, @splat(2))), .type_name = try type_names.internTypeName("Foreign") }, + .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 new file mode 100644 index 00000000000..a5acc52cbe3 --- /dev/null +++ b/src/check/inference_output.zig @@ -0,0 +1,268 @@ +//! 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 dependencies = @import("checking_dependencies.zig"); +const base = @import("base"); +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, +}; + +/// 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 = &.{}, +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) = .{}, + 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, 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| { + 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 .{ + .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, + .identity_vars = identities.items, + }; +} + +/// 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; + } +} + +/// 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, 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.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| { + 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, + }); + } +} + +/// 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.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.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; + 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 }; + 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; + 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 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) 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(); + 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"); + 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); + // 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.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 = 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) error{ IncompatibleReferences, OutOfMemory }!void { + var borrowed = env.*; + borrowed.gpa = allocator; + var binding = try output.makeBinding(&borrowed, scope); + defer binding.deinit(allocator); +} diff --git a/src/check/mod.zig b/src/check/mod.zig index f6d769cddce..cdb0937e819 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/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 62f91f5e099..cefb3abadb1 100644 --- a/src/check/problem/types.zig +++ b/src/check/problem/types.zig @@ -22,13 +22,13 @@ pub const Category = context_mod.Category; pub const CopiedRequirement = context_mod.CopiedRequirement; pub const SharedMethodUses = context_mod.SharedMethodUses; -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/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/check/value_identity.zig b/src/check/value_identity.zig index e65525a1c26..0a9b7b9fb56 100644 --- a/src/check/value_identity.zig +++ b/src/check/value_identity.zig @@ -38,18 +38,38 @@ pub const CompileTimeRootIdentity = extern struct { site: [32]u8, }; +/// A producer-authored report site. Identity names its declaration's bytes and +/// the site's relative span; region is this checked version's reporting location. +pub const TestReportSite = extern struct { + identity: [32]u8, + start: u32, + end: u32, + + /// The site's absolute region in this checked source version. + pub fn region(self: TestReportSite) base.Region { + return base.Region.from_raw_offsets(self.start, self.end); + } +}; + /// What the identities of one module's roots are computed from. pub const Inputs = struct { env: *const ModuleEnv, 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. 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. @@ -58,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. @@ -67,9 +87,21 @@ pub const ExportIdentity = extern struct { value: [32]u8, }; +/// Stable declaration provenance, indexed by the current checked source node. +pub const DeclarationIdentity = extern struct { + statement: CIR.Statement.Idx, + value: [32]u8, +}; + /// 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, +}; + +/// 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 @@ -86,6 +118,9 @@ pub const ModuleIdentities = struct { export_requires: [][32]u8, /// Every root's identity, in root order. Owned. roots: []CompileTimeRootIdentity, + definitions: []DefinitionIdentity, + report_sites: []TestReportSite, + declarations: []DeclarationIdentity, }; /// Compute the module's value context and every root's identity. @@ -95,8 +130,11 @@ pub fn compute(allocator: Allocator, inputs: Inputs) Allocator.Error!ModuleIdent var state = try State.init(allocator, inputs); defer state.deinit(); - try state.computeDefinitionIdentities(); try state.mapExpressionOwners(); + const declarations = try state.declarationIdentities(); + errdefer allocator.free(declarations); + state.type_walk.declarations = declarations; + try state.computeDefinitionIdentities(); const context = state.moduleContext(); const type_world = try state.typeWorld(); const value_context = valueContext(context, type_world); @@ -107,17 +145,71 @@ 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) }); + } + // 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 { + 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| { identity.* = try state.rootIdentity(root, context, type_world, &ordinals); } + const report_sites = if (inputs.test_dependencies) try state.reportSites() else try allocator.alloc(TestReportSite, 0); + errdefer allocator.free(report_sites); return .{ + .report_sites = report_sites, + .declarations = declarations, .context = value_context, .exports = exports, .method_requires = method_requires, .export_requires = export_requires, .roots = identities, + .definitions = try definitions.toOwnedSlice(allocator), }; } @@ -138,6 +230,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 { @@ -227,6 +320,18 @@ 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. + 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)); + } return hasher.finalResult(); } @@ -326,6 +431,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 { @@ -340,6 +453,249 @@ const State = struct { } } + fn declarationIdentities(self: *State) Allocator.Error![]DeclarationIdentity { + var rows = std.ArrayList(DeclarationIdentity).empty; + errdefer rows.deinit(self.allocator); + if (!self.inputs.test_dependencies) return rows.toOwnedSlice(self.allocator); + 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); + } + } + } + var seen = collections.DenseMap(CIR.Statement.Idx, void).init(self.allocator); + defer seen.deinit(); + var owner_digests = std.AutoHashMapUnmanaged(Owner, [32]u8).empty; + defer owner_digests.deinit(self.allocator); + for (declarations.items) |statement| { + const entry = try seen.getOrPut(statement); + if (entry.found_existing) continue; + var hash = Sha256.init(.{}); + hashTag(&hash, "roc.test-declaration.v1"); + hashBytes(&hash, env.module_name); + const region = env.store.getStatementRegion(statement); + hashBytes(&hash, self.regionText(region)); + if (owners.get(statement)) |owner| { + const envelope = switch (owner) { + .def => |def| env.store.getExprRegion(env.store.getDef(def).expr), + .expect, .type_default => |root| env.store.getStatementRegion(root), + }; + const owner_digest = try owner_digests.getOrPut(self.allocator, owner); + if (!owner_digest.found_existing) { + var owner_hash = Sha256.init(.{}); + owner_hash.update(&self.ownerKey(owner)); + hashBytes(&owner_hash, self.regionText(envelope)); + owner_digest.value_ptr.* = owner_hash.finalResult(); + } + hash.update(owner_digest.value_ptr); + hashU32(&hash, region.start.offset - envelope.start.offset); + hashU32(&hash, region.end.offset - envelope.start.offset); + } else if (!self.type_walk.top_level.contains(statement)) { + // A declaration outside the expression ownership graph retains + // its exact version provenance; no lexical owner is invented. + hash.update(&self.inputs.own_identity); + hashU32(&hash, @backingInt(statement)); + } + try rows.append(self.allocator, .{ .statement = statement, .value = hash.finalResult() }); + } + std.mem.sort(DeclarationIdentity, rows.items, {}, struct { + fn less(_: void, a: DeclarationIdentity, b: DeclarationIdentity) bool { + return @backingInt(a.statement) < @backingInt(b.statement); + } + }.less); + return rows.toOwnedSlice(self.allocator); + } + + fn reportSites(self: *State) Allocator.Error![]TestReportSite { + var sites = std.ArrayList(TestReportSite).empty; + errdefer sites.deinit(self.allocator); + const env = self.inputs.env; + const Anchor = struct { region: base.Region, identity: [32]u8 }; + var anchors = std.AutoHashMapUnmanaged(Owner, Anchor).empty; + defer anchors.deinit(self.allocator); + var it = self.owners.keyIterator(); + while (it.next()) |expr| { + const owner = self.owners.get(expr.*).?; + const anchor = try anchors.getOrPut(self.allocator, owner); + if (!anchor.found_existing) { + const region = switch (owner) { + .def => |def| env.store.getExprRegion(env.store.getDef(def).expr), + .expect, .type_default => |statement| env.store.getStatementRegion(statement), + }; + var hash = Sha256.init(.{}); + hashTag(&hash, "roc.test-report-owner.v1"); + hash.update(&self.ownerKey(owner)); + hashBytes(&hash, self.regionText(region)); + anchor.value_ptr.* = .{ .region = region, .identity = hash.finalResult() }; + } + const owner_region = anchor.value_ptr.region; + const identity = anchor.value_ptr.identity; + try self.appendReportSite(&sites, identity, owner_region, env.store.getExprRegion(expr.*)); + if (env.store.getExpr(expr.*) == .e_block) { + for (env.store.sliceStatements(env.store.getExpr(expr.*).e_block.stmts)) |statement| { + try self.appendReportSite(&sites, identity, owner_region, env.store.getStatementRegion(statement)); + } + } + } + return sites.toOwnedSlice(self.allocator); + } + + fn appendReportSite(self: *State, sites: *std.ArrayList(TestReportSite), owner: [32]u8, envelope: base.Region, region: base.Region) Allocator.Error!void { + // Generated expressions without a source span have no report site. + if (region.start.offset < envelope.start.offset or region.end.offset > envelope.end.offset) return; + var hash = Sha256.init(.{}); + hash.update(&owner); + hashU32(&hash, region.start.offset - envelope.start.offset); + hashU32(&hash, region.end.offset - envelope.start.offset); + try sites.append(self.allocator, .{ .identity = hash.finalResult(), .start = region.start.offset, .end = region.end.offset }); + } + + 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; + 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 { + // 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 { + 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, 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, importedDefinition(self.importedByIdentity(identity.*), lookup.target_def_idx)); + } + }, + .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); + } + 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 +706,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. @@ -406,6 +768,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); @@ -503,6 +881,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))), }; } @@ -519,6 +898,105 @@ 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; + }, + .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); + } + 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, @@ -545,6 +1023,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| { @@ -553,16 +1032,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))); - }, - } 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); @@ -618,6 +1113,7 @@ const TypeDigest = struct { /// type that names it would otherwise hash all of it again. declaration_digests: collections.DenseMap(CIR.Statement.Idx, [32]u8), stack: std.ArrayList(CheckedTypeId) = .empty, + declarations: []const DeclarationIdentity = &.{}, fn init(allocator: Allocator, inputs: Inputs) TypeDigest { return .{ @@ -637,11 +1133,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; @@ -670,10 +1175,39 @@ const TypeDigest = struct { return std.meta.eql(self.inputs.names.moduleIdentityBytes(origin).*, self.inputs.own_identity); } + fn importedView(self: *const TypeDigest, identity: [32]u8, comptime by_artifact: bool) checked_artifact.ImportedModuleView { + for (self.inputs.imports) |entry| { + const key = if (by_artifact) entry.view.key.bytes else entry.view.module_identity.stable_hash; + if (std.meta.eql(key, identity)) return entry.view; + } + for (self.inputs.available) |view| { + const key = if (by_artifact) view.key.bytes else view.module_identity.stable_hash; + if (std.meta.eql(key, identity)) return view; + } + for (self.inputs.relations) |view| { + const key = if (by_artifact) view.key.bytes else view.module_identity.stable_hash; + if (std.meta.eql(key, identity)) return view; + } + base.invariant("checked test type has no declaring imported module", .{}); + } + + fn writeImportedDeclaration(_: *const TypeDigest, hasher: *Sha256, view: checked_artifact.ImportedModuleView, declaration: u32) void { + const summary = view.compile_time_root_identities; + hashTag(hasher, "imported-test-type"); + hasher.update(&summary.test_context[0]); + hasher.update(&declarationIdentity(summary.test_declarations, declaration)); + } + /// Names a declaration of the module: a top-level one by its name and /// source, any other by version-specific data. fn writeOwnDeclaration(self: *TypeDigest, hasher: *Sha256, name: []const u8, source_decl: ?u32) Allocator.Error!void { if (source_decl) |raw| { + if (self.inputs.test_dependencies) { + hashTag(hasher, "own-test-type"); + hashBytes(hasher, name); + hasher.update(&declarationIdentity(self.declarations, raw)); + return; + } const statement: CIR.Statement.Idx = @fromBackingInt(raw); if (self.top_level.contains(statement)) { hashTag(hasher, "own-top-level"); @@ -726,6 +1260,9 @@ const TypeDigest = struct { const name = names.typeNameText(alias.name); if (self.isOwn(alias.origin_module)) { try self.writeOwnDeclaration(hasher, name, alias.source_decl); + } else if (self.inputs.test_dependencies and alias.source_decl != null) { + hashBytes(hasher, name); + self.writeImportedDeclaration(hasher, self.importedView(names.moduleIdentityBytes(alias.origin_module).*, false), alias.source_decl.?); } else { hasher.update(names.moduleIdentityBytes(alias.origin_module)); hashBytes(hasher, name); @@ -740,6 +1277,9 @@ const TypeDigest = struct { const name = names.typeNameText(nominal.name); if (self.isOwn(nominal.origin_module)) { try self.writeOwnDeclaration(hasher, name, nominal.source_decl); + } else if (self.inputs.test_dependencies) { + hashBytes(hasher, name); + self.writeImportedDeclaration(hasher, self.importedView(names.moduleIdentityBytes(nominal.origin_module).*, false), nominal.source_decl); } else { hasher.update(names.moduleIdentityBytes(nominal.origin_module)); hashBytes(hasher, name); @@ -751,8 +1291,14 @@ const TypeDigest = struct { switch (nominal.representation) { .builtin => |builtin_nominal| hashBytes(hasher, @tagName(builtin_nominal)), .imported_declaration => |ref| { - hasher.update(&ref.artifact.bytes); - hashU32(hasher, @backingInt(ref.declaration)); + if (self.inputs.test_dependencies) { + const owner = self.importedView(ref.artifact.bytes, true); + const declaration = owner.checked_types.nominal_declarations[@backingInt(ref.declaration)]; + self.writeImportedDeclaration(hasher, owner, declaration.source_statement); + } else { + hasher.update(&ref.artifact.bytes); + hashU32(hasher, @backingInt(ref.declaration)); + } }, .imported_box_payload_capability => |ref| { hasher.update(&ref.artifact.bytes); @@ -824,6 +1370,25 @@ 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; + while (lo < hi) { + const mid = lo + (hi - lo) / 2; + const node = @backingInt(rows[mid].statement); + if (node < raw) lo = mid + 1 else if (node > raw) hi = mid else return rows[mid].value; + } + base.invariant("checked test type has no producer-authored declaration identity", .{}); +} + fn expectBody(env: *const ModuleEnv, statement: CIR.Statement.Idx) CIR.Expr.Idx { const data = env.store.getStatement(statement); if (data != .s_expect) base.invariant("compile-time statement root was not an expect", .{}); 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/completed_test.zig b/src/cli/completed_test.zig new file mode 100644 index 00000000000..1c15b8ba808 --- /dev/null +++ b/src/cli/completed_test.zig @@ -0,0 +1,275 @@ +//! 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; + +/// Prefix of every command record; changing the record format changes it. +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. +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 }; +/// Producer-authored location-independent reporting identity and current region. +pub const ReportSite = @import("check").CheckedArtifact.TestReportSite; +/// A source owner and its reporting map under the exact command input manifest. +pub const Source = struct { name: Digest, qualified_name: []const u8, path: []const u8, sites: []const ReportSite }; +/// A current test occurrence and the durable semantic outcome it instantiates. +pub const Root = struct { order: u32, region: base.Region, key: Digest, payload_hash: Digest }; +/// A tested module and its current root occurrences. +pub const Module = struct { + source: u32, + 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, + file: ?[]const u8, + 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 }; + } + + /// 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, + .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); + } +}; +/// 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, + modules: []const Module, + debug_messages: []const []const u8, + diagnostics: []Diagnostic, + 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); +} + +/// 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" => |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 + }, + .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)), + } +} + +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 + }, + .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]; + 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/link_artifacts.zig b/src/cli/link_artifacts.zig index 40f22c67503..3f2021c3321 100644 --- a/src/cli/link_artifacts.zig +++ b/src/cli/link_artifacts.zig @@ -3,6 +3,7 @@ //! Evicting a cache name cannot remove an object from an in-progress link. const std = @import("std"); +const artifacts = @import("compile").artifacts; const backend = @import("backend"); const CoreCtx = @import("ctx").CoreCtx; const Allocator = std.mem.Allocator; @@ -28,10 +29,25 @@ pub const Counters = struct { /// Owns the scratch lifetime pins for one native build's immutable objects. pub const Objects = struct { + pub const Cache = struct { + store: artifacts.Store, + identity: artifacts.Identity, + + fn key(self: Cache, id: Identity) artifacts.Identity { + var result = self.identity; + result.key = id.digest; + return result; + } + + fn forTest(allocator: Allocator, io: std.Io, path: []const u8) Allocator.Error!Cache { + return .{ .store = try artifacts.Store.init(allocator, io, path), .identity = .{ .namespace = @splat(0), .family = .program_output, .key = @splat(0) } }; + } + }; core: CoreCtx, arena: Allocator, /// Same compiler/format/target/optimization namespace as program records. - root: ?[]const u8, + root: ?Cache, + scratch: []const u8, lease: std.Io.File, counters: Counters = .{}, @@ -41,7 +57,7 @@ pub const Objects = struct { /// Why writing an object into the scratch directory can fail. pub const MaterializeError = Allocator.Error || CoreCtx.DeleteError || std.Io.Dir.WriteFileError; - pub fn init(core: CoreCtx, arena: Allocator, root: ?[]const u8, scratch: []const u8) InitError!Objects { + pub fn init(core: CoreCtx, arena: Allocator, root: ?Cache, scratch: []const u8) InitError!Objects { const lock_path = try std.fs.path.join(arena, &.{ scratch, "link.lock" }); const lease = try std.Io.Dir.cwd().createFile(core.std_io, lock_path, .{ .read = true, .exclusive = true, .lock = .exclusive }); return .{ .core = core, .arena = arena, .root = root, .scratch = scratch, .lease = lease }; @@ -56,20 +72,30 @@ pub const Objects = struct { } fn cachePath(self: *Objects, id: Identity) Allocator.Error!?[]const u8 { - const root = self.root orelse return null; - return try std.fs.path.join(self.arena, &.{ root, "programs", try self.name(id) }); + const cache = self.root orelse return null; + var artifact = (cache.store.acquire(cache.key(id), cache.store.now()) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }) orelse return null; + defer artifact.deinit(); + return try self.arena.dupe(u8, artifact.path); } - /// Pin before validating. The sweep may unlink the cache name at any time. - /// No writer ever opens a published object (or its pin) for writing. + /// Acquire a catalog lease before creating the scratch hard link. The + /// registered scratch lease then pins the inode for the complete link. pub fn find(self: *Objects, id: Identity) Allocator.Error!?[]const u8 { return self.findKnown(id, null); } fn findKnown(self: *Objects, id: Identity, expected: ?[]const u8) Allocator.Error!?[]const u8 { - const cache_path = try self.cachePath(id) orelse return null; + const cache = self.root orelse return null; + var artifact = (cache.store.acquire(cache.key(id), cache.store.now()) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }) orelse return null; + defer artifact.deinit(); const path = try std.fs.path.join(self.arena, &.{ self.scratch, try self.name(id) }); - self.core.hardLink(cache_path, path) catch |err| switch (err) { + self.core.hardLink(artifact.path, path) catch |err| switch (err) { error.PathAlreadyExists => {}, else => return null, }; @@ -127,14 +153,14 @@ pub const Objects = struct { }; try backend.writeFileWindowsAvSafe(self.core.std_io, path, bytes); self.counters.written += bytes.len; - if (try self.cachePath(id)) |cache_path| { - self.core.makePath(std.fs.path.dirname(cache_path).?) catch return path; - // Each invocation's scratch directory gives its staging name a - // unique identity, even when many writers publish the same object. - const staging = try std.fmt.allocPrint(self.arena, "{s}.{s}.tmp", .{ cache_path, std.fs.path.basename(self.scratch) }); - self.core.hardLink(path, staging) catch return path; - defer self.core.deleteFile(staging) catch {}; - self.core.rename(staging, cache_path) catch return path; + if (self.root) |cache| { + var staging = cache.store.reserve(cache.key(id), bytes.len, cache.store.now()) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return path, + }; + defer staging.deinit(); + self.core.hardLink(path, staging.path) catch return path; + staging.publish() catch return path; } return path; } @@ -152,9 +178,9 @@ test "immutable objects pin bytes through eviction and repair corruption without try tmp.dir.createDir(io, "third", .default_dir); const root = try tmp.dir.realPathFileAlloc(io, ".", a); const core = CoreCtx.os(std.testing.allocator, a, io); - var first = try Objects.init(core, a, root, try std.fs.path.join(a, &.{ root, "first" })); + var first = try Objects.init(core, a, try Objects.Cache.forTest(a, io, root), try std.fs.path.join(a, &.{ root, "first" })); defer first.deinit(); - var second = try Objects.init(core, a, root, try std.fs.path.join(a, &.{ root, "second" })); + var second = try Objects.init(core, a, try Objects.Cache.forTest(a, io, root), try std.fs.path.join(a, &.{ root, "second" })); defer second.deinit(); const data = "immutable object bytes"; const id = Identity.of(data); @@ -167,16 +193,16 @@ test "immutable objects pin bytes through eviction and repair corruption without try core.deleteFile(cache_path); try std.testing.expectEqualStrings(data, try core.readFile(pinned, a)); try std.testing.expectEqualStrings(data, try core.readFile(published, a)); - var third = try Objects.init(core, a, root, try std.fs.path.join(a, &.{ root, "third" })); + var third = try Objects.init(core, a, try Objects.Cache.forTest(a, io, root), try std.fs.path.join(a, &.{ root, "third" })); defer third.deinit(); try std.testing.expect(try third.find(id) == null); // An interrupted or corrupt publication is a miss, even at the right name // and size; repairing it must not mutate another reader's inode. - try core.writeFile(cache_path, "xxxxxxxxxxxxxxxxxxxxxx"); + try first.root.?.store.write(first.root.?.key(id), "xxxxxxxxxxxxxxxxxxxxxx", first.root.?.store.now()); try std.testing.expect(try third.find(id) == null); _ = try third.materialize(data); try std.testing.expectEqualStrings(data, try core.readFile(pinned, a)); - try std.testing.expectEqualStrings(data, try core.readFile(cache_path, a)); + try std.testing.expectEqualStrings(data, try core.readFile((try third.cachePath(id)).?, a)); // A second publisher takes the existing content without writing it. _ = try second.materialize(data); try std.testing.expectEqual(@as(u64, 0), second.counters.written); @@ -205,7 +231,7 @@ test "concurrent object publishers leave complete immutable objects" { var storage = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer storage.deinit(); const allocator = storage.allocator(); - var objects = try Objects.init(CoreCtx.os(allocator, allocator, thread_io), allocator, cache_root, scratch); + var objects = try Objects.init(CoreCtx.os(allocator, allocator, thread_io), allocator, try Objects.Cache.forTest(allocator, thread_io, cache_root), scratch); defer objects.deinit(); _ = try objects.materialize("concurrent content"); } @@ -219,8 +245,8 @@ test "concurrent object publishers leave complete immutable objects" { first.join(); second.join(); for (failures) |failure| if (failure) |err| return err; - const name = try std.fmt.allocPrint(a, "programs/{s}.o", .{std.fmt.bytesToHex(Identity.of("concurrent content").digest, .lower)}); - const content = try tmp.dir.readFileAlloc(io, name, a, .unlimited); + const cache = try Objects.Cache.forTest(a, io, root); + const content = (try cache.store.read(a, cache.key(Identity.of("concurrent content")), cache.store.now())).?; try std.testing.expectEqualStrings("concurrent content", content); } @@ -234,7 +260,7 @@ test "a refused unlink never permits truncating a published inode" { try tmp.dir.createDir(io, "scratch", .default_dir); const root = try tmp.dir.realPathFileAlloc(io, ".", a); const core = CoreCtx.os(std.testing.allocator, a, io); - var objects = try Objects.init(core, a, root, try std.fs.path.join(a, &.{ root, "scratch" })); + var objects = try Objects.init(core, a, try Objects.Cache.forTest(a, io, root), try std.fs.path.join(a, &.{ root, "scratch" })); defer objects.deinit(); const content = "retained by another linker"; const pinned = try objects.materialize(content); diff --git a/src/cli/linker.zig b/src/cli/linker.zig index 5b84699d1f3..9aa540174ed 100644 --- a/src/cli/linker.zig +++ b/src/cli/linker.zig @@ -1051,6 +1051,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 e42a0b9acd5..0fc51c91f61 100644 --- a/src/cli/main.zig +++ b/src/cli/main.zig @@ -181,6 +181,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(link_artifacts); std.testing.refAllDecls(@import("builder.zig")); std.testing.refAllDecls(@import("host_symbols.zig")); @@ -314,6 +315,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)); @@ -323,6 +325,7 @@ const CompileTimeObjectCache = struct { .linked_stores = self.linked_stores, .io = self.io, .build_env = build_env, + .explicit_artifacts = self.explicit_artifacts, }; } @@ -337,7 +340,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. @@ -356,11 +359,14 @@ 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; - const placement = build_env.packPlacementForArtifactKey(root_artifact.key) 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.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 }; } @@ -1041,107 +1047,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. @@ -1299,43 +1228,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 { @@ -1356,9 +1262,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) { @@ -1721,28 +1627,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. @@ -2868,10 +2752,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); @@ -3046,27 +2926,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 @@ -3257,18 +3118,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) { @@ -3321,7 +3171,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); @@ -3349,7 +3199,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); @@ -3730,16 +3580,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 }); @@ -3808,15 +3655,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 }); @@ -3871,12 +3711,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 @@ -4075,7 +3910,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}); } @@ -4178,7 +4013,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}); } @@ -5145,7 +4980,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}); } @@ -6286,7 +6121,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; @@ -6885,7 +6720,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); } @@ -6939,7 +6774,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); @@ -7544,15 +7378,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, @@ -7560,15 +7388,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 }; + base.Sha256.hash(build_options.compiler_compatibility_id, &id.namespace, .{}); + base.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. @@ -8396,7 +8250,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 }); @@ -8599,7 +8453,14 @@ 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 (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); if (roots.len == 0) continue; @@ -8608,42 +8469,24 @@ 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. + const set = &pack.compiled.excerpts.?; + 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; - 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}); - }; } } -/// 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, + app_excerpts: ?*const backend.dev.CodeExcerpt.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, @@ -8663,49 +8506,11 @@ 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); - 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(build_env, store, placement.origin, placement.identity, root_artifact.codeGenerationKey().bytes, set, app_lowered, loaded_packs); } } - // 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.has(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); - 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}); - }; - } } /// The optimization level whose object packs a dev build for `target` ranks @@ -8772,7 +8577,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, @@ -9083,7 +8888,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, @@ -9091,6 +8896,8 @@ 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, + .iterator_fusion_scope = proc.iterator_fusion_scope, .rc_ret_conditions = try GuardedList.dupe(arena, u32, store.getU32Span(proc.rc_ret_conditions)), }); } @@ -9100,7 +8907,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); @@ -9135,7 +8942,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| { @@ -9293,14 +9100,11 @@ 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); - 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}); - }; + if (dev_diagnostics.isSet(.pack_trace)) std.debug.print("compile-time pack {d} artifacts\n", .{combined.excerpts.len}); + try parts.offers.publish(build_env, store, destination.placement.origin, destination.placement.identity, destination.key, &combined, &cache.packs); + cache.packs.refreshIndex(); } /// File the discovery summaries of a compilation that reported no error beside @@ -9327,17 +9131,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); -} - -/// 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; + if (build_env.compile_time_object_cache_owner) |owner| { + const cache: *CompileTimeObjectCache = @ptrCast(@alignCast(owner.context)); + try cache.packs.publishInterfaces(); } - return false; } /// Encode a pack program's artifacts with its spec table: every keyed @@ -9345,7 +9142,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 { @@ -9357,6 +9154,14 @@ 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.CodeExcerpt.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, loaded_packs); +} + /// 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 @@ -9370,17 +9175,19 @@ 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, + 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. - keys: std.AutoHashMap([32]u8, void), + keys: std.AutoHashMap([32]u8, ?[32]u8), const Candidate = struct { key: [32]u8, @@ -9388,6 +9195,9 @@ const PackOffers = struct { reads: lir.CheckedPipeline.SpecComptimeReads.Lookup, /// A rule read from the program's code withholds it. 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, @@ -9405,14 +9215,14 @@ const PackOffers = struct { return .{ .arena = base.SingleThreadArena.init(allocator), .evaluation = evaluation, - .literal_converters = std.AutoHashMap(lir.ProcIdentity, void).init(allocator), - .keys = std.AutoHashMap([32]u8, 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(); } @@ -9422,20 +9232,25 @@ 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 converting = try lir.PackProgram.literalConvertingProcs(allocator, store); - defer allocator.free(converting); - for (store.getProcSpecs(), converting) |proc, converts| { - if (converts) try self.literal_converters.put(proc.identity, {}); + 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, 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| { - if ((try self.keys.getOrPut(spec_proc.key)).found_existing) continue; - try self.keyed.append(self.arena.allocator(), try self.candidate(lowered, spec_proc.proc, spec_proc.key, comptime_reads.get(spec_proc.key))); + 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, &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)); @@ -9443,16 +9258,22 @@ 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); - if ((try self.keys.getOrPut(key)).found_existing) continue; - try self.by_identity.append(self.arena.allocator(), try self.candidate(lowered, id, key, comptime_reads.fnReads(source_fn))); + 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, &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, @@ -9471,16 +9292,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 withheld = !reads.offerable or + 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 - try procReachesLinkedEntry(self.literal_converters.allocator, store, id) 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 .{ @@ -9488,12 +9311,15 @@ 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, .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, + .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, @@ -9502,18 +9328,22 @@ 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.CodeExcerpt.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); - defer converting_artifacts.deinit(allocator); + 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(); + const rejected = try allocator.alloc(bool, set.excerpts.len); + defer allocator.free(rejected); + 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| { - const artifact = closure.offeredProcArtifact(offer.identity) orelse continue; + const excerpt = closure.offeredProcExcerpt(offer.identity) orelse continue; if (offer.withheld) { withheld += 1; continue; @@ -9522,8 +9352,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)) { + 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; } @@ -9532,7 +9362,8 @@ const PackOffers = struct { }; try specs.append(allocator, .{ .key = offer.key, - .artifact = artifact, + .excerpt = excerpt, + .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, @@ -9540,14 +9371,50 @@ 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, + .iterator_fusion_scope = offer.iterator_fusion_scope, .rc_ret_conditions = offer.rc_ret_conditions, }); } } 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 }); + } + if (self.offered_count) |out| out.* = specs.items.len; + return specs.toOwnedSlice(allocator); + } + + fn encode(self: *const PackOffers, allocator: Allocator, set: *const backend.dev.CodeExcerpt.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, build_env: *const BuildEnv, store: *const pack_store.Store, origin: BuildEnv.PackOrigin, identity: [32]u8, key: [32]u8, set: *const backend.dev.CodeExcerpt.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); + 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", .{}); + } + } } - return backend.dev.PackFile.write(allocator, set, specs.items); + store.writeIndexedRetained(origin, identity, set, specs, providers, loaded_packs) 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 => {}, + }; } }; @@ -9559,104 +9426,23 @@ 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), - 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`). -fn literalConvertingArtifacts( +/// 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.ProcArtifact.Set, - literal_converters: *const std.AutoHashMap(lir.ProcIdentity, void), + set: *const backend.dev.CodeExcerpt.Set, + rejected_procedures: *const std.AutoHashMap(lir.ProcIdentity, void), ) Allocator.Error!std.DynamicBitSetUnmanaged { - var converting = try std.DynamicBitSetUnmanaged.initEmpty(allocator, set.artifacts.len); - if (literal_converters.count() == 0) return converting; - for (set.artifacts, 0..) |artifact, index| switch (artifact.kind) { - .proc => |identity| if (literal_converters.contains(identity)) converting.set(index), + var converting = try std.DynamicBitSetUnmanaged.initEmpty(allocator, set.excerpts.len); + if (rejected_procedures.count() == 0) return converting; + for (set.excerpts, 0..) |excerpt, index| switch (excerpt.kind) { + .proc => |identity| if (rejected_procedures.contains(identity)) converting.set(index), .rc_helper, .boxy_thunk, .entrypoint, .message_pool_run, .branch_island => {}, }; return converting; @@ -9664,50 +9450,55 @@ 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) error{ OutOfMemory, TestExpectedEqual }!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.excerpts.len); + defer std.testing.allocator.free(marks); + for (marks, 0..) |*mark, i| mark.* = closureNamesProgramLocalSymbols(set, &.{@intCast(i)}); + try backend.dev.CodeExcerpt.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" { - 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", @@ -9735,15 +9526,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", @@ -9756,20 +9547,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 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(); - 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", @@ -9798,7 +9589,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)); } @@ -10112,7 +9903,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( @@ -10143,7 +9934,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; @@ -10761,7 +10552,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 @@ -10822,7 +10613,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 @@ -11524,7 +11315,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) 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, .{ @@ -11623,6 +11413,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); @@ -11710,7 +11503,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(); @@ -11874,18 +11671,9 @@ 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(); + 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); @@ -12018,37 +11806,63 @@ 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); - var link_objects = link_artifacts.Objects.init(ctx.coreCtx(), ctx.arena, if (object_store) |store| store.root else null, build_scratch_dir) catch return error.NativeCompilationFailed; + defer releaseScratchDir(ctx, build_scratch_dir); + var link_objects = link_artifacts.Objects.init(ctx.coreCtx(), ctx.arena, if (object_store) |store| .{ .store = store.native, .identity = store.linkObjectIdentity(@splat(0)) } else null, build_scratch_dir) catch return error.NativeCompilationFailed; defer link_objects.deinit(); 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, .build_env = &build_env, .store = store, + .manager = build_env.cache_manager.?, .policy = programOutputPolicy(args, link_type), .linked_stores = if (linked_stores) |*stores| stores else null, .objects = &link_objects, }; }; - if (program_probe) |*probe| build_env.setRuntimeOutputProbe(probe.runtimeOutputProbe()); + if (program_probe) |*probe| { + try probe.initialize(args); + 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.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 (program_probe) |probe| if (probe.lookup_ns != 0) { reporter.recordCompletedWithBreakdown("Object reuse (during checking)", probe.lookup_ns, .{}, &.{}); }; - const root_artifact = try checkedArtifactForBuild(ctx, &build_env, args.path); if (build_env.runtimeOutputCached()) { const output = program_probe.?.found.?; const obj_path = program_probe.?.object_path.?; reporter.begin("Preparing Link Inputs"); - try linkNativeBuild(ctx, &reporter, args, target, link_type, final_output_path, platform_dir, resolved_targets_config, build_scratch_dir, &link_objects, obj_path, &.{}, output.facts); + 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, &link_objects, obj_path, program_probe.?.additional_object_paths, 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); @@ -12057,28 +11871,19 @@ 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, currentRuntimeSpecializationStrategy(args.specialization_strategy))); defer lowered.deinit(); // 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} cache bytes loaded: {d}\n", .{ packs.hits, external_procs, linked_procs, packs.artifacts_served, packs.packs.items.len, packs.specs.count(), packs.bytes_loaded }); - } + 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); @@ -12119,9 +11924,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(); var written = object_compiler.compileToObjectFile( &lowered.lir_result.store, @@ -12138,10 +11943,11 @@ 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.deinit(); - if (object_compiler.capture_artifacts) { - object_compiler.captured_artifacts = written.artifacts; - written.artifacts = null; + if (object_compiler.capture_excerpts) { + object_compiler.captured_excerpts = written.excerpts; + written.excerpts = null; } const uses_boxy = written.uses_boxy; reporter.endWithBreakdown(&devBackendBreakdown(backend_timing.snapshot())); @@ -12152,7 +11958,10 @@ 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"); + if (loaded_packs) |packs| try packs.publishInterfaces(); + try writePacksToStore(ctx, &build_env, store, root_artifact, &lowered, if (object_compiler.captured_excerpts) |*set| set else null, loaded_packs); + reporter.end(); } reporter.begin("Object Materialization"); @@ -12167,20 +11976,23 @@ 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, object_identity, link_facts); - }; - + const additional_object_ids = try ctx.arena.alloc(link_artifacts.Identity, linked_objects.len); + var extra_objects = std.array_list.Managed([]const u8).init(ctx.arena); + for (linked_objects, additional_object_ids) |object, *identity| { + identity.* = link_artifacts.Identity.of(object); + try extra_objects.append(link_objects.materializeKnown(object, identity.*) catch return error.NativeCompilationFailed); + } reporter.end(); + // 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) { + reporter.begin("Publishing Output Cache"); + try probe.record(key, object_identity, additional_object_ids, link_facts, pack_links, resolved_targets_config); + reporter.end(); + }; + 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) |object| { - try extra_objects.append(link_objects.materialize(object) catch return error.NativeCompilationFailed); - } // Boxy programs reference the `roc_boxy_*` runtime and, in their // entrypoints, call `roc_boxy_init_embedded`. Link the boxy runtime object // and a data object carrying the sidecar those symbols read. @@ -12330,96 +12142,111 @@ fn validateLinkInputStates(ctx: *CliCtx, paths: []const []const u8, states: []co } } -/// 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 { + manifest: Manifest = .{}, + diagnostics: []const compile.completed_compilation.Diagnostic = &.{}, object: link_artifacts.Identity, + additional_objects: []const link_artifacts.Identity = &.{}, facts: LinkFacts, + pack_links: PackLinks = .{}, + 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 = "ROCPRGO2"; + /// 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, + }; + const Manifest = struct { + request: [32]u8 = @splat(0), + inputs: ?compile.completed_compilation.Inputs = null, + optimizations: []const OptimizationInput = &.{}, + }; + const Payload = struct { + object: link_artifacts.Identity, + additional_objects: []const link_artifacts.Identity, + facts: LinkFacts, + pack_links: PackLinks, + outcome: compile.completed_compilation.Outcome, + diagnostics: []const compile.completed_compilation.Diagnostic, + }; + const magic = "ROCCOMP3"; + 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 bytes = std.ArrayList(u8).empty; - errdefer bytes.deinit(allocator); - try bytes.appendSlice(allocator, magic); - try bytes.appendSlice(allocator, &output.object.digest); - try appendWord(allocator, &bytes, output.object.size); - 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); - } - var checksum: [32]u8 = undefined; - std.crypto.hash.Blake3.hash(bytes.items, &checksum, .{}); - try bytes.appendSlice(allocator, &checksum); - return bytes.toOwnedSlice(allocator); - } - - fn appendWord(allocator: Allocator, bytes: *std.ArrayList(u8), value: u64) 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(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, + .pack_links = output.pack_links, + .outcome = output.outcome, + .diagnostics = output.diagnostics, + }) catch return error.OutOfMemory; + const bytes = try out.toOwnedSlice(); + 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 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, &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, + }; + return if (reader.offset == bytes.len) result else null; } - 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); + 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]); } - /// 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 { - if (bytes.len < magic.len + 32) return null; - const payload = bytes[0 .. bytes.len - 32]; - var checksum: [32]u8 = undefined; - std.crypto.hash.Blake3.hash(payload, &checksum, .{}); - if (!std.mem.eql(u8, &checksum, bytes[bytes.len - 32 ..])) return null; - var reader = Reader{ .bytes = payload }; - const head = reader.take(magic.len) orelse return null; - if (!std.mem.eql(u8, head, magic)) return null; - const digest = reader.take(32) orelse return null; - const size_bytes = reader.take(8) orelse return null; - const object: link_artifacts.Identity = .{ .digest = digest[0..32].*, .size = std.mem.readInt(u64, size_bytes[0..8], .little) }; - 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 != payload.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); - } - }; + 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, + .pack_links = payload.pack_links, + .outcome = payload.outcome, + .diagnostics = payload.diagnostics, + }; + } }; test "program output records round-trip and reject foreign bytes" { @@ -12432,6 +12259,9 @@ test "program output records round-trip and reject foreign bytes" { }; const bytes = try ProgramOutput.encode(std.testing.allocator, .{ .object = link_artifacts.Identity.of("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.expectEqualDeep(link_artifacts.Identity.of("object bytes"), decoded.object); try std.testing.expectEqual(@as(usize, 2), decoded.facts.hosted_symbols.len); @@ -12462,7 +12292,7 @@ test "native link objects remain isolated by final target and CPU floor" { defer store.deinit(); try tmp.dir.createDir(io, @tagName(target), .default_dir); const scratch = try std.fs.path.join(scratch_allocator, &.{ root, @tagName(target) }); - var objects = try link_artifacts.Objects.init(core, scratch_allocator, store.root, scratch); + var objects = try link_artifacts.Objects.init(core, scratch_allocator, .{ .store = store.native, .identity = store.linkObjectIdentity(@splat(0)) }, scratch); defer objects.deinit(); try std.testing.expect(try objects.find(id) == null); _ = try objects.materializeKnown(bytes, id); @@ -12470,11 +12300,49 @@ test "native link objects remain isolated by final target and CPU floor" { } } -/// 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 = link_artifacts.Identity.of("final normalized object"), + .additional_objects = &.{link_artifacts.Identity.of("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.expectEqualDeep(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 identities than it contains. + bytes[ProgramOutput.header_len + 9] ^= 1; + const payload_start = ProgramOutput.manifestEnd(bytes).?; + @memset(bytes[payload_start + 32 + 8 ..][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); +} + +/// 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, @@ -12482,59 +12350,283 @@ 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, objects: *link_artifacts.Objects, object_path: ?[]const u8 = null, lookup_ns: u64 = 0, + additional_object_paths: []const []const u8 = &.{}, + /// 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 }, + bytes_read: usize = 0, + bytes_written: usize = 0, + 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-v3"), + .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 { + defer if (!self.early_hit) { + self.found = null; + }; + 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, + }; + if (!try self.resolveObjects(output)) 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 }; + } + + /// Every object is pinned and validated before the completed result is admitted. + fn resolveObjects(self: *ProgramOutputProbe, output: *const ProgramOutput) Allocator.Error!bool { + const started = std.Io.Timestamp.now(self.objects.core.std_io, .awake).nanoseconds; + defer self.lookup_ns += @intCast(std.Io.Timestamp.now(self.objects.core.std_io, .awake).nanoseconds - started); + const object_path = try self.objects.find(output.object) orelse return false; + const paths = try self.arena.alloc([]const u8, output.additional_objects.len); + for (output.additional_objects, paths) |identity, *path| { + path.* = try self.objects.find(identity) orelse return false; + } + self.object_path = object_path; + self.additional_object_paths = paths; + return true; + } 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); + var admitted = false; + defer { + if (!admitted) self.found = null; + for (self.code_providers) |provider| { + if (provider) |packs| packs.defer_code_lookup = admitted; + } + } + 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.v3"); + hasher.update("roc.completed-build.v3"); + hasher.update(&self.request); 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| { + 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 false; - const output = try ProgramOutput.decode(self.arena, bytes) orelse return false; - const started = std.Io.Timestamp.now(self.objects.core.std_io, .awake); - defer self.lookup_ns += @intCast(std.Io.Timestamp.now(self.objects.core.std_io, .awake).nanoseconds - started.nanoseconds); - self.object_path = try self.objects.find(output.object) orelse return false; - self.found = output; - return true; - } - - fn record(self: *ProgramOutputProbe, key: [32]u8, object: link_artifacts.Identity, facts: ProgramOutput.LinkFacts) CliMainError!void { - const bytes = try ProgramOutput.encode(self.gpa, .{ .object = object, .facts = facts }); + 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); + 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; + } + if (!try self.resolveObjects(found)) return null; + admitted = true; + self.manager.storePlacementPointer(self.request, key); + return &found.outcome; + } + + fn record(self: *ProgramOutputProbe, key: [32]u8, object: link_artifacts.Identity, objects: []const link_artifacts.Identity, 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); + var outcome = self.build_env.coordinator.?.completion_outcome; + outcome.targets = targets; + 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, + .pack_links = pack_links, + .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; + self.manager.storePlacementPointer(self.request, key); } }; +/// 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 excerpts: {d} packs loaded: {d} keys: {d} cache bytes loaded: {d}\n", .{ packs.hits, links.external_procs, links.linked_procs, packs.excerpts_served, packs.packs.items.len, packs.specs.count(), packs.bytes_loaded +| packs.interface_bytes_loaded }); +} + +/// 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{}; + 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 }, + .{ .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 + if (build_env.coordinator) |coord| coord.completion_capture_ns else @as(u64, 0) }, + }); +} + /// 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 { @@ -12552,6 +12644,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 { @@ -12571,7 +12673,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(); @@ -12672,7 +12773,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( @@ -12786,28 +12887,39 @@ 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, inline_suppressed: bool = false, source_env: ?*const ModuleEnv = null, + source_common: ?*const base.CommonEnv = null, + source_name: []const u8 = "", source_path: ?[]const u8 = null, 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, }; 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, + reports: CliTestReportMap = .{}, + name: []const u8 = "", +}; +const CliTestSourceModuleMap = std.AutoHashMapUnmanaged([32]u8, CliTestSourceModule); const CliTestRunSummary = struct { passed: u32 = 0, @@ -12937,15 +13049,14 @@ const CliTestPlanModule = struct { test_roots: []check.CheckedArtifact.RootRequest, /// Complete reporting-plan slot for each source root, independent of the subset. result_indices: []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; } }; @@ -12962,9 +13073,8 @@ const CliTestPlan = struct { for (self.modules) |*module| { allocator.free(module.test_roots); allocator.free(module.result_indices); - 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); @@ -12985,10 +13095,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.test_roots.len; - } + const hits = module.test_roots.len - module.uncachedCount(); + stats.roots_cached += hits; + if (hits == module.test_roots.len) stats.modules_cached += 1; } return stats; } @@ -13026,11 +13135,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); @@ -13073,7 +13177,305 @@ 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); +} + +/// 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" }); +} + +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) CliCompletedTestError!?CliCompletedTest { + const manager = build.cache_manager orelse return null; + const directory = try cliCompletedTestDir(arena, manager); + const plan_bytes = manager.loadFamilyBytes(.test_result, 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.loadFamilyBytes(.test_result, 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 }), .name = source.qualified_name, .reports = (try CliTestReportMap.init(arena, source.sites, normalized.len)) orelse return null }; + 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 source = bound_sources[module.source]; + var merged = std.ArrayList(CliTestResultItem).empty; + var transferred = false; + defer if (!transferred) { + deinitCliTestResultItemPayloads(ctx.gpa, merged.items); + merged.deinit(ctx.gpa); + }; + for (module.roots) |root| { + if (root.region.start.offset > root.region.end.offset or root.region.end.offset > source.common.source.len) return null; + const data = manager.loadFamilyBytes(.test_result, root.key, test_dir) orelse return null; + defer ctx.gpa.free(data); + if (!std.mem.eql(u8, &completed_test.hash(data), &root.payload_hash)) return null; + const cached = (try decodeCliTestOutcome(ctx.gpa, data, &source_modules, .{ + .key = root.key, + .order = root.order, + .region = root.region, + .source = source, + })) orelse return null; + defer ctx.gpa.free(cached); + errdefer deinitCliTestResultItemPayloads(ctx.gpa, cached); + try merged.appendSlice(ctx.gpa, cached); + } + std.mem.sort(CliTestResultItem, merged.items, {}, struct { + fn less(_: void, a: CliTestResultItem, b: CliTestResultItem) bool { + if (a.inline_expect != b.inline_expect) return !a.inline_expect; + if (!a.inline_expect) return a.order < b.order; + return cliInlineResultLessThan({}, a, b); + } + }.less); + const owned = try merged.toOwnedSlice(ctx.gpa); + errdefer deinitCliTestResultItems(ctx.gpa, owned); + transferred = true; + try results.append(arena, .{ .env = source.common, .path = source.path, .results = owned, .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, +) 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; + }; + 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()), + .qualified_name = module.semantic.env.qualifiedModuleName(), + .sites = if (module.semantic.checked_artifact) |artifact| artifact.compile_time_root_identities.test_report_sites else &.{}, + .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 roots = try arena.alloc(completed_test.Root, planned.test_roots.len); + for (roots, planned.test_roots) |*shape, root| { + const result_key = cliTestOutcomeKey(planned.artifact, root, currentRuntimeSpecializationStrategy(args.specialization_strategy), args.opt); + // Reference only durable, independently validated outcomes. + const data = manager.loadFamilyBytes(.test_result, result_key, test_dir) orelse return; + defer ctx.gpa.free(data); + shape.* = .{ .order = root.order, .region = testRootRegion(planned.module.semantic.env, root.source), .key = result_key, .payload_hash = completed_test.hash(data) }; + } + 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, .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.storeFamilyBytes(.test_result, key, bytes, directory, "completed test command"); + // Publish the input plan only once its complete outcome is readable. + const durable = manager.loadFamilyBytes(.test_result, key, directory) orelse return; + defer ctx.gpa.free(durable); + if (!std.mem.eql(u8, bytes, durable)) return; + manager.storeFamilyBytes(.test_result, seed, plan_bytes, directory, "test command inputs"); +} + +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; @@ -13163,58 +13565,116 @@ 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); + hasher.update(@tagName(RocTarget.detectNative())); + return cliTestCacheKey(.{ .bytes = hasher.finalResult() }, strategy, opt); +} + +/// The current checked pass owns both directions. Duplicate identities at +/// different regions are ambiguous; they never select an arbitrary occurrence. +const CliTestReportMap = struct { + by_region: std.AutoHashMapUnmanaged(base.Region, ?[32]u8) = .empty, + by_identity: std.AutoHashMapUnmanaged([32]u8, ?base.Region) = .empty, + + fn init(allocator: Allocator, sites: []const completed_test.ReportSite, source_len: usize) Allocator.Error!?CliTestReportMap { + var map = CliTestReportMap{}; + errdefer map.deinit(allocator); + for (sites) |site| { + if (site.region().start.offset > site.region().end.offset or site.region().end.offset > source_len) { + map.deinit(allocator); + return null; + } + const region = try map.by_region.getOrPut(allocator, site.region()); + if (!region.found_existing) region.value_ptr.* = site.identity else if (region.value_ptr.*) |old| { + if (!std.mem.eql(u8, &old, &site.identity)) region.value_ptr.* = null; + } + const identity = try map.by_identity.getOrPut(allocator, site.identity); + if (!identity.found_existing) identity.value_ptr.* = site.region() else if (identity.value_ptr.*) |old| { + if (!old.eq(site.region())) identity.value_ptr.* = null; + } + } + return map; + } + + fn deinit(self: *CliTestReportMap, allocator: Allocator) void { + self.by_region.deinit(allocator); + self.by_identity.deinit(allocator); + } +}; + +const CliTestOccurrence = struct { + key: [32]u8, + order: u32, + region: base.Region, + source: CliTestSourceModule, +}; + +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, + source_modules: *const CliTestSourceModuleMap, ) (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, 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)); try appendU64(&bytes, ctx.gpa, result.inline_passed); try appendU64(&bytes, ctx.gpa, result.inline_failed); - if (result.inline_expect and result.source_env == null) { - 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_env = result.source_env orelse artifact.moduleEnvConst(); + const source_id = completed_test.hash(source_env.qualifiedModuleName()); + const source = source_modules.get(source_id) orelse return; + // Root-relative spans instantiate at this occurrence, including duplicate + // bodies. Other sites must resolve through the checked producer's map. + const relative = source_env == artifact.moduleEnvConst() and + result.region.start.offset >= root_region.start.offset and result.region.end.offset <= root_region.end.offset; + try bytes.append(ctx.gpa, @intFromBool(relative)); + if (relative) { + try appendU32(&bytes, ctx.gpa, result.region.start.offset - root_region.start.offset); + try appendU32(&bytes, ctx.gpa, result.region.end.offset - root_region.start.offset); } else { - try appendU32(&bytes, ctx.gpa, 0); + const site = (source.reports.by_region.get(result.region) orelse return) orelse return; + try bytes.appendSlice(ctx.gpa, &source_id); + try bytes.appendSlice(ctx.gpa, &site); } - 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); 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); @@ -13223,7 +13683,8 @@ 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); + try bytes.appendSlice(ctx.gpa, &completed_test.hash(bytes.items)); + manager.storeFamilyBytes(.test_result, outcome_key, bytes.items, entries_dir, artifact.moduleEnvConst().module_name); } fn loadCliTestTranscriptEvents( @@ -13260,7 +13721,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); @@ -13276,7 +13737,7 @@ fn loadCliTestTranscriptEvents( return owned; } -fn loadCachedCliTestResults( +fn loadCachedCliTestOutcome( ctx: *CliCtx, cache_manager: ?*CacheManager, artifact: *const check.CheckedArtifact.CheckedModuleArtifact, @@ -13284,29 +13745,50 @@ 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.loadFamilyBytes(.test_result, outcome_key, entries_dir) orelse return null; + defer ctx.gpa.free(raw_data); + const source = source_modules.get(completed_test.hash(module.semantic.env.qualifiedModuleName())) orelse + base.invariant("test occurrence has no current source", .{}); + return decodeCliTestOutcome(ctx.gpa, raw_data, source_modules, .{ + .key = outcome_key, + .order = root.order, + .region = testRootRegion(module.semantic.env, root.source), + .source = source, + }); +} + +fn decodeCliTestOutcome(allocator: Allocator, raw_data: []const u8, source_modules: *const CliTestSourceModuleMap, occurrence: CliTestOccurrence) Allocator.Error!?[]CliTestResultItem { + 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], &occurrence.key)) return null; + offset += 32; + const root_region = occurrence.region; + if (root_region.start.offset > root_region.end.offset or root_region.end.offset > occurrence.source.common.source.len) 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; defer { if (!results_owned_by_module) { - deinitCliTestResultItemPayloads(ctx.gpa, results.items); - results.deinit(ctx.gpa); + deinitCliTestResultItemPayloads(allocator, results.items); + results.deinit(allocator); } } @@ -13320,28 +13802,28 @@ 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; - 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; - const source_name = data[offset..][0..source_name_len]; - offset += source_name_len; - var source_env: ?*const ModuleEnv = 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; - source_path = source_module.path; - } - if (source_env == null) return null; - } - const order = readU32(data, &offset) orelse return null; - 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 (inline_expect and root_index == 0) return null; + const relative = readU8(data, &offset) orelse return null; + var source = occurrence.source; + const region: base.Region = switch (relative) { + 1 => blk: { + const start = readU32(data, &offset) orelse return null; + const end = readU32(data, &offset) orelse return null; + if (start > end or end > root_region.end.offset - root_region.start.offset) return null; + break :blk base.Region.from_raw_offsets(root_region.start.offset + start, root_region.start.offset + end); + }, + 0 => blk: { + if (data.len - offset < 64) return null; + source = source_modules.get(data[offset..][0..32].*) orelse return null; + const site = data[offset + 32 ..][0..32].*; + offset += 64; + break :blk (source.reports.by_identity.get(site) orelse return null) orelse return null; + }, + else => return null, + }; + const order = occurrence.order; 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; } @@ -13349,20 +13831,20 @@ 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; - 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 (region.end.offset > source.common.source.len) return null; var visibility: CliTestFailureDetailVisibility = .always; const message = if (has_message == 0) null else blk: { @@ -13375,41 +13857,109 @@ 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; - const message = try ctx.gpa.dupe(u8, data[offset..][0..message_len_usize]); + if (message_len_usize > data.len - offset) return null; + const message = try allocator.dupe(u8, data[offset..][0..message_len_usize]); offset += message_len_usize; break :blk message; }; - try results.append(ctx.gpa, .{ + errdefer if (message) |owned| allocator.free(owned); + try results.append(allocator, .{ .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, - .source_env = source_env, - .source_path = source_path, + .source_env = source.env, + .source_common = source.common, + .source_name = source.name, + .source_path = source.path, .transcript = transcript, .failure_detail = message, .failure_detail_visibility = visibility, }); 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); + const owned_results = try results.toOwnedSlice(allocator); errdefer { - deinitCliTestResultItems(ctx.gpa, owned_results); + deinitCliTestResultItems(allocator, owned_results); } results_owned_by_module = true; - return .{ - .results = owned_results, - .summary = summary, - }; + return owned_results; +} + +test "relocatable outcomes bind duplicates and reject incomplete reporting atomically" { + const allocator = std.testing.allocator; + var common = try base.CommonEnv.init(allocator, "\n\nexpect True\nexpect True\n"); + defer common.deinit(allocator); + const source_id = completed_test.hash("Main"); + const site_id = completed_test.hash("helper observation"); + var source = CliTestSourceModule{ .env = null, .common = &common, .path = "current/Main.roc" }; + source.reports = (try CliTestReportMap.init(allocator, &.{ + .{ .identity = site_id, .start = 4, .end = 8 }, + .{ .identity = site_id, .start = 4, .end = 8 }, + }, common.source.len)).?; + defer source.reports.deinit(allocator); + var sources = CliTestSourceModuleMap{}; + defer sources.deinit(allocator); + try sources.put(allocator, source_id, source); + var bytes = std.ArrayList(u8).empty; + defer bytes.deinit(allocator); + const key = completed_test.hash("outcome"); + try bytes.appendSlice(allocator, cli_test_cache_magic); + try bytes.appendSlice(allocator, &key); + try appendU32(&bytes, allocator, 2); + for (0..2) |index| { + try bytes.append(allocator, @intFromBool(index == 1)); + try appendU64(&bytes, allocator, if (index == 1) 1 else 0); + try appendU64(&bytes, allocator, 0); + try bytes.append(allocator, @intFromBool(index == 0)); + if (index == 0) { + try appendU32(&bytes, allocator, 0); + try appendU32(&bytes, allocator, 4); + } else { + try bytes.appendSlice(allocator, &source_id); + try bytes.appendSlice(allocator, &site_id); + } + try bytes.append(allocator, 0); // passed + try appendU32(&bytes, allocator, 1); // one raw dbg event + try bytes.appendSlice(allocator, &.{ 0, 1 }); // dbg, stderr + const payload = "old/Main.roc:123 is user output"; + try appendU32(&bytes, allocator, payload.len); + try bytes.appendSlice(allocator, payload); + try bytes.append(allocator, 0); // no failure detail + } + const digest = completed_test.hash(bytes.items); + try bytes.appendSlice(allocator, &digest); + for ([_]u32{ 2, 14 }) |start| { + const occurrence = CliTestOccurrence{ .key = key, .order = start, .region = base.Region.from_raw_offsets(start, start + 4), .source = source }; + const results = (try decodeCliTestOutcome(allocator, bytes.items, &sources, occurrence)).?; + defer deinitCliTestResultItems(allocator, results); + try std.testing.expectEqual(start, results[0].region.start.offset); + try std.testing.expectEqual(start, results[0].order); + try std.testing.expectEqual(@as(u32, 4), results[1].region.start.offset); + try std.testing.expectEqualStrings("current/Main.roc", results[1].source_path.?); + try std.testing.expectEqualStrings("old/Main.roc:123 is user output", results[0].transcript[0].payload); + } + const occurrence = CliTestOccurrence{ .key = key, .order = 0, .region = base.Region.from_raw_offsets(2, 6), .source = source }; + for (0..bytes.items.len) |len| try std.testing.expect(try decodeCliTestOutcome(allocator, bytes.items[0..len], &sources, occurrence) == null); + // The first result has already been decoded when the second reference + // fails; no partially owned result or transcript escapes the decoder. + sources.getPtr(source_id).?.reports.by_identity.getPtr(site_id).?.* = null; + try std.testing.expect(try decodeCliTestOutcome(allocator, bytes.items, &sources, occurrence) == null); + _ = sources.remove(source_id); + try std.testing.expect(try decodeCliTestOutcome(allocator, bytes.items, &sources, occurrence) == null); + var ambiguous = (try CliTestReportMap.init(allocator, &.{ + .{ .identity = site_id, .start = 4, .end = 8 }, + .{ .identity = site_id, .start = 14, .end = 18 }, + }, common.source.len)).?; + defer ambiguous.deinit(allocator); + try std.testing.expect(ambiguous.by_identity.get(site_id).? == null); } fn collectTestRootRequests( @@ -13439,9 +13989,6 @@ fn buildCliTestPlan( for (planned_modules.items) |*module| { ctx.gpa.free(module.test_roots); ctx.gpa.free(module.result_indices); - if (module.cached_results) |results| { - deinitCliTestResultItems(ctx.gpa, results); - } } planned_modules.deinit(ctx.gpa); entries.deinit(ctx.gpa); @@ -13482,7 +14029,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, @@ -13523,15 +14070,20 @@ fn buildCliTestPlan( for (owned_modules) |*module| { ctx.gpa.free(module.test_roots); ctx.gpa.free(module.result_indices); - 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, @@ -13835,7 +14387,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)); @@ -13988,11 +14540,12 @@ 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, .platform_entrypoints => |artifact| artifact == .dev_run_image, - .test_plan => false, + .test_plan => opt == .dev, }, .inline_expects = switch (roots) { .test_plan => .run, @@ -14001,12 +14554,12 @@ 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), .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), }, }; } @@ -14029,6 +14582,20 @@ 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.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); +} + /// 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. @@ -14221,10 +14788,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() }, ); } @@ -14363,14 +14930,10 @@ fn copyCliTestResultItem( null; errdefer if (failure_detail) |message| allocator.free(message); - return .{ - .result = result.result, - .order = result.order, - .region = result.region, - .transcript = transcript, - .failure_detail = failure_detail, - .failure_detail_visibility = result.failure_detail_visibility, - }; + var copied = result; + copied.transcript = transcript; + copied.failure_detail = failure_detail; + return copied; } const CliInterpreterTestHostEnv = struct { @@ -14495,115 +15058,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( @@ -14640,11 +15218,13 @@ fn appendInlineExpectResults( const result: CliTestResult = if (count.failed == 0) .passed else .failed; var source_env: ?*const ModuleEnv = null; var source_path: ?[]const u8 = null; + var source_name: []const u8 = ""; 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; + source_name = source_module.name; } if (source_env == null) { base.invariant("test expect source module {s} was absent from the checked module plan", .{qualified_name}); @@ -14658,6 +15238,7 @@ fn appendInlineExpectResults( .inline_failed = count.failed, .source_env = source_env, .source_path = source_path, + .source_name = source_name, .failure_detail = null, .failure_detail_visibility = .always, }); @@ -14844,10 +15425,39 @@ 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); } +/// 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.CodeExcerpt.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.offered_count = &offered; + try offers.addProgram(self.program, reads); + // Normalize static names before sharing the code with another program. + var normalized = try backend.dev.CodeExcerpt.combineDistinct(self.ctx.gpa, &.{set}); + defer normalized.deinit(); + const specs = try offers.entries(self.ctx.gpa, &normalized); + defer self.ctx.gpa.free(specs); + if (offered == 0) return; + self.cache.store.writeIndexed(self.placement.origin, self.placement.identity, &normalized, specs) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.PackWriteFailed => return, + }; + self.cache.packs.refreshIndex(); + } +}; + fn lowerPlannedTestModule( ctx: *CliCtx, shared: *lir.CheckedPipeline.LoweredProgram, @@ -14865,7 +15475,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, @@ -14876,7 +15486,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); @@ -14895,12 +15504,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, source_modules); + 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, + .env = &module.semantic.env.common, .path = module.path, - .results = try ctx.gpa.dupe(CliTestResultItem, results.items), + .results = merged, .cached = false, }); results.deinit(ctx.gpa); @@ -14927,6 +15542,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; @@ -14986,7 +15602,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, @@ -15031,7 +15647,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 { @@ -15047,24 +15662,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 cliInlineResultLessThan(_: void, a: CliTestResultItem, b: CliTestResultItem) bool { + const names = std.mem.order(u8, a.source_name, b.source_name); + 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; +} + +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..], {}, cliInlineResultLessThan); + 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, source_modules: *const CliTestSourceModuleMap) (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], source_modules); + } + if (cursor != fresh.len) base.invariant("fresh outcomes were not partitioned by checked root order", .{}); +} + fn appendPlannedModuleResult( ctx: *CliCtx, module_results: *std.ArrayList(CliModuleTestResult), @@ -15075,7 +15737,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, @@ -15084,7 +15746,7 @@ fn appendPlannedModuleResult( } const InlineExpectResultKey = struct { - env: *const ModuleEnv, + env: *const base.CommonEnv, region_start: u32, region_end: u32, }; @@ -15103,7 +15765,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 = .{ @@ -15143,17 +15805,11 @@ fn coalesceInlineExpectResults( /// Feed cached roots through the same ordered output coordinator even when /// there is no runtime program. Inline observations are merged after all roots. fn publishCachedTestModule(live: *CliOptimizedLiveTestOutput, planned: *const CliTestPlanModule) ReportRenderError!void { - var root_index: usize = 0; - for (planned.cached_results.?) |result| { - if (result.inline_expect) continue; - live.publishCopiedEntry( - planned.result_indices[root_index], - planned.module.semantic.env, - planned.module.path, - result, - ); - root_index += 1; - } + for (planned.cached_roots, planned.result_indices) |slot, result_index| if (slot) |items| { + for (items) |result| if (!result.inline_expect) { + live.publishCopiedEntry(result_index, planned.module.semantic.env, planned.module.path, result); + }; + }; try live.checkError(); } @@ -15170,6 +15826,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) { @@ -15198,11 +15855,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| try publishCachedTestModule(live, planned); - 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.result_indices[index], planned.module.semantic.env, planned.module.path, result); + }; + }; + try live.checkError(); } + if (planned.uncachedCount() == 0) continue; try lowered_modules.append( ctx.gpa, @@ -15210,23 +15871,22 @@ 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]; - 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].?, source_modules); } 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; @@ -16315,7 +16975,16 @@ fn runWatchCommand(ctx: *CliCtx, arg0: []const u8, command: WatchCommand) WatchC } } +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(); @@ -16344,6 +17013,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, .{ @@ -16352,7 +17027,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, @@ -16364,6 +17039,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 @@ -16388,6 +17064,49 @@ 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 }}); + var roots: u64 = 0; + for (completed.record.modules) |module| roots += module.roots.len; + const counters = cliTestCacheCounters(.{ + .modules_total = completed.record.modules.len, + .modules_cached = completed.record.modules.len, + .roots_total = roots, + .roots_cached = roots, + }); + reporter.recordCounters("Test result cache", &counters); + 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; + if (warnings > 0) printCliTestWork(ctx); + 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)); @@ -16403,14 +17122,21 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr const modules = try build_env.getCompiledModules(ctx.gpa); defer ctx.gpa.free(modules); var source_modules = CliTestSourceModuleMap{}; - defer source_modules.deinit(ctx.gpa); + defer { + var sources = source_modules.valueIterator(); + while (sources.next()) |source| source.reports.deinit(ctx.gpa); + 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, .name = qualified_name }; + const sites = if (module.semantic.checked_artifact) |artifact| artifact.compile_time_root_identities.test_report_sites else &.{}; + entry.value_ptr.reports = (try CliTestReportMap.init(ctx.gpa, sites, module.semantic.env.getSourceAll().len)) orelse + base.invariant("checked producer published invalid report sites", .{}); } const report_config = testReportingConfig(ctx); @@ -16427,22 +17153,15 @@ 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; + 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; + } } } - reporter.recordCounters("Test result cache", &cliTestCacheCounters(cliTestCacheStats(&test_plan))); + reporter.end(); var discovery_requests = std.ArrayList(check.CheckedArtifact.RootRequest).empty; defer discovery_requests.deinit(ctx.gpa); @@ -16455,15 +17174,15 @@ 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) { - try discovery_requests.appendSlice(ctx.gpa, planned.test_roots); - try discovery_sources.appendNTimes(ctx.gpa, planned.artifact.key.digest(), planned.test_roots.len); - 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, planned.result_indices) |root, result_index| { - const entry = test_plan.entries[result_index]; + for (planned.test_roots, 0..) |root, root_index| { + if (planned.cached_roots[root_index] != null) { + try discovery_requests.append(ctx.gpa, root); + try discovery_sources.append(ctx.gpa, planned.artifact.key.digest()); + continue; + } + try runtime_requests.append(ctx.gpa, root); + try runtime_sources.append(ctx.gpa, planned.artifact.key.digest()); + const entry = test_plan.entries[planned.result_indices[root_index]]; try runtime_metadata.append(ctx.gpa, .{ .request_index = @intCast(runtime_metadata.items.len), .root_order = root.order, @@ -16485,7 +17204,29 @@ 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 (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; + } runtime_config.explicit_roots = runtime_roots; + // Test execution and discovery share the checked session's interface provider. + 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(); runtime_config.compile_time_discovery_roots = .{ .requests = discovery_requests.items, .source_modules = discovery_sources.items, @@ -16501,16 +17242,28 @@ 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. 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))); + 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(); @@ -16538,6 +17291,22 @@ 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(); + + 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 = test_plan.entries.len != 0 and switch (test_mode) { .llvm_size, .llvm_speed => true, @@ -16618,14 +17387,11 @@ 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| { - 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, @@ -16648,20 +17414,27 @@ fn rocTest(ctx: *CliCtx, args_in: cli_args.TestArgs, arg0: []const u8) RocTestEr } else { for (test_plan.modules) |*planned| { if (live_output) |*live| try publishCachedTestModule(live, planned); - const summary = planned.cached_summary; - try appendPlannedModuleResult(ctx, &module_results, planned, planned.releaseCachedResults(), true); - total.passed += summary.passed; - total.failed += summary.failed; - total.compiler_errors += summary.compiler_errors; - total.cached_modules += summary.cached_modules; + const results = try mergeCliRootOutcomes(ctx.gpa, planned, &.{}); + try appendPlannedModuleResult(ctx, &module_results, planned, results, true); + total.cached_modules += 1; } } + 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; 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.index_stats.payload_files }, + .{ .name = "Bytes read", .count = cache.packs.bytes_loaded }, + }); reporter.finish(); // Calculate elapsed time @@ -16705,6 +17478,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; } @@ -16802,7 +17576,7 @@ fn renderCliTestTranscriptEventRecords( } const CliTestRenderEntry = struct { - env: *const ModuleEnv, + env: *const base.CommonEnv, path: []const u8, result: *const CliTestResultItem, }; @@ -17186,7 +17960,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| { @@ -17227,7 +18001,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| { @@ -17298,7 +18072,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" }; @@ -17349,7 +18123,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" }; @@ -17411,7 +18185,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); @@ -17549,7 +18323,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, @@ -17561,7 +18335,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; @@ -17589,7 +18363,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, @@ -18161,12 +18935,20 @@ 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, .{}, &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 { @@ -18223,7 +19005,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) [42]progress.Counter { const graph = diagnostics.graph; return .{ .{ .name = "Graphs created", .count = diagnostics.body.graphs_created }, @@ -18254,13 +19036,39 @@ 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 }, - }; -} - -fn monotypeBodyCounters(diagnostics: postcheck.Monotype.Lower.Diagnostics) [26]progress.Counter { + .{ .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 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 = "Analysis changed nodes", .count = graph.analysis_changed_nodes }, + .{ .name = "Analysis invalidation edges", .count = graph.analysis_invalidation_edges }, + .{ .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 }, + }; +} + +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 }, .{ .name = "Body contexts created", .count = body.body_contexts_created }, .{ .name = "Type instantiation scopes", .count = body.instantiation_scopes_created }, @@ -18685,16 +19493,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[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, @@ -18747,7 +19555,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(); @@ -19110,6 +19927,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, @@ -19424,6 +20242,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); @@ -19431,6 +20250,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] = .{ @@ -19441,6 +20261,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, @@ -19687,7 +20508,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(); @@ -19720,7 +20541,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(); @@ -19757,7 +20578,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(); @@ -21233,3 +22054,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 new file mode 100644 index 00000000000..a6a42079483 --- /dev/null +++ b/src/cli/pack_index.zig @@ -0,0 +1,478 @@ +//! 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"); +const lir = @import("lir"); +const PA = backend.dev.CodeExcerpt; +const PF = backend.dev.PackFile; +const Allocator = std.mem.Allocator; + +/// 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; + base.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.Excerpt) 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); +} + +/// Bytes are owned by the loader, and remain live until all its consumers end. +pub fn materialize(artifact: *PA.Excerpt, 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.CodeExcerpt.Set, + procs: std.AutoHashMap(lir.ProcIdentity, CodeExcerpts), + 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, + 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. + const CodeExcerpts = struct { first: u32, last: u32 }; + + pub 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, CodeExcerpts).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.excerpts.len); + for (set.excerpts, 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 offeredProcExcerpt(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 { + 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.excerpt); + return self.expand(); + } + + fn clear(self: *SpliceClosure) void { + for (self.order.items) |index| self.seen.unset(index); + self.order.clearRetainingCapacity(); + 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.excerpts[self.order.items[cursor]]; + for (artifact.refs) |ref| { + self.edges_visited += 1; + try self.visit(ref.target); + } + for (artifact.symbolic_refs) |ref| { + 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.excerpts.len + 1); + var targets = std.ArrayList(u32).empty; + for (set.excerpts, 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.excerpts.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.excerpts.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.excerpts[lhs].metadata_origin.?.index < owner.excerpts[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.excerpts[members[0]].metadata_origin orelse return null; + if (first.source.excerpts.len != members.len) return null; + for (members) |index| { + const origin = set.excerpts[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 { excerpt: 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.Excerpt, members.len); + var edges = std.ArrayList(Edge).empty; + for (members, artifacts, 0..) |old, *artifact, local| { + artifact.* = set.excerpts[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, .{ .excerpt = @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, .{ .excerpt = @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, .excerpts = 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.excerpt); + 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.* = .{ .excerpt = 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.excerpts.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.excerpts, 0..) |artifact, i| { + if (artifact.code.len != 0 or artifact.runtime_numbers.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.excerpt != 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].excerpt == 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.excerpt = 0; + const encoded_pack = try PF.write(a, &.{ .arena = undefined, .excerpts = &.{.{ .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.excerpts.len != 1 or pack.specs.len != 1 or pack.set.excerpts[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; // 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, + .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.excerpts[index]; + const origin = current.metadata_origin.?; + if (origin.index != ordinal) return null; + const old = origin.source.excerpts[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.RuntimeNumberLoad, current.runtime_numbers, old.runtime_numbers)) 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 5e3fbb32e2b..73c5199c6b1 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, @@ -30,227 +33,467 @@ pub const Store = struct { 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, + /// One retention observation for this invocation-owned cache reader. + read_timestamp: i64, + variant: [32]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}; - 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); + 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 { + 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 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 }; + 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, + }); + 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"); - } + const IndexedKind = enum { payload, metadata, offer, offer_domain, completed, interface }; + + const offer_domain_bytes = "ROCNOFF1"; - 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 offerDomainIdentity(self: *const Store, origin: compile.BuildEnv.PackOrigin) compile.artifacts.Identity { + return self.indexedIdentity(origin, @splat(0), Index.digest(offer_domain_bytes), .offer_domain); } - /// 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 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 = base.Sha256.init(.{}); + hash.update(&id.variant); + hash.update("roc-indexed-procedure-v1"); + hash.update(if (kind == .interface) "interface-request-v3" else @tagName(kind)); + hash.final(&id.variant); + return id; } - 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 linkObjectIdentity(self: *const Store, digest: [32]u8) compile.artifacts.Identity { + var id = self.artifactIdentity(.program_output, @splat(0), digest); + var hash = base.Sha256.init(.{}); + hash.update(&id.variant); + hash.update("roc-native-link-object-v1"); + hash.final(&id.variant); + return id; } - /// 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) { + fn readPrefix(self: *const Store, id: compile.artifacts.Identity, len: usize, allocator: Allocator) Allocator.Error!?[]u8 { + var artifact = (self.native.acquire(id, self.read_timestamp) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, + else => return null, + }) orelse return null; + defer artifact.deinit(); + if (artifact.size < len) return null; + const bytes = try allocator.alloc(u8, len); + const count = artifact.file.readPositionalAll(self.native.io, bytes, 0) catch { + allocator.free(bytes); + return null; + }; + if (count != len) { + allocator.free(bytes); + return null; + } + return bytes; + } + + pub fn readProgramPrefix(self: *const Store, key: [32]u8, len: usize, allocator: Allocator) Allocator.Error!?[]const u8 { + 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.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 { + return self.native.read(allocator, id, self.read_timestamp) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, else => null, }; } - /// Atomically record the runtime output of the program `key` names. - /// This also replaces a corrupt record after a cache miss. - 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); - self.roc_ctx.makePath(std.fs.path.dirname(path).?) catch return error.PackWriteFailed; - const staging = self.roc_ctx.writeTempFile(self.allocator, path, bytes) catch |err| switch (err) { + 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, - else => return error.PackWriteFailed, + 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; + }, }; - defer self.allocator.free(staging); - defer self.roc_ctx.deleteFile(staging) catch {}; - // Records are small checksummed manifests. Replacing a corrupt record - // repairs a miss, and never changes the immutable object it names. - self.roc_ctx.rename(staging, path) catch return error.PackWriteFailed; } - /// 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 writeDiscovery(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + var key: [32]u8 = undefined; + base.Sha256.hash(bytes, &key, .{}); + try self.writeEntry(self.artifactIdentity(if (origin == .local) .local_discovery else .package_discovery, module, key), 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 readProgram(self: *const Store, key: [32]u8, allocator: Allocator) Allocator.Error!?[]u8 { + return self.read(allocator, self.artifactIdentity(.program_output, @splat(0), key)); } - /// 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) { - error.OutOfMemory => return error.OutOfMemory, - else => return null, - }; + pub fn writeProgram(self: *const Store, key: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + 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.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); } - /// 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) { + pub fn replaceOptimized(self: *const Store, module: [32]u8, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!void { + 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 { + var artifact = (self.native.acquire(self.packIdentity(origin, module, key), self.read_timestamp) 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; - }; + else => return false, + }) orelse return false; + defer artifact.deinit(); + return true; } - /// 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 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); } - /// 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 loadIdentity(self: *const Store, _: std.Io, packs: *LoadedPacks, origin: compile.BuildEnv.PackOrigin, module: [32]u8) Allocator.Error!void { + try packs.addIndexProvider(self, origin, module); } - /// 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; + /// 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.CodeExcerpt.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.CodeExcerpt.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.CodeExcerpt.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; + 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; + var closure = try SpliceClosure.init(allocator, set); + defer closure.deinit(); + var graph = try Index.ComponentGraph.init(allocator, &closure); + defer graph.deinit(); + stats.graph_nodes += set.excerpts.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); + const payload_hashes = try allocator.alloc([32]u8, set.excerpts.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(compile.artifacts.Artifact).empty; + defer { + 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; } - const temp_path = self.roc_ctx.writeTempFile(self.allocator, path, bytes) catch |err| switch (err) { + var published = std.AutoHashMap([32]u8, void).init(allocator); + defer published.deinit(); + for (union_indices) |index| { + included[graph.components.component_of[index]] = true; + const artifact = set.excerpts[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 id = self.indexedIdentity(.local, @splat(0), record.digest, .payload); + if (std.mem.eql(u8, &record.storage_identity, &self.retainedIdentity(id.indexedKey()))) stats.validated_references += 1 else _ = try self.publishIndexedHeld(id, 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); + payload_hashes[index] = hash; + if ((try published.getOrPut(hash)).found_existing) continue; + const id = self.indexedIdentity(.local, @splat(0), hash, .payload); + _ = try self.publishIndexedHeld(id, bytes, stats, &pins); + } + var names: ?backend.dev.CodeExcerpt.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 id = self.indexedIdentity(.local, @splat(0), record.digest, .metadata); + if (std.mem.eql(u8, &record.storage_identity, &self.retainedIdentity(id.indexedKey()))) stats.validated_references += 1 else _ = try self.publishIndexedHeld(id, record.bytes, stats, &pins); + continue; + } + if (names == null) names = try backend.dev.CodeExcerpt.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 id = self.indexedIdentity(.local, @splat(0), hash, .metadata); + _ = try self.publishIndexedHeld(id, bytes, stats, &pins); + } + 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.excerpt]]; + const ordinal = graph.ordinal[spec.excerpt]; + 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.excerpts[spec.excerpt].kind.proc, spec); + defer allocator.free(bytes); + stats.bytes_hashed += bytes.len; + const record = try allocator.alloc(u8, 32 + bytes.len); + defer allocator.free(record); + @memcpy(record[0..32], &provider.identity); + @memcpy(record[32..], bytes); + const id = self.indexedIdentity(provider.origin, spec.key, Index.digest(record), .offer); + _ = try self.publishIndexedHeld(id, record, stats, &pins); + } + } + + /// 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, module: [32]u8, key: [32]u8) Allocator.Error!bool { + const bytes = (try self.read(self.allocator, self.indexedIdentity(origin, module, key, .completed))) orelse return false; + defer self.allocator.free(bytes); + return std.mem.eql(u8, bytes, "ROCINDEX01"); + } + + pub fn completeIndexed(self: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8, key: [32]u8) (Allocator.Error || error{PackWriteFailed})!void { + _ = try self.publishIndexed(self.indexedIdentity(origin, module, key, .completed), "ROCINDEX01"); + } + + 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.artifactIdentity(if (origin == .local) .local_discovery else .package_discovery, module, @splat(0)), .discovery); + } + + fn retainedIdentity(self: *const Store, key: [64]u8) [32]u8 { + var hash = base.Sha256.init(.{}); + hash.update(self.native.root); + hash.update(&key); + return hash.finalResult(); + } + + const RetainedArtifact = struct { + allocator: Allocator, + artifact: compile.artifacts.Artifact, + // Artifact.deinit only needs this owned io/allocator, not a live Store. + store: compile.artifacts.Store, + fn release(context: *anyopaque) void { + const self: *@This() = @ptrCast(@alignCast(context)); + self.artifact.deinit(); + self.allocator.destroy(self); + } + }; + + 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 { + const acquired = (batch.acquireKey(key, self.read_timestamp) 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; - }; + else => return error.Unavailable, + }) orelse return error.Unavailable; + return self.retainAcquired(owner, acquired, key, kind); } - /// 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) { + /// 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, - else => return, + else => return error.Unavailable, }; - defer self.allocator.free(existing); - if (!std.mem.eql(u8, existing, bytes)) { - @import("base").invariant("object-cache pack {s} already holds different bytes", .{path}); - } + errdefer owner.allocator.free(bytes); + const held = try owner.allocator.create(RetainedArtifact); + errdefer owner.allocator.destroy(held); + held.* = .{ .allocator = owner.allocator, .artifact = acquired, .store = self.native }; + held.artifact.store = &held.store; + return owner.take(bytes, key[32..].*, kind, self.retainedIdentity(key), .{ .context = held, .deinit = RetainedArtifact.release }); } - 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 {}; + fn publishIndexed(self: *const Store, id: compile.artifacts.Identity, bytes: []const u8) (Allocator.Error || error{PackWriteFailed})!bool { + var stats: PublicationStats = .{}; + return self.publishIndexedHeld(id, bytes, self.publication_stats orelse &stats, null); } - /// 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); + 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.read_timestamp) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.PackWriteFailed, + }) |value| { + var acquired = value; + var transferred = false; + defer if (!transferred) acquired.deinit(); + const existing = acquired.read(self.allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.PackWriteFailed, + }; + defer self.allocator.free(existing); + stats.bytes_read += existing.len; + if (std.mem.eql(u8, existing, bytes)) { + if (pins) |held| { + held.appendAssumeCapacity(acquired); + transferred = true; + } + return false; + } + } + self.native.write(id, bytes, self.native.now()) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.PackWriteFailed, + }; + if (pins) |held| { + 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; + errdefer acquired.deinit(); + // Acquisition may race another publisher; validate the generation + // actually acquired before publishing references to its identity. + const actual = acquired.read(self.allocator) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.PackWriteFailed, + }; + defer self.allocator.free(actual); + stats.records_read += 1; + stats.bytes_read += actual.len; + if (!std.mem.eql(u8, actual, bytes)) return error.PackWriteFailed; + held.appendAssumeCapacity(acquired); + } + stats.records_written += 1; + stats.bytes_written += bytes.len; + return true; } - /// 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); + 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(keys); + for (keys) |key| { + var artifact = (self.native.acquireKey(key, self.read_timestamp) 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(); + switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => 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, + }; + } } }; @@ -261,9 +504,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, @@ -275,7 +518,8 @@ 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, + .iterator_fusion_scope = true, .rc_borrowed_params = 1, .rc_ret_borrowed = false, .rc_ret_lenders = 0, @@ -297,13 +541,15 @@ 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 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 { @@ -315,7 +561,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; } }; @@ -333,6 +579,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"); @@ -347,18 +600,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, @@ -370,7 +623,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, @@ -385,155 +638,14 @@ 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; } } -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; @@ -552,6 +664,32 @@ 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); + const artifact: backend.dev.CodeExcerpt.Excerpt = .{ .kind = .{ .proc = lir.ProcIdentity.forTest(4) }, .code = "\xc3", .entry = 0, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }; + const spec: PackFile.SpecEntry = .{ .key = offered_key, .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 = &.{} }; + try musl.writeIndexed(.local, identity, &.{ .arena = undefined, .excerpts = &.{artifact} }, &.{spec}); + try musl.completeIndexed(.local, identity, key); + try std.testing.expect(try glibc.hasIndexed(.local, identity, key)); + { + var packs = LoadedPacks.init(a); + defer packs.deinit(); + try glibc.loadIdentity(io, &packs, .local, identity); + try packs.indexPacks(); + try std.testing.expect(packs.specCacheLookup().lookup(offered_key, .dev_code, &.{}) != null); + } try musl.writeProgram(key, "musl program output"); try std.testing.expectEqual(null, try glibc.readProgram(key, a)); try glibc.writeProgram(key, "glibc program output"); @@ -562,10 +700,23 @@ test "native procedure domain shares packs but isolates final outputs and linked var incompatible = try Store.init(a, config, target, "dev"); defer incompatible.deinit(); try std.testing.expect(!try incompatible.has(.local, identity, key)); + 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); } var boxy = try Store.initForStrategy(a, config, .x64glibc, "dev", .boxy); 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(); @@ -573,62 +724,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.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)); } } -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 @@ -654,24 +779,35 @@ 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 (programPackPlacement(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 { var hasher = std.crypto.hash.Blake3.init(.{}); 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}); + for (selection.modules) |module| { + 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}); + const identity = selection.optimized_identity orelse continue; + const input_key = try store.optimizedInputKey(identity) orelse continue; + hasher.update(&store.artifactIdentity(.optimized_pack, identity, @splat(0)).indexedKey()); hasher.update(&input_key); } var digest: [32]u8 = undefined; @@ -680,11 +816,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); } @@ -722,10 +858,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; @@ -736,7 +872,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, @@ -778,6 +914,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 @@ -833,17 +972,109 @@ 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] }); + } + } +}; + +/// The native packs a build has acquired, with the offers they link. pub const LoadedPacks = struct { const LinkedOffer = struct { hit: Common.SpecCacheHit, pack: u32, }; + const IndexedMetadata = struct { + record: *const base.retained_cache.Record, + pack: *PackFile.Pack, + hashes: []const [32]u8, + edges: []const Index.Edge, + start: ?u32 = null, + materialized: bool = false, + }; + const MissKey = struct { + key: [32]u8, + takes: Common.SpecCacheTakes, + modules: [32]u8, + }; + const IndexedResult = enum { hit, miss, unavailable }; + + /// 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, 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, + interface_invalid_records: u64 = 0, + interface_families: std.AutoHashMapUnmanaged([32]u8, *InterfaceFamily) = .empty, + interface_store: ?*const Store = null, allocator: Allocator, + index_dirs: std.ArrayList(Provider) = .empty, + index_roots: std.ArrayList(IndexRoot) = .empty, + provider_ranks: std.AutoHashMapUnmanaged([32]u8, u32) = .empty, + metadata: std.AutoHashMapUnmanaged([32]u8, *IndexedMetadata) = .empty, + indexed_pack: ?*PackFile.Pack = null, + indexed_artifacts: std.ArrayList(backend.dev.CodeExcerpt.Excerpt) = .empty, + indexed_files: std.ArrayList([32]u8) = .empty, + indexed_file_ids: std.AutoHashMapUnmanaged([32]u8, u32) = .empty, + 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, + + 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, 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), @@ -854,11 +1085,12 @@ 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.CodeExcerpt.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, 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`). @@ -870,11 +1102,12 @@ 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, /// Bytes read from procedure, discovery and linked-object files. bytes_loaded: u64 = 0, - /// Set until `indexPacks` runs; lookups before that load the pending - /// store's packs for the modules in view first. + lookups: u64 = 0, + /// 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`. @@ -888,6 +1121,629 @@ 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); + self.record_owner = owner; + 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; + 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.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; + for (hit.comptime_root_reads, spec.comptime_root_reads) |a, b| if (!std.meta.eql(a, b)) return false; + if (offer.bytes.len < 32 or !std.mem.eql(u8, offer.bytes[0..32], &provider.identity)) return false; + const id = store.indexedIdentity(provider.origin, spec.key, offer.digest, .offer); + return std.mem.eql(u8, &offer.storage_identity, &store.retainedIdentity(id.indexedKey())); + } + + 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, + 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 addIndexProvider(self: *LoadedPacks, store: *const Store, origin: compile.BuildEnv.PackOrigin, module: [32]u8) Allocator.Error!void { + self.record_domain = store.domain; + const provider_key = store.packIdentity(origin, module, @splat(0)).indexedKey()[0..32].*; + if (self.provider_ranks.contains(provider_key)) return; + try self.index_dirs.ensureUnusedCapacity(self.allocator, 1); + try self.index_roots.ensureUnusedCapacity(self.allocator, 1); + try self.provider_ranks.put(self.allocator, provider_key, @intCast(self.index_dirs.items.len)); + self.index_dirs.appendAssumeCapacity(.{ .store = store, .origin = origin, .module = module }); + const root_key = store.packIdentity(origin, @splat(0), @splat(0)).indexedKey()[0..32].*; + const known = for (self.index_roots.items) |existing| { + const previous = existing.store.packIdentity(existing.origin, @splat(0), @splat(0)).indexedKey()[0..32].*; + if (std.mem.eql(u8, &previous, &root_key)) break true; + } else false; + if (!known) self.index_roots.appendAssumeCapacity(.{ .store = store, .origin = origin }); + 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) { + record.loaded = false; + }; + } + } + + /// 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(); + } + + 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, store: *const Store, hash: [32]u8) Allocator.Error!?*IndexedMetadata { + if (self.metadata.get(hash)) |existing| return existing; + 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) { + error.OutOfMemory => return error.OutOfMemory, + error.Corrupt => { + self.index_stats.corrupt += 1; + return null; + }, + error.Unavailable => { + self.index_stats.missing += 1; + 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; + var decoded = Index.decodeComponent(allocator, record.bytes) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => { + self.index_stats.corrupt += 1; + return null; + }, + }; + 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; + // Allocate through the original arena until ownership transfers. + const a = decoded.pack.set.arena.allocator(); + const PA = backend.dev.CodeExcerpt; + const origins = try a.alloc(PA.MetadataOrigin, decoded.pack.set.excerpts.len); + for (decoded.pack.set.excerpts, 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.excerpt]; + if (edge.symbolic) @constCast(origin.symbolic)[edge.reference] = target else @constCast(origin.refs)[edge.reference] = target; + } + for (@constCast(decoded.pack.set.excerpts), 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; + } + + /// 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. + fn appendComponent(self: *LoadedPacks, entry: *IndexedMetadata) (Allocator.Error || error{MalformedPack})!void { + const PA = backend.dev.CodeExcerpt; + if (self.indexed_pack == null) { + var value = PackFile.Pack{ .set = .{ .arena = base.SingleThreadArena.init(self.allocator), .excerpts = &.{} }, .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.excerpts = 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.excerpts.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.excerpts.len) return error.MalformedPack; + if (edge.symbolic) { + const target = entry.pack.set.excerpts[edge.excerpt].symbolic_refs[edge.reference].target; + const kind = target_set.excerpts[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.Excerpt, entry.pack.set.excerpts); + 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.excerpt]; + 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, 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); + 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(store, 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, 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); + 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.excerpts), entry.hashes, 0..) |*artifact, hash, i| { + if (artifact.payload_record == null) { + const record = try self.readPayload(store, 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; + } + + 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 { + 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, + record: *const base.retained_cache.Record, + store: *const Store, + 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.digest, &b.digest); + } + }; + var candidates = std.ArrayList(Candidate).empty; + defer candidates.deinit(allocator); + var cacheable = true; + 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) { + error.OutOfMemory => return error.OutOfMemory, + else => { + cacheable = false; + continue; + }, + }; + 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) { + error.OutOfMemory => return error.OutOfMemory, + error.Corrupt => { + self.index_stats.corrupt += 1; + cacheable = false; + continue; + }, + error.Unavailable => { + self.index_stats.missing += 1; + cacheable = false; + continue; + }, + }; + const bytes = record.bytes; + self.bytes_loaded +|= bytes.len; + self.index_stats.index_bytes += bytes.len; + if (bytes.len < 32) { + self.index_stats.corrupt += 1; + cacheable = false; + continue; + } + const provider = root.store.packIdentity(root.origin, bytes[0..32].*, @splat(0)).indexedKey()[0..32].*; + const rank = self.provider_ranks.get(provider) orelse continue; + try candidates.append(allocator, .{ .digest = record.digest, .record = record, .store = root.store, .rank = rank }); + } + } + 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; + const store = candidate.store; + const bytes = offer_record.bytes[32..]; + 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(); + 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; + }; + if (selection.index >= metadata.pack.set.excerpts.len or metadata.pack.set.excerpts[selection.index].kind != .proc or !std.mem.eql(u8, &selection.pack.specs[0].key, &key) or + !std.meta.eql(selection.pack.set.excerpts[0].kind.proc, metadata.pack.set.excerpts[selection.index].kind.proc)) + { + self.index_stats.corrupt += 1; + 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; + const absent = try self.sourceMarks(&pack.set, modules); + if (absent[spec.excerpt]) { + self.index_stats.source_ineligible += 1; + continue; + } + if (takes.refusesDevCodeOfLinkedProcs()) { + self.ensureLinkedLoaded(); + const linked = try self.linkedMarks(&pack.set); + if (linked[spec.excerpt]) { + self.index_stats.linked_ineligible += 1; + continue; + } + } + 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); + try self.specs.ensureUnusedCapacity(1); + _ = self.specs.remove(key); + _ = self.excerpts.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", .{}), + }; + try self.retained_offers.put(allocator, key, offer_record); + self.index_stats.served_artifacts += metadata.pack.set.excerpts.len; + return .hit; + } + return if (cacheable) .miss else .unavailable; + } + + fn readPayload(self: *LoadedPacks, store: *const Store, hash: [32]u8) Allocator.Error!?*const base.retained_cache.Record { + 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, 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; + return null; + }, + error.Unavailable => { + self.index_stats.missing += 1; + return null; + }, + }; + 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 record; + } + pub const LoadError = Allocator.Error || PackFile.ReadError || error{PackDirectoryUnreadable}; pub const Input = enum { /// Independent optional offers; an incompatible file does not veto peers. @@ -901,13 +1757,13 @@ 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), }; } - /// Load every `.rpk` file in `dir_path` and index the result. + /// Load explicit diagnostic pack files from a directory. pub fn loadDir(allocator: Allocator, io: std.Io, dir_path: []const u8) LoadError!LoadedPacks { var self = LoadedPacks.init(allocator); errdefer self.deinit(); @@ -976,10 +1832,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(); - 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) { @@ -1001,19 +1858,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) { @@ -1110,28 +1964,33 @@ 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.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)) return; + try self.excerpts.ensureUnusedCapacity(1); + try self.specs.ensureUnusedCapacity(1); + self.excerpts.putAssumeCapacityNoClobber(spec.key, .{ .set = &pack.set, .index = spec.excerpt }); + 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, + .may_leave_pending = spec.may_leave_pending, + .iterator_fusion_scope = spec.iterator_fusion_scope, + .rc_ret_conditions = spec.rc_ret_conditions, + }); } /// Index every loaded object pack, in load order: the first pack to @@ -1140,13 +1999,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, @@ -1155,6 +2014,8 @@ 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, + .iterator_fusion_scope = spec.iterator_fusion_scope, .rc_ret_conditions = spec.rc_ret_conditions, }, .pack = @intCast(pack_index), @@ -1187,29 +2048,33 @@ 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); - 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.artifactIdentity(.optimized_pack, placement.identity, @splat(0))); } } 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 { 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. @@ -1230,6 +2095,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| { @@ -1240,6 +2113,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| { @@ -1273,6 +2158,37 @@ 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}); + 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_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()) |record| { + if (record.payload) |bytes| self.allocator.free(bytes); + self.allocator.destroy(record); + } + 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); + var metadata = self.metadata.valueIterator(); + while (metadata.next()) |entry| { + self.allocator.destroy(entry.*); + } + self.metadata.deinit(self.allocator); + self.indexed_file_ids.deinit(self.allocator); + 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); self.discovery.deinit(self.allocator); @@ -1280,15 +2196,15 @@ 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); self.object_packs_used.deinit(self.allocator); - self.artifacts.deinit(); + self.excerpts.deinit(); self.specs.deinit(); for (self.packs.items) |pack| { pack.deinit(); @@ -1299,7 +2215,125 @@ 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; + } + + /// 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); + const family = try self.allocator.create(InterfaceFamily); + family.* = .{}; + entry.value_ptr.* = 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 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; + 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; + 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); + if (record.payload) |old| self.allocator.free(old); + record.payload = owned; + record.loaded = true; + record.dirty = true; + } + + /// 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); + 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.*; + 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); + 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; + } + } } /// The artifact source the object compiler splices from. @@ -1339,6 +2373,8 @@ 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.lookups +|= 1; self.ensureLoaded(); if (self.state != .ready) return null; // An optimized object serves a consumer whose code is linked ahead @@ -1351,66 +2387,105 @@ 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 takes == .test_dev_code and !self.specs.get(key).?.test_compatible) eligible = false; + 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) { + 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; + self.index_stats.served += 1; return hit; } /// 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 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 located = self.excerpts.get(key) orelse return false; + 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.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.* = .{}; + 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 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 !facts.present_files.items[artifact.loc.file]; + for (artifact.lines) |line| { + 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); + return marks; + } + + fn linkedMarks(self: *LoadedPacks, set: *const backend.dev.CodeExcerpt.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.excerpts.len) return slot.value_ptr.items; + try slot.value_ptr.resize(self.allocator, set.excerpts.len); + errdefer slot.value_ptr.items.len = first; + const marks = slot.value_ptr.items; + for (set.excerpts[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.CodeExcerpt.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 located = self.excerpts.get(key) orelse return false; + const marks = self.linkedMarks(located.set) catch return true; return marks[located.index]; } @@ -1429,18 +2504,19 @@ 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 &.{}; 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; } }; @@ -1449,6 +2525,86 @@ 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.CodeExcerpt.Set = .{ + .arena = undefined, + .excerpts = &.{.{ .kind = .{ .proc = lir.ProcIdentity.forTest(1) }, .code = "\xc3", .entry = 0, .frame = null, .refs = &.{}, .relocations = &.{}, .data = &.{} }}, + }; + var spec: PackFile.SpecEntry = .{ + .key = key, + .excerpt = 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); + try store.writeIndexed(.local, identity, &set, &.{spec}); + // Repeated publication reuses both immutable files. + try store.writeIndexed(.local, identity, &set, &.{spec}); + } + 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.index_stats.payload_files); + const lookup = packs.specCacheLookup(); + try std.testing.expect(lookup.lookup(@splat(99), .test_dev_code, &.{}) == null); + try std.testing.expectEqual(@as(u64, 0), packs.index_stats.payload_files); + 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, 1), packs.index_stats.payload_files); + // 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, @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); + 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); + + try store.writeIndexed(.local, identity, &set, &.{spec}); + var corrupt = LoadedPacks.init(a); + defer corrupt.deinit(); + try store.loadIdentity(io, &corrupt, .local, identity); + try corrupt.indexPacks(); + try store.native.write(payload_id, "interrupted payload", store.native.now()); + 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, &set, &.{spec}); + 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; @@ -1491,10 +2647,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() }} }, @@ -1504,7 +2660,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) }); @@ -1544,7 +2700,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 @@ -1583,23 +2739,1099 @@ 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 // 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); } + +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()) (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); + defer allocator.free(root); + self.store = try Store.init(allocator, .{ .cache_dir = root, .roc_ctx = CoreCtx.os(allocator, self.arena.allocator(), std.testing.io) }, .x64glibc, "dev"); + } + + fn deinit(self: *@This()) void { + self.store.deinit(); + self.arena.deinit(); + self.tmp.cleanup(); + } + + 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); + try packs.indexPacks(); + return packs; + } + + fn excerpt(id: u8, code: []const u8) backend.dev.CodeExcerpt.Excerpt { + 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), .excerpt = 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.CodeExcerpt.Excerpt, specs: []const PackFile.SpecEntry, files: []const [32]u8) (Allocator.Error || error{PackWriteFailed})!void { + try self.store.writeIndexed(.local, identity, &.{ .arena = undefined, .excerpts = artifacts, .source_files = files }, specs); + } + + 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; + defer record.deinit(); + return allocator.dupe(u8, record.path); + } +}; + +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(); + defer fixture.deinit(); + const F = IndexFixture; + const module: [32]u8 = @splat(17); + var root = F.excerpt(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.excerpt(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.excerpt(4, &unused_code); + unused.loc = .{ .file = 1, .line = 99, .column = 1 }; + const artifacts = [_]backend.dev.CodeExcerpt.Excerpt{ root, callee, F.excerpt(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.excerpts.get(spec.key).?; + try std.testing.expectEqual(@as(usize, 3), located.set.excerpts.len); + try std.testing.expectEqualStrings("constant bytes", located.set.excerpts[located.index].data[0].bytes); + try std.testing.expectEqual(@as(u32, 4), located.set.excerpts[located.index].lines[0].loc.line); + const dependency = located.set.excerpts[located.set.excerpts[located.index].refs[0].target]; + try std.testing.expectEqualStrings(dependency.data[0].name, located.set.excerpts[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}).?; + 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.excerpts.get(spec.key).?.set); + + var large: [16 * 1024]u8 = @splat(7); + for (2..130) |version| { + large[0] = @intCast(version); + const unrelated = F.excerpt(@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.excerpt(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; + const selected = packs.excerpts.get(spec.key).?; + try std.testing.expectEqual(files[version], selected.set.source_files[selected.set.excerpts[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); + 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); + } +} + +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.excerpt(1, "root"); + root.refs = &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}; + const dep = F.excerpt(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.excerpts.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; + // 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.excerpts.get(spec.key).?; + try std.testing.expectEqualStrings("dependency", located.set.excerpts[located.set.excerpts[located.index].refs[0].target].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.excerpt(1, "root"); + root.symbolic_refs = &.{.{ .site = 0, .form = .call, .target = .{ .proc = lir.ProcIdentity.forTest(2) } }}; + var callee = F.excerpt(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.excerpt(1, "root"); + root.refs = &.{.{ .site = 0, .form = .call, .target = 1, .delta = 0 }}; + const dep = F.excerpt(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(); + // 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, @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); + 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, .excerpts = &.{F.excerpt(self.key, "code")} }, &.{F.spec(self.key, 0)}) catch |err| switch (err) { + error.PackWriteFailed => continue, // Optional publication can lose catalog contention. + else => { + 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. + // 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); + } + } + } + try std.testing.expect(!a.failed and !b.failed); + for ([_]u8{ 1, 2 }) |key| try fixture.publish(&.{F.excerpt(key, "code")}, &.{F.spec(key, 0)}, &.{}); + 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); + } + try std.testing.expectEqual(@as(u64, 1), interrupted.index_stats.payload_opens); +} + +test "indexed object cache rejects corrupt offers and shared metadata before reading payloads" { + const F = IndexFixture; + for ([_]bool{ false, true }) |shared_metadata| { + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + const artifacts = [_]backend.dev.CodeExcerpt.Excerpt{F.excerpt(1, "code")}; + const specs = [_]PackFile.SpecEntry{F.spec(1, 0)}; + try fixture.publish(&artifacts, &specs, &.{}); + const group = fixture.store.indexedIdentity(.local, if (shared_metadata) @splat(0) else @splat(1), @splat(0), if (shared_metadata) .metadata else .offer); + const keys = try fixture.store.native.groupKeys(F.allocator, group); + defer F.allocator.free(keys); + var record = (try fixture.store.native.acquireKey(keys[0], fixture.store.native.now())).?; + defer record.deinit(); + const corrupted = try record.read(F.allocator); + defer F.allocator.free(corrupted); + corrupted[0] ^= 0xff; + try std.Io.Dir.cwd().writeFile(std.testing.io, .{ .sub_path = record.path, .data = corrupted }); + 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" { + 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.excerpt(2, "callee"); + for (0..2) |i| { + const swapped = (i == 1) != reverse; + var caller = F.excerpt(1, "caller"); + const refs = [_]backend.dev.CodeExcerpt.Reference{.{ .site = 0, .form = .call, .target = if (swapped) 0 else 1, .delta = 0 }}; + caller.refs = &refs; + const artifacts = if (swapped) [_]backend.dev.CodeExcerpt.Excerpt{ callee, caller } else [_]backend.dev.CodeExcerpt.Excerpt{ 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.excerpts.get(@splat(1)).?; + if (selected) |expected| try std.testing.expectEqual(expected, located.index) else selected = located.index; + try std.testing.expectEqualStrings("caller", located.set.excerpts[located.index].code); + const ref = located.set.excerpts[located.index].refs[0]; + try std.testing.expectEqualStrings("callee", located.set.excerpts[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.excerpt(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); +} + +test "unadvertised object namespaces skip bucket probes 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, 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, 1), packs.index_stats.bucket_opens); +} + +test "indexed object cache shares payloads and verifies immutable records before skipping writes" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var first = F.excerpt(1, "caller"); + var second = F.excerpt(2, "caller"); + first.refs = &.{.{ .site = 0, .form = .call, .target = 2, .delta = 0 }}; + second.refs = first.refs; + 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, @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, &.{}); + var after = (try fixture.store.native.acquireKey(keys[0], fixture.store.native.now())).?; + defer after.deinit(); + try std.testing.expectEqualStrings(before.path, after.path); + 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, 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); + const left = packs.excerpts.get(@splat(1)).?; + const right = packs.excerpts.get(@splat(2)).?; + try std.testing.expectEqual(left.set.excerpts[left.index].payload_record, right.set.excerpts[right.index].payload_record); +} + +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, .excerpts = &.{F.excerpt(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.excerpts.get(@splat(1)).?.set.excerpts[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).?.records.get(first).?.dirty); + try LoadedPacks.recordInterface(&producer, family, first, "one"); + 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; + 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); + 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)); + 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); + 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); +} + +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.CodeExcerpt.Excerpt = undefined; + var specs: [count]PackFile.SpecEntry = undefined; + const ref = backend.dev.CodeExcerpt.Reference{ .site = 0, .form = .call, .target = count, .delta = 0 }; + for (artifacts[0..count], &specs, 0..) |*artifact, *spec, i| { + artifact.* = F.excerpt(@intCast(i + 1), "unchanged caller"); + artifact.refs = &.{ref}; + spec.* = F.spec(@intCast(i + 1), @intCast(i)); + } + artifacts[count] = F.excerpt(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.CodeExcerpt.combineDistinct(a, &.{loaded.excerpts.get(specs[0].key).?.set}); + defer combined.deinit(); + var closure = try SpliceClosure.init(a, &combined); + defer closure.deinit(); + 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); + 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.excerpts)[specs[0].excerpt]; + 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.excerpts.get(specs[0].key).?; + try std.testing.expectEqualStrings("edited caller", edited.set.excerpts[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.excerpt(1, "one"); + const b = F.excerpt(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.excerpt(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.excerpts.get(spec.key).?; + var copy = try backend.dev.CodeExcerpt.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; + 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 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); +} + +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) 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(); + var writer = fresh.store; + 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) 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) 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); + } + }; + // 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(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(deterministic.allocator(), Check.copy, .{loaded.excerpts.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.CodeExcerpt; + const count = 128; + var artifacts: [count]PA.Excerpt = 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.excerpt(@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, .excerpts = &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.CodeExcerpt; + 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.Excerpt = undefined; + var refs: [count]PA.Reference = undefined; + var specs: [callers]PackFile.SpecEntry = undefined; + for (&artifacts, &refs, 0..) |*artifact, *ref, i| { + artifact.* = F.excerpt(@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.excerpts.get(specs[0].key).?.set; + for (specs) |spec| try std.testing.expectEqual(set, loaded.excerpts.get(spec.key).?.set); + try std.testing.expectEqual(@as(usize, count), set.excerpts.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.excerpt = loaded.excerpts.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.excerpts)[specs[0].excerpt]; + const previous = root.refs[0].target; + const next = owned.excerpts[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.excerpts.get(specs[0].key).?; + const dependency = hit.set.excerpts[hit.set.excerpts[hit.index].refs[0].target]; + try std.testing.expectEqual(owned.excerpts[next].kind.proc, dependency.kind.proc); +} + +test "native publication retains catalog generations through batch completion" { + const F = IndexFixture; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + 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 interruption at each catalog boundary exposes only complete offers" { + const F = IndexFixture; + 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), + }; + _ = 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(); + defer packs.deinit(); + const hit = packs.specCacheLookup().lookup(specs[0].key, .dev_code, &.{}); + try std.testing.expectEqual(index == 2, hit != null); + } +} + +test "native publication preserves symbolic bindings across historical definitions" { + const F = IndexFixture; + const PA = backend.dev.CodeExcerpt; + var fixture: F = undefined; + try fixture.init(); + defer fixture.deinit(); + var first = F.excerpt(1, "first caller"); + var second = F.excerpt(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.excerpt(2, "old definition") }, &.{F.spec(1, 0)}, &.{}); + try fixture.publish(&.{ second, F.excerpt(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.excerpts.get(@splat(1)).?; + const new = loaded.excerpts.get(@splat(3)).?; + try std.testing.expectEqual(old.set, new.set); + const old_target = old.set.symbolicDefinition(old.set.excerpts[old.index].symbolic_refs[0]).?; + const new_target = new.set.symbolicDefinition(new.set.excerpts[new.index].symbolic_refs[0]).?; + try std.testing.expectEqualStrings("old definition", old.set.excerpts[old_target].code); + try std.testing.expectEqualStrings("new definition", new.set.excerpts[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.excerpts[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); +} + +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) 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(); + 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()) error{ OutOfMemory, TestExpectedEqual, TestUnexpectedResult }!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); +} + +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); +} diff --git a/src/cli/progress.zig b/src/cli/progress.zig index 1fd409b4df2..db144b677b2 100644 --- a/src/cli/progress.zig +++ b/src/cli/progress.zig @@ -53,9 +53,10 @@ const max_phases: usize = 16; const max_subphases: usize = 25; // Shared lowering and a runtime continuation each report nine lowering groups // and native emission. Also reserve compile-time roots, test result admission, -// and dev test emission. -const max_counter_groups: usize = 2 * 10 + 3; -const max_counters_per_group: usize = 32; +// dev test emission, completed compilation, completed test command, +// native test artifact cache, and individual test outcome admission. +const max_counter_groups: usize = 2 * 10 + 7; +const max_counters_per_group: usize = 42; /// 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 @@ -925,7 +926,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(.{ @@ -939,14 +940,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); } @@ -1017,12 +1018,16 @@ test "counter capacity includes shared runtime native and test groups" { for (0..20) |_| reporter.recordCounters("Compiler work", &.{.{ .name = "Count", .count = 1 }}); reporter.recordCounters("Compile-time roots", &.{.{ .name = "Evaluated", .count = 2 }}); reporter.recordCounters("Final test cache", &.{.{ .name = "Hits", .count = 7 }}); + reporter.recordCounters("Completed compilation", &.{.{ .name = "Hits", .count = 1 }}); reporter.recordCounters("Dev test emission", &.{.{ .name = "Procedures emitted", .count = 3 }}); + reporter.recordCounters("Native test artifact cache", &.{.{ .name = "Hits", .count = 1 }}); reporter.finish(); try testing.expectEqual(@as(usize, 20), 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); try testing.expect(std.mem.find(u8, buf.written(), "Compile-time roots") != null); try testing.expect(std.mem.find(u8, buf.written(), "Dev test emission") != 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 656eafbdfc3..62b9d03a720 100644 --- a/src/cli/test/parallel_cli_runner.zig +++ b/src/cli/test/parallel_cli_runner.zig @@ -20,6 +20,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; @@ -44,7 +45,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 || @@ -465,10 +466,11 @@ 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, + persistent_interface_cache, check_compile_time_pack, discovery_summaries, ctfe_cache_publication, @@ -479,6 +481,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, @@ -527,6 +530,8 @@ const CustomCase = enum { build_glibc_target_non_linux_error, build_windows_shared_library, uncached_test_subset, + completed_test_command, + native_test_artifact_reuse, cache_passing_results, cache_failing_results, cache_invalidated_by_source_change, @@ -595,6 +600,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, }; @@ -614,12 +620,14 @@ 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{ .issue_12569_specialized_callables, + .completed_test_command, .package_dev_packs, - .native_build_artifact_round_trip, + .native_build_excerpt_round_trip, .native_build_pack_objects, .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, @@ -3033,7 +3041,9 @@ 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 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" }} } } }, @@ -3247,9 +3257,10 @@ 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 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 } }, @@ -3265,6 +3276,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 } }, @@ -3672,6 +3684,7 @@ const subcommand_cases = [_]CliCase{ .{ .id = 0, .suite = .subcommands, .name = "issue 12590: cached test roots do not enter shared compilation", .timeout_ms = 600_000, .body = .{ .custom = .uncached_test_subset } }, .{ .id = 0, .suite = .subcommands, .name = "issue 12590: interpreter cached subsets preserve transcripts", .backend = .interpreter, .body = .{ .custom = .uncached_test_subset } }, .{ .id = 0, .suite = .subcommands, .name = "issue 12590: optimized cached subsets preserve transcripts", .backend = .speed, .body = .{ .custom = .uncached_test_subset } }, + .{ .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 } }, @@ -5249,9 +5262,10 @@ 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_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), @@ -5266,6 +5280,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), .issue_12569_specialized_callables => customSpecializedCallables(io, allocator, &env, &timer, timeout_ms), .issue_12569_wasm_callables => customSpecializedWasmCallables(io, allocator, &env, &timer, timeout_ms), @@ -5323,6 +5338,8 @@ fn runCustomCase( .build_glibc_target_non_linux_error => customGlibcTargetNonLinux(io, allocator, &env, &timer, timeout_ms), .build_windows_shared_library => customWindowsSharedLibrary(io, allocator, &env, &timer, timeout_ms), .uncached_test_subset => customUncachedTestSubset(io, allocator, &env, &timer, timeout_ms, spec.backend orelse .dev), + .completed_test_command => customCompletedTestCommand(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), @@ -5391,6 +5408,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), }; @@ -7574,6 +7592,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, @@ -8164,33 +8190,68 @@ 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, 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 { 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 => {}, + .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); 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; @@ -8731,9 +8792,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 @@ -8750,6 +8811,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; @@ -8761,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; @@ -8910,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}); @@ -9346,12 +9413,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, @@ -9364,11 +9431,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}); @@ -9386,8 +9453,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, @@ -9403,8 +9470,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" }; @@ -9548,7 +9615,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; @@ -9556,6 +9623,117 @@ 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/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", .{ + .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. + 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; + // 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| + 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"); + } + 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; +} + /// 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( @@ -9570,23 +9748,38 @@ 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}); + // 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", - .{source}, + .{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}); 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, .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; @@ -9846,8 +10039,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", .{}); @@ -9857,26 +10049,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: @@ -9890,26 +10068,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. @@ -10073,6 +10246,101 @@ 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; + 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| + 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( @@ -10230,13 +10498,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, @@ -11012,6 +11282,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. @@ -11030,8 +11310,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| @@ -11046,11 +11328,14 @@ 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" }, .{ .source = revised, .specializes = true, .stdout = "55 155\n" }, .{ .source = revised, .specializes = false, .stdout = "55 155\n" }, + .{ .source = source, .specializes = false, .stdout = "45 155\n" }, }; var cold_elf: ?[]const u8 = null; for (steps, 0..) |step, index| { @@ -11067,9 +11352,34 @@ 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 (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 (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 (!step.specializes) { const written = timingCounter(run.stderr, "Link preparation", "Object bytes written") orelse @@ -11092,6 +11402,166 @@ fn customRecordedProgramOutput( .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}); + // The completed result remains usable after its checked IR was evicted. + { + const entries = indexedCacheEntries(io, allocator, cache) catch |err| return customInfraFailure(allocator, timer, "inspect checked IR: {}", .{err}); + defer allocator.free(entries); + for (entries) |entry| if (entry.family == .checked and entry.state == .published) { + retireTestEntry(io, allocator, cache, entry) 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| { + 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, "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. + 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, "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; + } + // 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 store = artifact_store.Store.init(allocator, io, cache) catch |err| return customInfraFailure(allocator, timer, "open cache: {}", .{err}); + defer store.deinit(); + const entries = indexedCacheEntries(io, allocator, cache) catch |err| return customInfraFailure(allocator, timer, "inspect records: {}", .{err}); + defer allocator.free(entries); + for (entries) |entry| { + if (entry.family != .program_output or entry.state != .published) continue; + var artifact = (store.acquireKey(entry.key, store.now()) catch |err| return customInfraFailure(allocator, timer, "acquire record: {}", .{err})) orelse continue; + defer artifact.deinit(); + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = artifact.path, .data = "ROCCOMP3 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; } @@ -11315,6 +11785,7 @@ fn customCtfeCachePublication( &.{ "test", "check" }, &.{ "check", "test" }, }; + var named_hits: usize = 0; for (sequences, 0..) |commands, sequence| { var traced = CaseEnv{ .dirs = env.dirs, @@ -11351,6 +11822,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, .{ @@ -11364,12 +11837,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"); } } @@ -11478,6 +11955,7 @@ fn customCtfeCachePublication( } } } + if (named_hits == 0) return customFailure(allocator, timer, "no command reused a demanded CTFE helper", .{}); return null; } @@ -11657,10 +12135,15 @@ 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. 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. @@ -11695,7 +12178,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| @@ -11713,6 +12196,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}); @@ -11724,12 +12208,17 @@ 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 }); } - 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; @@ -11778,6 +12267,28 @@ 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"); + } const require_hits = switch (expect.cache_hit_build) { .first_cached => index == @intFromBool(expect.uncached_baseline), .last => last, @@ -11791,7 +12302,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"); } } @@ -12583,50 +13094,25 @@ 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. +/// 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 { - 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(); - - 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; - 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); + const family: artifact_store.Family = if (std.mem.eql(u8, section, "mod")) .checked else .canonicalized; + 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 @@ -12970,7 +13456,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, @@ -12986,6 +13472,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; @@ -12994,11 +13484,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; } @@ -13006,7 +13507,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, @@ -13031,9 +13533,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| { @@ -13399,38 +13900,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 { @@ -13673,6 +14143,393 @@ 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, + }; + // 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; + } + // 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. +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 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; + 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); + if (!std.mem.startsWith(u8, bytes, magic)) continue; + 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; +} + +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) { + // 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; + } + 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}); @@ -14382,7 +15239,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}); @@ -14401,9 +15267,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"); } } @@ -14420,6 +15287,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(); @@ -14446,9 +15318,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"); @@ -16890,45 +17764,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; @@ -17171,8 +18016,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" }, @@ -17184,7 +18029,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 = &.{ @@ -17196,7 +18041,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" }, @@ -17217,6 +18064,55 @@ 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 = |_| { 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 }, + .{ .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 = "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 }, + }; + 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, @@ -17291,9 +18187,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"}, 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/compile/artifact_store.zig b/src/compile/artifact_store.zig new file mode 100644 index 00000000000..7bc57f7d38f --- /dev/null +++ b/src/compile/artifact_store.zig @@ -0,0 +1,1435 @@ +//! 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"); +const base = @import("base"); +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; + +/// Storage-format namespace shared by all compiler compatibility identities. +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. +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 +/// 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, + .checked, .canonicalized, .placement, .literals, .pairing, .executable, .test_result, .wasm_host, .glue_plugin, .scratch => false, + }; + } + + fn expiresByUse(self: Family) bool { + return self.isPack(); + } +}; + +/// Explicit semantic identity, independent of physical payload placement. +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 = base.Sha256.init(.{}); + hash.update("roc-artifact-group-v2"); + 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; + } +}; + +/// 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; + base.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, + pack_format: u32, + namespace: [32]u8, + generation: u64, + 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], "ROCART02"); + 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); + 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], "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, + .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), + .indexed_used = get(i64, &bytes, 184), + .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.indexed_used)) ^ (@as(u64, 1) << 63), .big); + std.mem.writeInt(u64, key[8..16], slot, .big); + return key; + } +}; + +/// Shared artifact namespace; operations acquire individual shard ownership. +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[0]); + } + + pub fn reserve(self: *const Store, identity: Identity, size: u64, timestamp: i64) Error!Staging { + return self.reserveWith(identity, size, timestamp, true); + } + + 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, + .indexed_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 { + 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.openRequired(self, shardFor(key), false, &counters) catch |err| switch (err) { + error.Missing, error.Busy => return null, + 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); + 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| switch (err) { + error.Missing, error.Busy => { + self.allocator.free(path); + return null; + }, + else => return err, + }; + errdefer lease.close(self.io); + 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); + 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 (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 }; + } + + /// 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.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 currentMetadata(&tx, key, 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); + } + + /// 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_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(); + 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]); + 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; + } + 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 }); + } +}; + +/// 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), + 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) } }; + } + + 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 {}; + for (&self.shards) |*entry| if (entry.*) |*shard| shard.close(); + 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 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; + } + 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; + 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)); + } + + fn flushShard(self: *RetentionBatch, number: u4) Error!void { + const count = self.counts[number]; + if (count == 0) return; + 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 + // 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. +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); + 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 }) + 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, + }; + } +} + +/// A catalog and its held kernel coordination lock. +pub const Shard = struct { + io: Io, + dir: Dir, + lock: Io.File, + 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); + 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); + // 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, + }; + 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; + } +}; + +/// Registered unpublished payload whose exclusive lease pins its slot. +pub const Staging = struct { + store: *const Store, + key: catalog.Key, + slot: u64, + 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 { + if (!self.published) 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.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; + 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 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; + 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(); + self.lease.close(self.store.io); + self.published = true; + } +}; + +/// Acquired payload file and shared lease, both owned until deinit. +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.findScalarLast(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; +} + +/// 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); + 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; +} + +/// 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) { + 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(); + // 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); + 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)); +} + +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); + } +} + +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])); +} diff --git a/src/compile/cache_cleanup.zig b/src/compile/cache_cleanup.zig index 44a85f91608..2fd089b8c85 100644 --- a/src/compile/cache_cleanup.zig +++ b/src/compile/cache_cleanup.zig @@ -1,977 +1,109 @@ -//! 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; - - // A link's hard links are its lifetime pins. The owner holds - // this lock until LLD and output finalization finish. A crashed - // owner's lock is released by the OS and ages out normally. - var candidate = scratch_dir.openDir(std_io, entry.name, .{}) catch continue; - defer candidate.close(std_io); - const lease: ?std.Io.File = candidate.openFile(std_io, "link.lock", .{ .lock = .exclusive, .lock_nonblocking = true }) catch |err| switch (err) { - error.FileNotFound => null, - else => continue, - }; - // Close before deletion for Windows. Having acquired the lock - // proves the original invocation has finished; this unique - // scratch directory is never reused by another invocation. - if (lease) |file| file.close(std_io); - 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 active: std.atomic.Value(?*Session) = .init(null); - 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; +/// Command-owned maintenance admission state, registered at coordinator safe points. +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, - 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; + 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 }; } -} -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); + pub fn start(self: *Session) void { + active.store(self, .release); + self.tick(.serial); } -} -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); - } + pub fn deinit(self: *Session) void { + active.store(null, .release); + self.tick(.serial); + self.store.deinit(); } -} - -/// 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); - } + pub fn tick(self: *Session, demand: Demand) void { + var stats = maintenance.Stats{}; + self.tickAt(self.store.now(), demand, &stats); } - // 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 {}; -} - -/// 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); - - 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; - } -} - -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" }); - } - 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); - } - 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); - } - - // 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); - }; -} - -test "cleanupPersistentCache deletes old cache files at each family depth" { - 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); - }; - 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", .{})); +/// 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 "size bound evicts least recently used files across families until under the limit" { +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" }); - 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", .{}); -} - -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", .{})); + 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 }, + }; + 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..7ff1baae447 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 { @@ -358,7 +344,9 @@ 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. + /// 163: producer-owned declaration slots and stored checking diagnostics. + pub const CACHE_VERSION = 163; }; /// Configuration for the Roc cache system. @@ -444,7 +432,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,23 +440,11 @@ 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 { + /// 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, &[_][]const u8{ version_dir, "literals" }); + return std.fs.path.join(allocator, &.{ version_dir, "inference" }); } /// Exact platform/app composition results, separate from reusable modules. @@ -478,7 +454,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 +483,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); @@ -559,11 +521,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 +562,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 +574,7 @@ pub const CacheStats = struct { switch (kind) { .checked => self.misses += 1, .canonicalized => self.canonicalized_misses += 1, + .inference => self.inference_misses += 1, } } @@ -607,6 +583,7 @@ pub const CacheStats = struct { switch (kind) { .checked => self.invalidations += 1, .canonicalized => self.canonicalized_invalidations += 1, + .inference => self.inference_invalidations += 1, } } @@ -621,6 +598,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 +610,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_index.zig b/src/compile/cache_index.zig new file mode 100644 index 00000000000..861976789bf --- /dev/null +++ b/src/compile/cache_index.zig @@ -0,0 +1,771 @@ +//! 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 +//! 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; + +/// 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. +pub const Counters = struct { + reads: usize = 0, + mapped_reads: usize = 0, + writes: usize = 0, + read_bytes: usize = 0, + 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, + 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; + } +}; + +/// Decoded branch, leaf, or free-list record. +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; + } +}; + +/// 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, + io: Io, + file: Io.File, + 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. + 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; + } + + /// 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(); + 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 (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 { + 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| { + 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]; + } + self.counters.reads += 1; + self.counters.read_bytes += record_size; + 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 { + 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; + 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 { + 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) { + 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]; + } + storage.* = try Node.decode(&try self.readRecord(id), self.header.next); + return storage; + } + + 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; + } +}; + +/// 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, + 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 { + 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; + const slot = id % self.read_ids.len; + 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 { + 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; + } + + /// 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; + 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.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, + .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.nodeRef(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.nodeRef(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 +} + +/// 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); +} + +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); +} + +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); +} + +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); +} + +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); + } + // 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/cache_maintenance.zig b/src/compile/cache_maintenance.zig new file mode 100644 index 00000000000..5d97ab0b8b7 --- /dev/null +++ b/src/compile/cache_maintenance.zig @@ -0,0 +1,633 @@ +//! 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; + +/// 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, + metadata: usize = 0, + deletions: usize = 0, + errors: usize = 0, + 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; + pub const grant_interval: i64 = std.time.ns_per_s; + pub const ordinary_units: usize = 1; + 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, + 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| { + 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) { + .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; + switch (err) { + error.Missing => checkpoint.bytes[number] = 0, + error.Busy => {}, + else => 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.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); + 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() (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); + 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 "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(); + 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); + 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 }; + 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..779b12fa6ca 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,251 @@ 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, .inference => .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| { + 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; }; - - // 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| { + 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; }; - 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); + switch (err) { + error.Busy => {}, + else => 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/cache_versioning.zig b/src/compile/cache_versioning.zig index 159b0165bdc..9a1c947135f 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, + 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/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/compile_build.zig b/src/compile/compile_build.zig index 2dab602d6a3..dba4523aebc 100644 --- a/src/compile/compile_build.zig +++ b/src/compile/compile_build.zig @@ -20,12 +20,14 @@ 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"); -/// 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 @@ -237,6 +239,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`. @@ -299,6 +302,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 @@ -522,8 +532,156 @@ 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 => {}, + }; 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); @@ -685,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; @@ -705,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 { @@ -905,6 +1089,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(); @@ -939,11 +1124,12 @@ 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 { + if (self.recorded_completion != null) return true; 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 { @@ -1345,9 +1531,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(); @@ -2147,7 +2333,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; @@ -2516,6 +2702,14 @@ 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, + 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 @@ -2580,6 +2774,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); @@ -2615,6 +2824,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(); @@ -3436,6 +3688,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 {}; } @@ -4466,3 +4719,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/compile_package.zig b/src/compile/compile_package.zig index c6fbe5d56fe..1e45eb4b479 100644 --- a/src/compile/compile_package.zig +++ b/src/compile/compile_package.zig @@ -174,6 +174,10 @@ pub const TypeCheckOutput = struct { checker: Check, publication: CheckedArtifact.CheckedModuleArtifact, 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); @@ -495,9 +499,11 @@ 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, + inference_cache: ?@import("inference_cache.zig").Cache, ) TypeCheckModuleError!TypeCheckOutput { const builtin_indices = compiled_builtins.builtinIndices(can.CIR); @@ -531,6 +537,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, 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(); + var checker = try Check.initWithOwnerModules( check_alloc, &env.types, @@ -550,7 +569,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, &checking_scope); + } else { + try checker.checkFile(); + if (inference_cache) |cache| cache.store(inference_key, env, &checker, &checking_scope); + } module_envs_map.deinit(); @@ -582,6 +606,10 @@ 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, + .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/completed_compilation.zig b/src/compile/completed_compilation.zig new file mode 100644 index 00000000000..5de848b599b --- /dev/null +++ b/src/compile/completed_compilation.zig @@ -0,0 +1,229 @@ +//! 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; + +/// 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 = "", +}; + +/// 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 { + 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 }; +/// 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, + kind: @import("lir").LIR.LiteralRejectionKind, + 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 = &.{}, + 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(); +} + +/// 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 => {}, + .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| { + 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)); + } + }, + .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, + 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; + }, + .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)), + } + } +}; + +/// 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), + .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| { + 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 618bcc81491..651c1110391 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"); @@ -55,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"); @@ -99,8 +101,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 || 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; @@ -246,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. @@ -700,6 +704,15 @@ 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, + checking_reference_bindings: u32 = 0, + checking_diagnostics_replayed: 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 @@ -765,6 +778,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 before checked-cache lookup: parsing and /// canonicalization produced them, and their own caches reproduce them. /// Checking (including the deferred import drain) and evaluation append @@ -998,6 +1014,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 @@ -1048,6 +1068,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 }); } @@ -1216,6 +1241,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 +1254,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 +1271,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.* = .{}; @@ -1471,9 +1502,15 @@ 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, + debug_observer: ?eval.CompileTimeFinalization.DebugObserver = null, 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 @@ -1588,6 +1625,11 @@ 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, + 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, @@ -1741,6 +1783,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| { @@ -2337,6 +2385,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().?; @@ -2362,23 +2421,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; @@ -2870,6 +3060,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 +3075,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; @@ -3121,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; @@ -3134,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()); } } } @@ -3159,8 +3363,63 @@ 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; var runtime_target: ?lir.CheckedPipeline.TargetConfig = if (self.runtime_lowering) |config| config.target else null; // An explicitly empty test execution subset has no runtime consumer. // Checking still finalizes every pending compile-time obligation. @@ -3193,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`. @@ -4166,7 +4447,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 { @@ -4174,7 +4455,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(); @@ -4643,6 +4924,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; @@ -4991,6 +5273,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)) { @@ -5009,6 +5301,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| { @@ -5348,6 +5641,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), }, }); } @@ -5369,6 +5663,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); } @@ -5475,8 +5774,25 @@ 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; + 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; + mod.inference_runs += 1; + self.checking_interfaces_built += 1; + mod.checking_interfaces_built += 1; + } try self.completeModuleSuccessfully(pkg, result.module_id); } @@ -6048,6 +6364,11 @@ 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, + .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, @@ -6706,9 +7027,11 @@ 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, + 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 @@ -6779,6 +7102,10 @@ 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, + .reference_bindings = typecheck_output.reference_bindings, + .diagnostics_replayed = typecheck_output.diagnostics_replayed, }, }; } @@ -7015,16 +7342,32 @@ 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, + has_errors: bool, 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, + reference_bindings: u32, + diagnostics_replayed: u32, +}; + fn lessThanBytes(_: void, a: [32]u8, b: [32]u8) bool { return std.mem.order(u8, &a, &b) == .lt; } @@ -7049,6 +7392,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, .{ @@ -7060,8 +7413,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, @@ -7086,10 +7439,16 @@ fn compileAppFactsWithPinVersion( 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, @@ -7110,6 +7469,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| { @@ -7123,16 +7489,43 @@ 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, + .reference_bindings = mod.checking_reference_bindings, + .diagnostics_replayed = mod.checking_diagnostics_replayed, + }); } } 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), + .has_errors = coord.hasUserErrors(), .rejected_modules = rejected_modules, .failed_modules = failed_modules, }; @@ -7683,7 +8076,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); @@ -7802,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); @@ -7829,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)]; } @@ -7915,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; @@ -7958,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), }; } @@ -8216,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 }); @@ -8250,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(.{}); @@ -9938,46 +10400,57 @@ 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); + } + const filesystem = CoreCtx.os(allocator, allocator, std.testing.io); + for (paths) |path| try filesystem.writeFile(path, 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); + const filesystem = CoreCtx.os(allocator, allocator, std.testing.io); + for (paths) |path| { + const contents = try filesystem.readFile(path, allocator); defer allocator.free(contents); if (contents.len == 0) continue; - try dir.writeFile(io, .{ .sub_path = entry.path, .data = contents[0 .. contents.len - 1] }); + try filesystem.writeFile(path, 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") + @@ -9987,22 +10460,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 filesystem = CoreCtx.os(allocator, allocator, io); + 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 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 = entry.path, .data = bytes }); + try filesystem.writeFile(path, bytes); corrupted += 1; } @@ -10257,13 +10728,20 @@ 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); + // 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, 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"); 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" { @@ -10280,8 +10758,14 @@ 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); + // 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, 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"); try std.testing.expect(second.build.modules_compiled > 0); @@ -10406,15 +10890,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" { @@ -10494,22 +10972,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" { @@ -10592,6 +11061,314 @@ 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, 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", + }); + 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 "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; @@ -10667,7 +11444,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. @@ -10675,7 +11452,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); } @@ -10925,7 +11702,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); @@ -10941,7 +11718,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); @@ -12468,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) 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); + 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) 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); + 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..c41bb66b30a --- /dev/null +++ b/src/compile/discovery_roots.zig @@ -0,0 +1,119 @@ +//! 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"; + +/// 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(.{}); + 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/inference_cache.zig b/src/compile/inference_cache.zig new file mode 100644 index 00000000000..454be16c120 --- /dev/null +++ b/src/compile/inference_cache.zig @@ -0,0 +1,155 @@ +//! 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 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; + defer if (owned) data.deinit(env.gpa); + const bodies = decode(key, data.data()) catch { + 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(); + env.* = cached.*; + owned = false; + return .{ .data = data, .metadata = bodies.metadata, .allocator = env.gpa }; + } + + /// 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, 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, scope); + 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..605a6b3a26c 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 @@ -349,6 +353,11 @@ 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, + reference_bindings: u32 = 0, + diagnostics_replayed: 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 a4528d8c337..335826c02a8 100644 --- a/src/compile/mod.zig +++ b/src/compile/mod.zig @@ -1,10 +1,10 @@ //! 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"); +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; @@ -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,12 +63,15 @@ 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")); 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")); diff --git a/src/compile/test/cache_test.zig b/src/compile/test/cache_test.zig index a10b5106a43..5ba72c4ffe0 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,9 @@ 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); + // 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); @@ -177,8 +110,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 +129,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); @@ -241,9 +173,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; @@ -256,7 +185,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 +195,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 +204,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, _: 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); + self.base.makePath(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 +227,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/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/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( diff --git a/src/compile/test/ctfe_code_identity_test.zig b/src/compile/test/ctfe_code_identity_test.zig index 2feda4b5d4b..fbc71b53be0 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"; @@ -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, @@ -167,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, @@ -176,7 +176,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); @@ -207,10 +207,10 @@ 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); // The same checked/LIR program emits identical normalized procedure packs // for the declared libc pair. Compare every byte of code, relocations, - // carried data, source locations and artifact metadata at each CPU floor. + // carried data, source locations and excerpt metadata at each CPU floor. if (roc_target.RocTarget.detectNative().toCpuArch() == .x86_64) { for ([_][2]roc_target.RocTarget{ .{ .x64musl, .x64glibc }, .{ .x64v1musl, .x64v1glibc } }) |pair| { var expected: ?[]u8 = null; @@ -230,7 +230,7 @@ test "every procedure compile-time evaluation shares with a dev build is the bui target, ); defer result.deinit(); - const bytes = try backend.dev.PackFile.write(allocator, &result.artifacts.?, &.{}); + const bytes = try backend.dev.PackFile.write(allocator, &result.excerpts.?, &.{}); if (expected) |prior| { defer allocator.free(bytes); try std.testing.expectEqualSlices(u8, prior, bytes); @@ -239,24 +239,24 @@ test "every procedure compile-time evaluation shares with a dev build is the bui } } - 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/compile/test/demand_driven_ctfe_test.zig b/src/compile/test/demand_driven_ctfe_test.zig index f587ed4a2ec..ef9a2e2b6ea 100644 --- a/src/compile/test/demand_driven_ctfe_test.zig +++ b/src/compile/test/demand_driven_ctfe_test.zig @@ -307,6 +307,61 @@ 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)), + .nested, + .local_hosted, + .imported_hosted, + .parser_runtime, + .encoder_for_runtime, + => {}, + } + } + } + } + } + // 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/compile/test/released_solver_types_test.zig b/src/compile/test/released_solver_types_test.zig index 69fa2b0f96b..4a357862212 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,50 @@ 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")) { + // 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[store.root.len..], '\\', '/'); + if (!try registeredPending(&store, path, null)) count += 1; + } } return count; } @@ -267,7 +318,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 +328,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 +443,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 +468,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); diff --git a/src/eval/compile_time_finalization.zig b/src/eval/compile_time_finalization.zig index 3adc7bc4a60..9c5fd77d544 100644 --- a/src/eval/compile_time_finalization.zig +++ b/src/eval/compile_time_finalization.zig @@ -52,6 +52,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; @@ -80,6 +87,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 { @@ -96,13 +109,18 @@ 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. 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, + 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 @@ -117,7 +135,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 @@ -397,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); @@ -586,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) { @@ -629,6 +653,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. @@ -758,9 +783,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. @@ -795,18 +820,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 @@ -1100,13 +1125,13 @@ 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. .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 @@ -1226,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); @@ -1272,8 +1297,14 @@ 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.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 @@ -1282,6 +1313,8 @@ 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); + base.dev_diagnostics.countTestWork(.second_specialization, 1); var respecialized = try specializeAndEvaluate( allocator, quiet_modules, @@ -1293,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"); @@ -1310,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); @@ -1330,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, }; } @@ -1394,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. @@ -1401,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; @@ -1479,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); @@ -1501,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; @@ -1520,6 +1557,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"), @@ -1532,6 +1570,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. @@ -1564,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) { @@ -2026,9 +2065,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| { @@ -2636,6 +2675,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 @@ -2665,6 +2706,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(), @@ -2687,6 +2730,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); @@ -2741,6 +2786,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), @@ -4778,7 +4825,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, @@ -4801,7 +4848,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; @@ -4816,14 +4863,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; @@ -4851,7 +4898,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, @@ -5002,7 +5049,7 @@ const DevProgram = struct { .splice = splice, .executable = executable, .entry_offsets = entry_offsets, - .artifacts = artifacts, + .excerpts = artifacts, .capture_prefix = lowered.lir_result.erased_capture_prefix, }; } @@ -5089,9 +5136,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, @@ -5111,7 +5158,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; @@ -5381,6 +5428,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) { @@ -5626,6 +5674,9 @@ 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) { if (options.event_callback) |callback| callback.notify(callback.context, .{ .dbg = message }); } 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/eval/inspected.zig b/src/eval/inspected.zig index b0387fae28b..05dcbcdae33 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.CodeExcerpt.Set) Allocator.Error!void, + } = null, +}; + /// Timings for JIT-compiling and running boolean test roots with the dev backend. pub const DevBoolRootTimingSnapshot = struct { lir_statements: u64 = 0, @@ -421,6 +432,10 @@ 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, + root_codegen_count: u64 = 0, + root_execution_count: u64 = 0, }; /// Mutable timing collector for one dev-backend boolean-root evaluation batch. @@ -487,6 +502,14 @@ pub const BoolRootEvalOutcome = union(enum) { pub const BoolRootEvalResult = struct { outcome: BoolRootEvalOutcome, events: []BoolRootEvent, + /// Sparse source-site observations owned by this invocation. + expect_observations: []ExpectObservation = &.{}, +}; + +/// The uncombined counts contributed by one root to an inline expect site. +pub const ExpectObservation = struct { + site: u32, + counts: ExpectCounts, }; /// Aggregate executions of one dense LIR expect site. @@ -569,6 +592,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. @@ -2302,6 +2326,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; @@ -2337,16 +2367,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( @@ -2361,29 +2401,65 @@ 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: ?boxy_abi.BoxyNativeFnTable = if (store.runtime_contract == .boxy) boxyNativeFnTable() else null; - codegen.boxy_native_fns = if (native_fns) |*table| table else null; + var native_fns: ?BoxyNativeFnTable = if (store.runtime_contract == .boxy) 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()); - if (timing) |timings| { - timings.snapshot_value.lir_statements += store.getCFStmts().len; - // Count resolved native procedures, excluding reserved static - // initializers that compileAllProcSpecs deliberately does not emit. - for (0..store.getProcSpecs().len) |index| { - if (codegen.compiledProcSymbol(@fromBackingInt(@intCast(index))) != null) - timings.snapshot_value.emitted_procedures += 1; - } + 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); - if (timing) |timings| timings.finish(procedure_codegen_started_ns, .procedure_codegen); + 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, + }); + base.dev_diagnostics.countTestWork(.native_test_procedures, metrics.procedures_emitted); + 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 { + base.dev_diagnostics.countTestWork(.native_test_procedures, demand.items.len); + try codegen.compileAllProcSpecs(store.getProcSpecs()); + try codegen.compileStaticDataRcHelpers(static_rc_helpers); + } + if (timing) |timings| { + timings.snapshot_value.lir_statements += store.getCFStmts().len; + for (demand.items) |id| if (codegen.compiledProcSymbol(id) != null) { + timings.snapshot_value.emitted_procedures += 1; + }; + 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); @@ -2398,15 +2474,24 @@ 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; + if (artifacts != null) try splice.generateHostedStubs(&codegen, native_fn_table); try codegen.finishImage(); if (timing) |timings| { timings.snapshot_value.emitted_entrypoints += roots.len; timings.snapshot_value.emitted_code_bytes += codegen.getGeneratedCode().len; } - 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(), @@ -2418,6 +2503,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.CodeExcerpt.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, @@ -2430,7 +2531,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"), }; @@ -2458,7 +2559,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; } } @@ -2483,6 +2589,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; @@ -2649,6 +2756,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].?); } @@ -2760,11 +2885,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; } } 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/glue/glue.zig b/src/glue/glue.zig index 3c316333180..8e52dfe4343 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. @@ -884,24 +861,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] == '\\')) { diff --git a/src/lir/LIR.zig b/src/lir/LIR.zig index a404d14daa9..56f7be0be7b 100644 --- a/src/lir/LIR.zig +++ b/src/lir/LIR.zig @@ -2102,6 +2102,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 @@ -2113,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/arc.zig b/src/lir/arc.zig index 0b17550d771..4e72ddf2d84 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, @@ -18316,3 +18317,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_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/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/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 592f52b5541..b6b31a794b1 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. @@ -164,6 +162,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 +179,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 @@ -213,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. @@ -234,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 @@ -1031,6 +1036,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 +1067,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 +1081,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 +1092,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, }; } @@ -1119,17 +1130,18 @@ 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, 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), fuse_tag_cases: bool, - scalarize_joins: bool, reuse_boxes: bool, thread_confined_rc: bool, layout_request_const_plans: bool, @@ -1446,6 +1458,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( @@ -1515,6 +1529,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, @@ -1523,11 +1538,13 @@ 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, .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, @@ -1553,10 +1570,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, @@ -1566,12 +1594,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)); } @@ -1618,7 +1647,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); @@ -1643,7 +1672,11 @@ 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); + 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(); @@ -1839,6 +1872,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, @@ -1985,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); } @@ -2040,7 +2074,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, @@ -2368,6 +2402,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()); } @@ -2403,7 +2448,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/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/lir_image.zig b/src/lir/lir_image.zig index f4e04312f1a..f8b55baac93 100644 --- a/src/lir/lir_image.zig +++ b/src/lir/lir_image.zig @@ -814,13 +814,13 @@ 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, @@ -829,7 +829,7 @@ pub fn buildCanonicalSidecarBlob( base.dev_diagnostics.traceBoxyWork("sidecar"); 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 @@ -933,7 +933,7 @@ const CanonicalLayouts = struct { } }; -fn serializeCanonicalSidecarInto( +fn serializeArrangedSidecarInto( gpa: std.mem.Allocator, buffer: []align(16) u8, lowered: *const Program.Result, @@ -1756,7 +1756,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/loop_append_promote.zig b/src/lir/loop_append_promote.zig index 2900bb0edbb..7ce418ac649 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,8 +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 self.classifyMetadataTransfers(scan, proc_id); 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.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; + var metadata_classified = false; outer: for (scan.joins.items) |info| { if (!info.has_back_edge) continue; const join = self.store.getCFStmtImmediate(info.stmt).join; @@ -1305,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; } @@ -1333,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, @@ -1449,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; @@ -4970,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. @@ -5027,7 +5083,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; @@ -5420,3 +5478,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); +} 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/pack_program.zig b/src/lir/pack_program.zig index ea1ed4e0767..c2f49aab7f6 100644 --- a/src/lir/pack_program.zig +++ b/src/lir/pack_program.zig @@ -500,73 +500,108 @@ 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); +} + +/// 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, + 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, + + pub fn deinit(self: *OfferFacts) void { + self.arena.deinit(); } - // 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; + pub fn init(allocator: Allocator, store: *const LirStore) Allocator.Error!OfferFacts { + return initWithSlots(allocator, store, &.{}); } - 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 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(); + 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); + 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); + 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; + observed[index] = proc.body == null; + 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.* == .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); + 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, 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| { + 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, .reaches_observation = observed, .reads_evaluation_value = evaluation_reads, .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 { @@ -696,6 +731,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; @@ -761,3 +855,117 @@ 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]); + } +} + +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(1) }, + .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/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/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, 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 be9c6d85e40..8ff8e850fd3 100644 --- a/src/postcheck/common.zig +++ b/src/postcheck/common.zig @@ -454,9 +454,15 @@ 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, + /// 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, + /// 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 @@ -496,6 +502,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. @@ -510,7 +518,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, }; } @@ -521,7 +529,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, }; } @@ -568,10 +576,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/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..340d70ffc9b --- /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 commit 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.representative(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.representative(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.representative(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/analysis_cache.zig b/src/postcheck/lambda_solved/analysis_cache.zig new file mode 100644 index 00000000000..1dde003b8d0 --- /dev/null +++ b/src/postcheck/lambda_solved/analysis_cache.zig @@ -0,0 +1,980 @@ +//! 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" }; + +/// 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 module. 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); + } +}; + +/// 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(); + 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.v3"); + inline for (columns) |field| { + out.value(@TypeOf(@field(input, field)), @field(input, 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); + } + 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. +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() }); + } + 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); + 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, + 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, + 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 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 { + 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)) { + 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 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); + return self.value(u32, entry.value_ptr.*); + }; + if (T == names.ModuleIdentityId) { + 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) { + 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); + } + 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; // the loop above matches the active tag, which is one of the union's fields + }, + .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 => {}, + .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 lowered 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, + }; +} + +/// 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; // 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: { + 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; + }, + .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, + }; + } + + 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, + key: [32]u8, + 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, 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.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 defaultDeclaringModule(self: *const Context, module: [32]u8, expr: u32) ?[32]u8 { + // Analysis only carries a current input default reference; it never + // 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 { + return self.modules.contains(module); + } + 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 != .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); + } + 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 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(); + var scratch = std.heap.ArenaAllocator.init(allocator); + defer scratch.deinit(); + 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, + }; +} + +/// 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, id: *const Identity, 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(), id); + defer context.deinit(); + 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, + }; + // 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); + @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) 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(3), .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 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(); + 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()); + try std.testing.expectEqual(@as(u32, 88), restored.lifted.current_loc.line); + 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" { + const gpa = std.testing.allocator; + var first = try @import("solve.zig").run(gpa, try literalInput(gpa, "before")); + defer first.deinit(); + 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(); + 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) 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()); + 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, &id, 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) Allocator.Error!Lifted.Program { + return recursiveInputWithPadding(allocator, false); +} +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(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 }); + 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 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, &id, bytes)).?; + owned = false; + defer restored.deinit(); + 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); + // 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(); + 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, &id, 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_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(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); + input.setExprData(closure, .{ .fn_ref = .{ .fn_id = second, .captures = .empty() } }); + 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 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, &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, &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) Allocator.Error!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(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 } }); + _ = 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)); +} + +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); + 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(2), .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); + } + } +} 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 9ffdd485630..fa3cac8e5d3 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"); @@ -48,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, @@ -78,6 +80,38 @@ 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); /// The pairs a unification walk has begun and not yet retired. A pair begun @@ -183,24 +217,124 @@ 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, + 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, + 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. + compatibility_cycles: u64 = 0, + compatibility_hits: u64 = 0, + compatibility_invalidations: u64 = 0, + compatibility_peak_entries: 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, + lambda_copies: u64 = 0, + /// Members offered to final ordering, including already ordered spans. + ordered_members: u64 = 0, +}; + /// Solve lambda-set relationships in a lifted Monotype program. 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 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. var program = Ast.Program.init(allocator, lifted); errdefer program.deinit(); + 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, &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. + 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, &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 }); + }; + 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; } +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, + 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 = .{}, + 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 = .{}, + compatibility_dependencies: std.ArrayList(CompatibilityProofs.Dependency) = .empty, + compatibility_entries: std.ArrayList(CompatibilityProofs.Entry) = .empty, + allocator: Allocator, program: *Ast.Program, lifted: Lifted.ProgramView, @@ -258,7 +392,8 @@ 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), + 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), @@ -334,6 +469,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 +608,17 @@ 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); + 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); + 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(); @@ -499,6 +651,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(); @@ -547,6 +702,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 +810,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(.ordered_members, members.count()); if (members.count() < 2) return null; const types = &self.program.types; const ascending = for (1..members.count()) |index| { @@ -816,6 +973,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"); @@ -1752,9 +1910,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 @@ -1772,6 +1947,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) @@ -1845,6 +2025,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(); @@ -1963,7 +2144,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; } @@ -2191,24 +2372,60 @@ 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(); + 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_proofs.get(UnifyPair.init(root_a, root_b))) |proven| { + self.countWork(.compatibility_hits, 1); + 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 committable = true; + 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); 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; - if ((try visited.getOrPut(UnifyPair.init(left, right))).found_existing) 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 commit a structural certificate. + committable = false; + continue; + } + if (try self.canLinkUntouchedLeafToInstance(right, raw_right, pair.first, left)) { + committable = false; + continue; + } + const key = UnifyPair.init(left, right); + if ((try visited.getOrPut(self.allocator, key)).found_existing) { + self.countWork(.compatibility_cycles, 1); + continue; + } + 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 (proven.value == 0) return self.rejectCompatibility(UnifyPair.init(root_a, root_b), epoch, committable); + 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); @@ -2224,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 false; + 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 false, + .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 false; + 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 false; + 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 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, 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 false; + 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) }); } @@ -2264,16 +2481,56 @@ 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, 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) }); } }, } } + // All visited pairs now hold, including mutually recursive ones. + // A failed or graph-mutating walk never retains its assumptions. + 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.proven.contains(key.*)) try self.compatibility_entries.append(self.allocator, .{ .key = key.*, .value = 1 }); + } + try self.compatibility_proofs.commit(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_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 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; + } + + fn invalidateCompatibilityProofs(self: *Solver) void { + 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 { const content = try self.resolvedContent(ty); if (std.meta.activeTag(content) != .named) return null; @@ -2563,6 +2820,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 +2859,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 @@ -2642,6 +2903,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| { @@ -2657,10 +2925,162 @@ 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.commit(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 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; + } + + /// 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; @@ -2670,7 +3090,8 @@ const Solver = struct { while (pending.pop()) |id| { if (seen.contains(id)) continue; try seen.put(id, {}); - if (id != root and self.arg_scopes.items[scope].contains(id)) return true; + self.countWork(.arg_scope_walk_nodes, 1); + if (self.leafSharing(id) != .positional) continue; switch (types.get(id)) { .named, .primitive, .zst, .erased, .func, .hole => {}, @@ -2685,7 +3106,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( @@ -3687,6 +4119,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 +4188,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 +4212,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 +5072,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 +5345,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 +5481,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 +5542,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 +5609,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 +6064,205 @@ 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 }); + // 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 }) }); + 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_entries); + } + } +} + +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)); + 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(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, 3), counts.compatibility_invalidations); +} + +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(); + 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, 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.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); +} + +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 }); + 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_proofs.proven.count()); + try std.testing.expect(try solver.typesCompatible(lazy, materialized)); + try std.testing.expect(solver.compatibility_proofs.proven.count() > 0); +} + +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(); + var solver = try Solver.init(allocator, &program); + defer solver.deinit(); + const types = &program.types; + const number = try types.add(.{ .primitive = .u64 }); + // 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(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); + 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 } }); + 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); @@ -5956,3 +6595,326 @@ 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.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)); + } + 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.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.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)); + 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)); +} + +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)); + 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" { + 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()); +} + +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); +} diff --git a/src/postcheck/lambda_solved/type.zig b/src/postcheck/lambda_solved/type.zig index f2b7c53bfcb..c4f7a6d8cab 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 proofChanges(self: *Store) *@import("../proofs.zig").Changes(TypeVarId) { + return &self.analysis_changes; + } + 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/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..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 @@ -619,6 +625,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 +873,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..160decc660a --- /dev/null +++ b/src/postcheck/monotype/interface_summary.zig @@ -0,0 +1,1549 @@ +//! 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"); +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 = "RIFSUM04"; +const frozen_magic = "RIFRZ001"; + +/// 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 }, + 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. +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 }; +/// 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}; + +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; +} + +/// 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; +} + +/// 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); +} + +/// 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(); + 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 }, + .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); +} + +/// 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 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); +} + +/// 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 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 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| { + 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 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 invalidSummary(@src().line), + .constraints => |c| { + 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 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); + } + if (!try placeholdersAreFieldKindSlots(scratch.allocator(), c)) return invalidSummary(@src().line); + }, + } + 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; +} + +/// 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); + // 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); + 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); + 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; // 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: { + 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, + .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)), + }; +} + +fn relocateFrozen(comptime T: type, value: anytype, 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 => {}, + .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)), + } +} + +// 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, .float, .bool, .void => {}, + .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"), + } + } +}; + +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(@TypeOf(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 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. + 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 invalidSummary(@src().line); + } + 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`. +pub fn requestKey(request: Constraints.Identity, evidence: [32]u8, procedure: [8]u8) [32]u8 { + var hash = base.Sha256.init(.{}); + hash.update("roc.interface-request.v3"); + 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; +} + +fn Writer(comptime Context: type) type { + return struct { + const Self = @This(); + context: Context, + stats: Stats = .{}, + 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, + allocator: Allocator, + types: *Type.Store, + 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); + @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)); + } + } + } + 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; + } + } + } + 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 { + 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; + return self.write(u32, try self.reference(value)); + } + if (T == Solve.InstNode) { + // 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()); + 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).*; + 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, declaring_module); + 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); + 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"), + }, + .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(@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, .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, + names: *names.NameStore, + leaves: []const Type.TypeId, + bytes: []const u8, + context: Context, + 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); + 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 invalidSummary(@src().line); + return @fromBackingInt(index); + } + if (T == Type.MaybeTypeId) return .of(try self.read(?Type.TypeId)); + 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) 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); + const declaring_module = try self.read([32]u8); + const expr = try self.read(u32); + 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) { + 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 invalidSummary(@src().line); + 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 invalidSummary(@src().line); + }, + .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 invalidSummary(@src().line); + const values = try self.arena.alloc(info.child, len); + 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: { + 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 invalidSummary(@src().line); + } + break :blk @fromBackingInt(value); + }, + .bool => switch (try self.read(u8)) { + 0 => false, + 1 => true, + 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 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..]; + break :blk @bitCast(value); + }, + .float => @bitCast(try self.read(@Int(.unsigned, @bitSizeOf(T)))), + .void => {}, + .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) { + 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 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 invalidSummary(@src().line); + }, + .primitive, .named, .tuple, .list, .box, .func, .erased, .zst, .hole => {}, + }; + return result; + } + }; +} + +const TestProvenance = struct { + declaring_module: [32]u8 = @splat(7), + available: bool = true, + closed_key: ?[32]u8 = null, + occurrence_version: u8 = 0, + 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.declaring_module; + } + 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) { + .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; + 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; + } + 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) error{ NotRepresentable, OutOfMemory }![]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) error{ InvalidSummary, NotRepresentable, OutOfMemory, TestExpectedEqual, TestUnexpectedResult }!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); + 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); + // 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(function_slot)}, + .nodes = &.{ .{ .open = 0 }, .{ .mono = source_ty } }, + .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, TestProvenance{}); + defer allocator.free(bytes); + 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], 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[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" { + 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(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(alias_slot)}, + .nodes = &.{.{ .mono = alias }}, + .open_nodes = &.{}, + .kinds = &.{}, + } }; + 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" { + 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(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(3) }, + .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(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; + 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 = &.{comptime testNodeRoot(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{ .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; + 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 = &.{ 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 }}, + } }; + 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) error{ InvalidSummary, NotRepresentable, OutOfMemory, TestExpectedEqual, TestUnexpectedResult }!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); +} + +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 = &.{comptime testNodeRoot(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 9162c9d321c..ab0a3fb0a75 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"); @@ -293,6 +294,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 @@ -311,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. @@ -412,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-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 }; /// `local` is set when the body evaluates a root in place: code that @@ -429,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 committed + /// 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, @@ -485,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); @@ -600,6 +608,20 @@ const ComptimeDemandState = struct { if (list.items.len < len) try list.appendNTimes(allocator, value, len - list.items.len); } + /// 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 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; + 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]; } @@ -995,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. @@ -1273,6 +1295,53 @@ const ParallelCoordinatorTimingScope = struct { } }; +const ResolutionProfile = struct { + 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), + 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 = 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; + } + + 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, @@ -1570,6 +1639,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, @@ -4537,6 +4607,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; @@ -4632,6 +4704,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, @@ -4644,6 +4723,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, @@ -4757,6 +4837,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); @@ -4947,6 +5028,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, @@ -4962,7 +5045,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); @@ -5009,6 +5097,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, @@ -5016,7 +5105,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, @@ -5348,7 +5437,9 @@ fn appendRuntimeSchemaRequestToProgram( try program.addRuntimeSchemaRequest(request); } -const DeclaredComptimeRootFunctions = std.AutoHashMap(EntryRoot, Ast.FnId); +// 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); /// Checked module keys cross independent checking environments. Their local /// module indices do not; distinct checked modules routinely share an index. @@ -5361,7 +5452,10 @@ 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 { + resolution_profile: ResolutionProfile = .{}, /// `appModuleIdentity` of this lowering's modules. app_module_identity: ?[32]u8, allocator: Allocator, @@ -5370,6 +5464,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. @@ -5426,6 +5521,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 @@ -5450,11 +5546,17 @@ 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 /// identity named, by module key. literal_top_statements: std.AutoHashMapUnmanaged([32]u8, collections.DenseMap(can.CIR.Statement.Idx, void)) = .empty, + /// 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, + /// 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, @@ -5464,9 +5566,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, @@ -5503,6 +5606,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. @@ -5762,6 +5866,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, @@ -5773,13 +5878,15 @@ 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, .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), @@ -5860,6 +5967,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); @@ -5931,14 +6039,16 @@ 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, }); 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; @@ -5992,6 +6102,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); @@ -6023,7 +6134,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); @@ -6036,10 +6147,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(); @@ -6067,6 +6180,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); @@ -6077,6 +6191,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(); @@ -6322,14 +6437,158 @@ 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, ); + 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); + } + } + + 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, 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; + const program_context = (try self.programValueContext(false)) orelse return null; + 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); + // 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 = 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); + 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 evidence = (try self.persistentInterfaceEvidence(entry.evidence)) orelse return; + const summary: PersistentInterface.Summary = switch (entry.summary) { + .unchanged => .unchanged, + .constraints => |c| .{ .constraints = c }, + }; + 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; + }, + error.OutOfMemory => return error.OutOfMemory, + }; + defer self.allocator.free(bytes); + 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 { + 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 { @@ -6684,9 +6943,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(); @@ -6703,7 +6962,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; @@ -6711,18 +6970,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"); @@ -6910,21 +7170,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); } @@ -6941,7 +7210,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| { @@ -6962,13 +7231,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; } @@ -7330,6 +7599,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. @@ -7348,42 +7618,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]); @@ -7395,25 +7655,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; @@ -7437,7 +7697,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; } @@ -7474,6 +7734,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, @@ -7481,7 +7742,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, @@ -7686,6 +7947,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, @@ -7693,7 +7955,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, @@ -7785,8 +8047,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() } }, @@ -8755,6 +9019,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; @@ -8772,9 +9038,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, @@ -8784,7 +9052,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"); @@ -8849,6 +9119,7 @@ const Builder = struct { if (request_accounting == .count) self.count("template_misses"); } + 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 @@ -8876,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; @@ -8885,12 +9157,16 @@ 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 // 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 + // 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; self.count("spec_cache_hits"); @@ -8898,12 +9174,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 @@ -8962,7 +9240,10 @@ 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), .dispatch_index = undefined, .spec = reserved.spec, .reservation = reservation, @@ -8978,6 +9259,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 @@ -9021,6 +9303,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"); } @@ -9358,6 +9641,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 { @@ -9375,10 +9695,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)); } @@ -9390,7 +9712,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); @@ -9409,13 +9731,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.?)); } } } @@ -9824,6 +10150,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, @@ -9831,7 +10158,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, @@ -10205,6 +10532,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.v5"); + 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 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); + 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, "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, + }; + 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 = 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")) { + 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, if (timing_io) |std_io| timingNowNs(std_io) - start else 0 }); + 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 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| { + if (comptime std.mem.eql(u8, field, "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); + } + } + 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) "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, + }; + 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, if (timing_io) |std_io| timingNowNs(std_io) - start else 0, lower_ns }); + return .{ .family = address.family, .key = address.key, .bytes = bytes }; + } + 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)); + 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, @@ -10214,6 +10866,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)) if (self.timing) |timing| timingNowNs(timing.std_io) else 0 else 0; switch (template.target) { .roc, .intrinsic, @@ -10239,6 +10892,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; @@ -10289,6 +10963,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, 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(); if (!std.meta.eql(program_types_before, program_types_after)) { @@ -10309,6 +10985,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, @@ -10380,6 +11057,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); @@ -11226,6 +11906,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.bodyCommitted(self.allocator, fn_id); } fn typedLocalsForArgs(self: *Builder, arg_tys: anytype) Allocator.Error!Ast.Span(Ast.TypedLocal) { @@ -11759,7 +12440,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), @@ -12143,6 +12824,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 representative 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"); @@ -12218,17 +12910,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( @@ -12472,7 +13164,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 }); } @@ -14797,6 +15489,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; @@ -14840,7 +15536,7 @@ const Builder = struct { spec.source_fn_ty, spec.source_fn_key, request_fn_ty, - spec.evidence, + evidence, request_subst, signature_relation, .already_counted, @@ -14901,6 +15597,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); @@ -15504,7 +16202,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), @@ -15648,6 +16346,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, @@ -15713,6 +16412,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, @@ -15857,7 +16557,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 commit lost dispatch evidence"), .topology = try self.retainFnEvidence(evidence), }); try self.markTemplateReady(fn_id, solved_fn_ty); @@ -16772,8 +17472,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, @@ -16918,6 +17619,7 @@ const DirectCallRequest = struct { }; const DraftProcCallee = union(enum(u8)) { + declared_root: u32, func: DraftFnSlot, lifted: Ast.LiftedFnId, }; @@ -17888,7 +18590,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 committable requests retain dispatch evidence. + evidence: ?[]const SpecEvidence, /// The request's substitution sealed in the shard's store epoch. subst: SealedSubstitution, codec_contract: ?SealedCodecContractContext, @@ -19066,6 +19770,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 @@ -19074,6 +19779,7 @@ const DraftStaticDataRequestAddress = struct { module: checked.ModuleId, node: checked.ConstNodeId, checked_type: checked.CheckedTypeId, + checked_type_module: checked.ModuleId, ty: DraftTypeCell, }; @@ -19083,6 +19789,7 @@ const DraftStaticDataRequest = struct { module: checked.ModuleId, const_node: checked.ConstNodeId, checked_type: checked.CheckedTypeId, + checked_type_module: checked.ModuleId, ty: DraftTypeCell, }; @@ -21585,7 +22292,7 @@ const FinalIdOffsets = struct { .final => false, }, }, - .lifted => false, + .lifted, .declared_root => false, }; } @@ -21598,6 +22305,7 @@ const FinalIdOffsets = struct { }, } }, .lifted => |lifted| .{ .lifted = lifted }, + .declared_root => Common.invariant("unresolved declared root reached Monotype emission"), }; } @@ -21787,6 +22495,8 @@ const ActiveConstBindingScope = struct { entered: bool = false, }; +const PersistentInterface = @import("interface_summary.zig"); + const InterfaceReplayStatus = enum { expanding, expanded, ready }; const InterfaceReplayAddress = struct { @@ -21836,6 +22546,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 @@ -21875,6 +22587,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. @@ -22061,6 +22774,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); @@ -22376,17 +23090,8 @@ 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 unsettled encounters 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 }, + analysis_memo: AnalysisMemo = .{}, + 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 = .{}, @@ -22775,10 +23480,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, @@ -22793,6 +23500,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, @@ -23293,7 +24001,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, &.{}), @@ -23338,7 +24045,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.inhabitation_visiting.deinit(self.allocator); + self.analysis_memo.deinit(self.allocator); + self.completion_scratch.deinit(self.allocator); self.releaseMemos(); self.node_uninhabited_scratch.deinit(self.allocator); self.pattern_uninhabited_scratch.deinit(self.allocator); @@ -26564,14 +27272,15 @@ 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, }; /// 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 }, explicit_binding_memo: collections.DenseMapUnmanaged(checked.CheckedPatternId, bool) = .empty, /// The chunk-directory span past which a released map gives up its @@ -26579,30 +27288,22 @@ const BodyContext = struct { const retained_chunks = 256; fn deinit(self: *Memos, allocator: Allocator) void { - self.inhabitation_entered.deinit(allocator); - for (&self.settled_node_uninhabited) |*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.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, - .settled_node_uninhabited = self.settled_node_uninhabited, .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); 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); } @@ -26633,6 +27334,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); @@ -26643,18 +27356,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); @@ -26704,7 +27405,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 { @@ -26732,6 +27433,8 @@ const BodyContext = struct { function_ret: checked.CheckedTypeId, tag_union_ext: checked.CheckedTypeId, nominal, + list_elem, + box_elem, }; }; @@ -26845,6 +27548,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"), @@ -26891,6 +27596,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 }) } }; }, @@ -27120,10 +27834,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 { @@ -27132,18 +27845,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, @@ -27162,14 +27865,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) { @@ -27207,7 +27902,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 }) } }; } @@ -27932,7 +28627,148 @@ 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; + 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); + 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; + }, + error.OutOfMemory => return error.OutOfMemory, + }; + const summary: InterfaceSummary = switch (decoded) { + .unchanged => .unchanged, + .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, .persisted = true }), .domain = .graph, .persistent = true }; + } + + 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 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; + } + 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; + } + 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) { + .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); + 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; + 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; + 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, + .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 }; + }, + }; + } + pub fn validNamedType(self: PersistentProvenance, named: Type.NamedType) bool { + 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(); + } + }; + + 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| { @@ -28000,7 +28836,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); + 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. var sealer = GraphTypeFinals.initRetainedTypeView(self.graph); @@ -28258,14 +29096,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); @@ -28754,57 +29592,95 @@ 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, + const ProducedValuePair = struct { + request: NodeId, + produced: NodeId, + }; - fn init(allocator: Allocator) RequestCompletionPath { - return .{ .allocator = allocator }; + 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: *RequestCompletionPath) void { - self.map.deinit(self.allocator); + fn end(self: *AnalysisMemo, start: usize) void { + self.depth -= 1; + self.dependencies.shrinkRetainingCapacity(start); } - fn enter(self: *RequestCompletionPath, pair: RequestCompletionPair) Allocator.Error!bool { - return !(try self.map.getOrPut(self.allocator, pair)).found_existing; + fn recordStats(self: *AnalysisMemo, ctx: *BodyContext) void { + if (ctx.graph.diagnostics) |stats| { + 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()); + } } - fn leave(self: *RequestCompletionPath, pair: RequestCompletionPair) void { - const removed = self.map.swapRemove(pair); - std.debug.assert(removed); + fn read(self: *AnalysisMemo, ctx: *BodyContext, node: NodeId) Allocator.Error!void { + try self.dependencies.append(ctx.allocator, .{ .node = ctx.graph.rootNode(node) }); } - }; - - const ProducedValuePair = struct { - request: NodeId, - 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. - const CompletionMemo = struct { - epoch: u32, - relations: std.AutoHashMap(RequestCompletionPair, RequestCompletion), + 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 init(allocator: Allocator) CompletionMemo { - return .{ .epoch = 0, .relations = std.AutoHashMap(RequestCompletionPair, RequestCompletion).init(allocator) }; + 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; } + }; - fn deinit(self: *CompletionMemo) void { - self.relations.deinit(); + const CompletionScratch = struct { + 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.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; } }; @@ -28820,31 +29696,7 @@ 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; + return (try self.requestCompletionRelation(request_ret_node, produced_ret_node, &self.analysis_memo)) == .completed; } fn relateCheckedNodeToProducedValue( @@ -28854,9 +29706,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 @@ -28867,6 +29717,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, @@ -28875,6 +29734,8 @@ const BodyContext = struct { /// related. produced_content: InstNode, pair: ProducedValuePair, + epoch: u64, + cycles: u64, ops_start: usize, next: usize, results_start: usize, @@ -28895,8 +29756,9 @@ const BodyContext = struct { root_checked: NodeId, root_produced: NodeId, visiting: *std.AutoHashMap(ProducedValuePair, void), - completions: *CompletionMemo, ) 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; @@ -28907,7 +29769,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, &memo, &frames, &ops, &results); while (true) { if (delivered) |node| { if (frames.items.len == 0) return node; @@ -28921,7 +29783,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, &memo, &frames, &ops, &results); continue; }, .field_name => |field| failed = field.checked != field.produced, @@ -28945,6 +29807,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 }); + } } } @@ -28955,7 +29820,7 @@ const BodyContext = struct { checked_node: NodeId, produced_node: NodeId, visiting: *std.AutoHashMap(ProducedValuePair, void), - completions: *CompletionMemo, + memo: *ProducedValueMemo, frames: *std.ArrayListUnmanaged(ProducedValueFrame), ops: *std.ArrayListUnmanaged(ProducedValueOp), results: *std.ArrayListUnmanaged(NodeId), @@ -28966,8 +29831,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); @@ -28994,7 +29867,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; @@ -29090,6 +29963,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, @@ -29420,118 +30295,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 { - 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); - }; - - 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); - 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, - }); - 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, - }, - } + self.graph.countDiagnostic("completion_queries"); + 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 { + 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; - if (try self.enterRequestCompletion(op.request, op.produced, visiting, &ops, &assumed)) |relation| { - if (assumed) { - for (frames.items) |*open| open.assumed = true; - } else if (memo) |m| { - try m.relations.put(.{ .request = self.graph.rootNode(op.request), .produced = self.graph.rootNode(op.produced) }, relation); - } - 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, - }); - } - 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.assumed) try m.relations.put(done.pair, finished.?); - 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.proven.contains(key)) try m.entries.append(self.allocator, .{ .key = key, .value = scratch.equations.value(i) }); + } + try m.proofs.commit(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: ?*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 (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)) @@ -29539,12 +30378,7 @@ const BodyContext = struct { return .completed; } - const pair = RequestCompletionPair{ .request = request_root, .produced = produced_root }; - if (!try visiting.enter(pair)) { - 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| { @@ -29588,6 +30422,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 }); @@ -29651,7 +30487,6 @@ const BodyContext = struct { }; if (relation) |decided| { ops.shrinkRetainingCapacity(start); - visiting.leave(pair); return decided; } return null; @@ -37265,9 +38100,33 @@ const BodyContext = struct { node: NodeId, backing_access: UninhabitedBackingAccess, ) Allocator.Error!bool { - self.inhabitation_entered.clearRetainingCapacity(); + self.graph.countDiagnostic("inhabitation_queries"); + 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.commit(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 { @@ -37808,11 +38667,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 = .{ @@ -47039,6 +47898,7 @@ const BodyContext = struct { const_locator, node, checked_type, + type_view.key, DraftTypeCell.fromSealed(ty), ); const address: StaticDataCandidateAddress = .{ @@ -47051,7 +47911,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) }; }; @@ -47088,7 +47948,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, @@ -47103,6 +47963,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 = .{ @@ -47122,6 +47983,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 => .{ @@ -47129,6 +47991,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, }; @@ -47167,7 +48030,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, @@ -47206,7 +48069,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), @@ -47528,25 +48391,40 @@ const BodyContext = struct { return materialized; } + fn constCaptureTypeCache(self: *BodyContext) *ConstTypeMemo { + // 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"); + 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; } @@ -47650,6 +48528,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 @@ -51940,7 +52819,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, @@ -53292,9 +54171,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); @@ -53413,7 +54292,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), @@ -53473,7 +54352,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"), @@ -54573,7 +55452,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, @@ -66664,55 +67543,124 @@ 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 { + 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.node_uninhabited_scratch = .{}; + self.ctx.analysis_memo = .{}; + self.ctx.completion_scratch = .{}; + } + + fn deinit(self: *@This()) void { + self.ctx.completion_scratch.deinit(self.allocator); + self.ctx.analysis_memo.deinit(self.allocator); + self.ctx.node_uninhabited_scratch.deinit(self.allocator); + self.graph.destroy(); + self.name_store.deinit(); + self.types.deinit(); + } + + fn expectRelation(self: *@This(), expected: BodyContext.RequestCompletion, request: NodeId, produced: NodeId) (Allocator.Error || error{TestExpectedEqual})!void { + for (0..2) |_| { + 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); + } + try std.testing.expectEqual(expected, try self.ctx.requestCompletionRelation(request, produced, null)); + } + + 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)); + 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)); + } +}; + +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, 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); + + 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. @@ -66728,16 +67676,203 @@ 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 recursive equation is settled together, including enclosing pairs. + 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); + } +} + +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.analysis_invalidated_groups > 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; + 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.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.proven.contains(.{ .completion = .{ .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)); + // 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); +} + +test "completion analysis scratch and partial memos survive every allocation failure" { + const Probe = struct { + 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); + } + }; + 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.pairs.items.len); + try std.testing.expectEqual(@as(usize, 0), fixture.ctx.completion_scratch.ops.items.len); + 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; } } @@ -68523,6 +69658,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, @@ -69138,37 +70274,35 @@ 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, - /// 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, - const Evaluation = AnyAll.Evaluation(NodeId, NodeUninhabitedScan); + const Evaluation = @import("../inhabitation.zig").Evaluation(NodeId, NodeUninhabitedScan); - fn settled(self: *NodeUninhabitedScan) *collections.DenseMapUnmanaged(NodeId, bool) { - return &self.body.settled_node_uninhabited[@backingInt(self.backing_access)]; + /// Graph aliases name one equation and one eventual proof entry. + pub fn representative(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"); + 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 != 0 }; } - if (body.inhabitation_visiting.isSet(root_index)) { - self.unsettled_hits += 1; - return .{ .value = false }; + 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); + return .{ .value = answer }; } - 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) }; const expansion: Evaluation.Expansion = switch ((try body.graph.content(root))) { .redirect => |target| blk: { @@ -69178,7 +70312,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); @@ -69199,6 +70332,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); @@ -69206,10 +70342,7 @@ const NodeUninhabitedScan = struct { } break :blk .{ .group = .all }; }, - .unresolved => blk: { - self.unsettled_hits += 1; - break :blk .{ .value = false }; - }, + .unresolved => .{ .value = false }, .primitive, .list, .func, @@ -69219,20 +70352,16 @@ const NodeUninhabitedScan = struct { .hole, => .{ .value = false }, }; - if (expansion == .group) { - body.inhabitation_visiting.set(root_index); - try body.inhabitation_entered.put(body.allocator, root, self.unsettled_hits); - } 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.unsettled_hits == hits_before) try self.settled().put(body.allocator, root, answer); + const key: BodyContext.AnalysisKey = .{ .inhabitation = .{ .node = root, .access = self.backing_access } }; + if (result) |answer| if (!body.analysis_memo.proofs.proven.contains(key)) { + try body.analysis_memo.entries.append(body.allocator, .{ .key = key, .value = @intFromBool(answer) }); + }; } }; @@ -72104,9 +73233,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. @@ -72195,6 +73330,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); @@ -72220,6 +73357,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" { @@ -72259,3 +73411,1108 @@ 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.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)); + 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.proven.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); +} + +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) Allocator.Error!checked.CheckedTypeId { + // 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 = .{ + .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); +} + +// 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: ?FrozenDispatchEvidence, + /// 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), + .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, + => {}, + } + } +}; + +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.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 { + 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.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.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); + if (spec.evidence) |stored_evidence| { + try self.evidence(stored_evidence.stored); + try stored_evidence.validate(self); + } + } + 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); + } + + 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), + .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); + 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); + 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) { + 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_), + .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, + => {}, + } + } + }; +}; + +/// 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); + }, + .structural, + .from_callable, + .from_scheme, + .unreachable_value, + .checked_error, + => {}, + }; + 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; + }, + .structural, + .from_callable, + .from_scheme, + .unreachable_value, + .checked_error, + => {}, + }; + 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: FrozenDispatchEvidence) Allocator.Error![]const SpecEvidence { + var self = FrozenEvidenceContext{ .allocator = builder.allocator, .builder = builder }; + var cursor: usize = 0; + 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"); + 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 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); + 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(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 = 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); +} + +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(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 = valid_body; + 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)); +} + +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 }, + 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: 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); + 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); +} + +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) }; + var templates: checked.ConstTemplateTable = .{}; + defer templates.templates.deinit(a); + const locator: checked.ConstLocator = .{ + .artifact = owner, + .owner = .{ .top_level_binding = .{ .module_idx = 0, .pattern = @fromBackingInt(1) } }, + .template = @fromBackingInt(@intCast(templates.templates.items.len)), + .source_scheme = .{ .bytes = @splat(12) }, + }; + // 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(identity_row), .root_type = @fromBackingInt(1) } }, + }); + 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(1) }, + }; + const path = [_]CompletedConstStep{.{ .kind = .box }}; + const dependency: CompletedBodyStaticDependency = .{ + .root = @fromBackingInt(identity_row), + .identity = rows[identity_row], + .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)); +} diff --git a/src/postcheck/monotype/solve.zig b/src/postcheck/monotype/solve.zig index 5800b863967..af068654f6c 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; } } @@ -1909,6 +1922,22 @@ 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_repeated_pairs: u64 = 0, + inhabitation_queries: u64 = 0, + inhabitation_node_entries: u64 = 0, + inhabitation_node_expansions: u64 = 0, + inhabitation_memo_hits: u64 = 0, + analysis_changed_nodes: u64 = 0, + analysis_invalidation_edges: u64 = 0, + analysis_peak_entries: 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, /// Visited-set slots initialized or touched by structural backing walks. @@ -2404,6 +2433,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 @@ -2693,6 +2724,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(); @@ -2751,7 +2783,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; } @@ -2863,6 +2896,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); @@ -3703,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 } }, @@ -3865,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| { @@ -3924,10 +3958,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; @@ -4003,7 +4039,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 @@ -4028,6 +4064,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); @@ -4324,6 +4361,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; @@ -4924,6 +4962,11 @@ pub const InstGraph = struct { return node; } + /// Exact changed identities for dependency invalidation; compression is absent. + pub fn proofChanges(self: *InstGraph) *@import("../proofs.zig").Changes(NodeId) { + return &self.analysis_changes; + } + /// Current root for the node's union-find class. pub fn rootNode(self: *InstGraph, id: NodeId) NodeId { return self.find(id); @@ -6837,6 +6880,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 @@ -6887,6 +6932,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; } @@ -7249,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; } @@ -7480,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; @@ -7641,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, @@ -7821,14 +7909,20 @@ 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, + // 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); @@ -8842,7 +8936,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"), }; }, }, @@ -8918,25 +9012,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 representative identity. + pub fn representative(_: *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)) { @@ -8963,6 +9050,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); @@ -8974,14 +9064,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); } }; @@ -10198,7 +10285,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"), }; } @@ -15280,3 +15367,76 @@ 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)); +} + +test "nominal joins after lifting another instance backing preserve structure in either order" { + const Case = struct { + fn run(a: Allocator) error{ OutOfMemory, TestUnexpectedResult }!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, + }; + } +} diff --git a/src/postcheck/monotype/specialize.zig b/src/postcheck/monotype/specialize.zig index 4d5e307b87d..dd3e5d50731 100644 --- a/src/postcheck/monotype/specialize.zig +++ b/src/postcheck/monotype/specialize.zig @@ -65,6 +65,20 @@ 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_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. interface_summary_unchanged_hits: u64 = 0, /// Relation requests whose settled parametric instantiations were captured as holes. diff --git a/src/postcheck/monotype/type.zig b/src/postcheck/monotype/type.zig index bb794df1e58..f5b2cf7ab71 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); @@ -2525,6 +2545,11 @@ pub const Store = struct { return self.computeDigest(name_store, ty, .representation, stats) catch digestOutOfMemory(); } + /// 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); + } + /// Exact structural equality for closed Monotype types. /// /// Aliases with backing compare as their backing, non-alias named types @@ -4858,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, @@ -4876,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" { diff --git a/src/postcheck/monotype_lifted/lift.zig b/src/postcheck/monotype_lifted/lift.zig index 2dcfdd7a180..2230193aa9f 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,50 @@ 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(1), + } }, + .source_fn_ty = @fromBackingInt(1), + .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(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(@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 2e51bff0fbb..ec215bba5df 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, + /// 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, + 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 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. + /// 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 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; + 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 new file mode 100644 index 00000000000..81df0e89299 --- /dev/null +++ b/src/postcheck/proofs.zig @@ -0,0 +1,402 @@ +//! 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; + +/// 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 committed proof's dependency array. + pub const Watch = struct { + subscribed: bool = false, + 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 commits 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); + } + /// New nodes have no existing readers. + pub fn appendAssumeCapacity(self: *Self) void { + self.records.appendAssumeCapacity(null); + } + /// 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.epoch = 0; + } + /// Release the graph after its query owners have detached. + pub fn deinit(self: *Self, gpa: Allocator) void { + self.clearRetainingCapacity(); + self.records.deinit(gpa); + } + /// 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.*; + 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.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; + 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); + } + } + }; +} + +/// 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 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 }; + // 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 proven and reverse dependencies. + pub const Proof = struct { + valid: bool = true, + pending_next: ?*Proof = null, + notifications: *Notifications, + changes: *MutationIndex, + slot: usize, + dependencies: []Link, + keys: []Key, + dependents: std.ArrayList(*Link) = .empty, + 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; + } + 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 Proven = struct { value: u2, proof: *Proof }; + /// An outcome prepared for an atomic commit. + pub const Entry = struct { key: Key, value: u2 }; + proven: std.AutoHashMapUnmanaged(Key, Proven) = .empty, + changes: ?*MutationIndex = null, + notifications: ?*Notifications = null, + 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, + + /// Release certificates, tables and retained capacity. + pub fn deinit(self: *Self, gpa: Allocator) void { + self.clear(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(link); + for (self.groups.items) |proof| self.freeProof(gpa, proof); + if (self.notifications) |state| state.head = null; + self.groups.clearRetainingCapacity(); + self.pending.clearRetainingCapacity(); + self.proven.clearRetainingCapacity(); + self.dependency_bytes = 0; + self.dependent_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); + } + /// 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 { + const changes = graph.proofChanges(); + std.debug.assert(self.groups.items.len == 0 or self.changes == changes); + self.changes = changes; + self.epoch = epoch; + 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(link); + for (self.pending.items) |proof| { + for (proof.keys) |key| { + const removed = self.proven.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, link: *Link) void { + 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; + }, + } + } + /// Find a proven value after the caller has refreshed dependencies. + pub fn get(self: *Self, key: Key) ?Proven { + return self.proven.get(key); + } + /// 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.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) { + 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.* = .{ .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(link); + for (dependencies, links) |dependency, *link| { + link.* = .{ .dependency = dependency, .owner = proof, .slot = 0 }; + switch (dependency) { + .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; + } + for (entries, keys) |entry, *key| { + std.debug.assert(!self.proven.contains(entry.key)); + key.* = entry.key; + 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); + } + /// Table payload and certificate bytes, excluding allocator metadata, + /// alignment padding and hash-table control bytes. + pub fn retainedPayloadBytes(self: *const Self) usize { + 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 + + self.dependent_capacity_bytes; + } + }; +} + +test "issue 12616 proof dependencies invalidate transitively without dropping disjoint proven" { + const Node = enum(u32) { a, b, c }; + const Store = struct { + changes: Changes(Node) = .{}, + /// Read exact test mutation events, independent of proof state. + pub fn proofChanges(self: *@This()) *Changes(Node) { + return &self.changes; + } + }; + 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); + proofs.refresh(gpa, graph.changes.epoch, &graph); + 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.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); + 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, 1), proofs.changed_nodes); + proofs.clear(gpa); + try std.testing.expectEqual(@as(usize, 0), proofs.proven.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); + 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.commit(gpa, &.{.{ .key = 1, .value = 1 }}, &.{.{ .node = first_node }}); + 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(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) error{ OutOfMemory, TestExpectedEqual }!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.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.proven.count()); +} + +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.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); + 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_inline.zig b/src/postcheck/solved_inline.zig index 1b18caa4913..e9622b40bee 100644 --- a/src/postcheck/solved_inline.zig +++ b/src/postcheck/solved_inline.zig @@ -13,13 +13,17 @@ const GuardedList = collections.GuardedList; /// Post-check inline analysis mode. pub const Mode = enum { none, + /// 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, /// 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, }; @@ -110,6 +114,7 @@ pub fn analyze( ) std.mem.Allocator.Error!OwnedPlan { return switch (mode) { .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), }; @@ -126,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, @@ -142,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()); @@ -167,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(); @@ -204,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; } diff --git a/src/postcheck/solved_lir_lower.zig b/src/postcheck/solved_lir_lower.zig index ff3d18f0915..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()); @@ -3333,9 +3345,14 @@ 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, + .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, @@ -4493,7 +4510,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)); 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( 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/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)), + } +} diff --git a/test/cli/cache_work_bench.py b/test/cli/cache_work_bench.py new file mode 100644 index 00000000000..4633fd4c3b5 --- /dev/null +++ b/test/cli/cache_work_bench.py @@ -0,0 +1,174 @@ +#!/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. +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 +from pathlib import Path +import re +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) + 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") + 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() + 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() + 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: + 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", ".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(): + 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/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 diff --git a/test/cli/interface_cache_bench.py b/test/cli/interface_cache_bench.py new file mode 100644 index 00000000000..453b9de41b5 --- /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"RIFACE02truncated") + 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/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({}) } 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 +} diff --git a/test/cli/pack_index_bench.py b/test/cli/pack_index_bench.py new file mode 100644 index 00000000000..9c22837284f --- /dev/null +++ b/test/cli/pack_index_bench.py @@ -0,0 +1,152 @@ +#!/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 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 = templates[0] + offer_template = template.read_bytes() + assert offer_template[:8] == b"ROFFER03" + namespace = template.parents[3] + 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"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(" 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() diff --git a/test/cli/pack_publication_bench.py b/test/cli/pack_publication_bench.py new file mode 100644 index 00000000000..7df0dd00e24 --- /dev/null +++ b/test/cli/pack_publication_bench.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Measure native publication with a ReleaseFast diagnostic compiler on Linux. + + zig build -j2 roc -Doptimize=ReleaseFast -Ddev-diagnostics + python3 test/cli/pack_publication_bench.py zig-out/bin/roc + +Uses a built test/fx-open platform (or --platform). GNU time measures the whole +process, including maintenance; operation counts measure publication separately. +Only this disposable fixture's completed-command records are removed, forcing +native compilation/publication while preserving checked and procedure caches. +The test command runs independently; this fixture's runtime tests currently +publish no native offers, so its empty publication counters are reported +explicitly, not credited as a publication improvement. No timing thresholds +are asserted on the shared machine. +""" +import argparse +import os +from pathlib import Path +import re +import subprocess +import tempfile + + +def counters(stderr, prefix): + result = {} + for line in stderr.splitlines(): + if line.startswith(prefix): + for key, value in re.findall(r"(\w+)\s*=\s*(\d+)", line): + result[key] = result.get(key, 0) + int(value) + return result + + +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("--callers", type=int, default=24) + args = parser.parse_args() + assert 1 <= args.callers <= 128 + roc = args.roc.resolve() + with tempfile.TemporaryDirectory(prefix="roc-native-publication-") as tmp: + work = Path(tmp) + (work / "Shared.roc").write_text('''Shared := [].{ + leaf : U64 -> 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() 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 new file mode 100644 index 00000000000..df9848533a1 --- /dev/null +++ b/test/cli/persistent_interface_cache/Generic.roc @@ -0,0 +1,17 @@ +import Defaults + +Generic := [].{ + 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 + 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..8ec3e807235 --- /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) + Generic.composite({}).value +} + +unused : U64 +unused = crash "unused failing interface root" + +main! = |args| { + total = Generic.f7(args.len().to_u64() + 10) + offset + Generic.composite({}).value + Stdout.line!(total.to_str()) + Ok({}) +} 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 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