From 287485d47d451fcba23372c5cf229633890d6029 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 02:40:45 +0000 Subject: [PATCH 01/10] feat(sync): require explicit SHARE for shared params --- .../true-synchronization-polymorphism.md | 61 ++++ spec/borrow_checker_spec.rb | 47 ++++ spec/capabilities_spec.rb | 19 +- spec/mir_checker_spec.rb | 17 ++ spec/mir_emitter_spec.rb | 12 + spec/mir_lowering_spec.rb | 78 ++++++ spec/ownership_graph_spec.rb | 58 ++++ spec/share_spec.rb | 262 ++++++++++++++++++ spec/use_after_move_dataflow_spec.rb | 97 +++++++ spec/use_after_move_spec.rb | 21 ++ src/annotator-helpers/capabilities.rb | 4 +- src/annotator-helpers/fixable_helpers.rb | 18 +- src/annotator-helpers/function_analysis.rb | 21 ++ src/annotator-helpers/generic_analysis.rb | 9 +- src/annotator-helpers/method_analysis.rb | 2 +- src/annotator-helpers/pipe_analysis.rb | 2 +- src/annotator.rb | 76 +++-- src/ast/ast.rb | 1 + src/ast/lexer.rb | 2 +- src/ast/parser.rb | 1 + src/mir/control_flow.rb | 70 ++++- src/mir/escape_analysis.rb | 2 +- src/mir/mir.rb | 6 + src/mir/mir_checker.rb | 3 + src/mir/mir_emitter.rb | 17 ++ src/mir/mir_lowering.rb | 39 ++- src/mir/ownership_graph.rb | 65 ++++- .../201_capability_passthrough.cht | 4 +- transpile-tests/216_loop_carry_nested.cht | 6 +- .../217_loop_carry_overflow_blocks.cht | 10 +- 30 files changed, 974 insertions(+), 56 deletions(-) create mode 100644 spec/share_spec.rb diff --git a/docs/agents/true-synchronization-polymorphism.md b/docs/agents/true-synchronization-polymorphism.md index bc0d35b1..52122217 100644 --- a/docs/agents/true-synchronization-polymorphism.md +++ b/docs/agents/true-synchronization-polymorphism.md @@ -333,6 +333,67 @@ Combined with `LOCKED` and `SNAPSHOTTED`, this lets a fn declare sync strategy, including non-sync ones. (Or introduce `ANY` as the umbrella — naming choice deferred.) +#### Revised function-boundary rule: `T@shared` is strict + +The older "capabilities flow through ordinary function boundaries" +draft treated `T@shared`-style parameters as a polymorphic acceptance +surface: callers could pass bare `T`, `@multiowned`, or concrete +`@shared:*` bindings, and the callee body would decide whether it +needed a `WITH`, `CLONE`, or execution-boundary-safe capture. + +That plan is now deliberately narrowed for v0.1: + +- A parameter declared as `T@shared` accepts only a real shared handle. + Bare `T`, `@local`, and `@multiowned` do not satisfy it implicitly. +- Callers with an owned value must write `SHARE x`. `SHARE` promotes the + value into the shared function-boundary representation and consumes + the source unless the caller writes `SHARE COPY x`. +- Callers with `@multiowned` must also opt in with `SHARE x` if they + want to cross a `T@shared` API boundary. For v0.1 this may wrap in + the contending Arc/shared representation; the non-contending + `@multiowned` effect is not preserved silently. +- `WITH POLYMORPHIC` remains the access-polymorphic mechanism for + functions that explicitly admit several synchronization families via + `REQUIRES`. It is not a hidden conversion from bare `T` to + `T@shared`. + +This keeps the effect model honest. The old implicit plan only works +without semantic surprises for narrow transaction-style helpers that do +not cross execution boundaries and do not retain the value. If such a +helper later captures into `BG`, `DO`, or `CONCURRENT`, an implicit +bare/`@multiowned` acceptance path would have to silently upgrade to +`T@shared` and add contention/effects at the call site. v0.1 avoids +that by making the upgrade explicit. + +Future optimization: once the compiler has callee-behavior summaries, +`SHARE x` can be elided or downgraded when proven safe: + +- If the callee does not cross an execution boundary and does not + retain/clone the handle, `SHARE x` can lower to a no-op/direct borrow. +- If the callee stays on one scheduler and only needs retain semantics, + `SHARE x` may lower to `@multiowned`/Rc instead of Arc, avoiding the + `contends` effect. +- If the callee crosses `BG`, `DO`, or `CONCURRENT`, stores the handle + beyond the call, or otherwise needs scheduler-safe sharing, `SHARE x` + must remain the shared/Arc representation and surface the relevant + effects. + +Acceptance coverage for this revised boundary should extend the existing +polymorphic-sync suite with three representative callee shapes: + +1. A `T@shared` function that only retains the handle (`CLONE x`) and + never opens an access gate. This verifies that `T@shared` signatures + may rely on retain semantics, and bare callers must write `SHARE x`. +2. A `T@shared` function that crosses an execution boundary by capturing + the handle into one representative `BG` / `DO` / `CONCURRENT` body. + We only need one boundary form for this acceptance target; the point + is proving that implicit bare/`@multiowned` admission would have + needed a hidden upgrade and effect change. +3. The existing transaction/access-gate case: a function that crosses + `WITH POLYMORPHIC x AS y { ... }`. This remains the intended surface + for true synchronization polymorphism rather than `T@shared` + accepting arbitrary non-shared callers. + ### Gate 3 — multi-family `WITH POLYMORPHIC` lowering Today's `WITH POLYMORPHIC` lowering doesn't yet handle multi-family diff --git a/spec/borrow_checker_spec.rb b/spec/borrow_checker_spec.rb index 8f723723..0e351d3f 100644 --- a/spec/borrow_checker_spec.rb +++ b/spec/borrow_checker_spec.rb @@ -272,6 +272,53 @@ def expect_no_error(src, fn_name = "main") expect(errors.first).to include("u") end + it "catches SHARE of a borrowed value" do + errors = check_errors(<<~CLEAR) + STRUCT User { id: Int64 } + FN main() RETURNS Void -> + u: %User = User{ id: 1 }; + WITH BORROWED u AS ref { + shared = SHARE u; + } + RETURN; + END + CLEAR + expect(errors.length).to eq(1) + expect(errors.first).to include("MOVE_WHILE_BORROWED") + expect(errors.first).to include("u") + end + + it "catches SHARE of a complex expression that moves a borrowed value" do + errors = check_errors(<<~CLEAR) + STRUCT User { id: Int64 } + STRUCT Box { user: User } + FN main() RETURNS Void -> + u: %User = User{ id: 1 }; + WITH BORROWED u AS ref { + shared = SHARE Box{ user: u }; + } + RETURN; + END + CLEAR + expect(errors.length).to eq(1) + expect(errors.first).to include("MOVE_WHILE_BORROWED") + expect(errors.first).to include("u") + end + + it "walks SHARE nodes while looking for explicit moved identifiers" do + token = Lexer::Token.new(:VAR_ID, "u", 9, 7) + ident = AST::Identifier.new(token, "u") + ident.full_type = Type.new(:User) + ident.was_moved = true + share = AST::ShareNode.new(token, ident) + fn = Struct.new(:name, :body).new("main", []) + checker = BorrowChecker.new(fn, schema_lookup: nil) + seen = [] + + checker.send(:walk_for_was_moved, share) { |node| seen << node.name.to_s } + expect(seen).to eq(["u"]) + end + it "allows GIVE on @list inside WITH BORROWED (CopyNode - frame stays alive)" do errors = check_errors(<<~CLEAR) FN consume(TAKES items: Int64[]) RETURNS Int64 -> diff --git a/spec/capabilities_spec.rb b/spec/capabilities_spec.rb index 5fac184b..5a2d5d9c 100644 --- a/spec/capabilities_spec.rb +++ b/spec/capabilities_spec.rb @@ -154,18 +154,31 @@ def shared_decl(source) end end - context "capability annotation on a function parameter" do + context "@multiowned capability annotation on a function parameter" do let(:code) { <<~FLUX STRUCT Point { x: Float64 } - FN bad(p: Point @shared) RETURNS Float64 -> RETURN 0; END + FN bad(p: Point @multiowned) RETURNS Float64 -> RETURN 0; END FLUX } - it "raises a parser error: capabilities are not allowed on function parameters" do + it "raises an annotation error: capabilities are not allowed on function parameters" do expect { ast }.to raise_error(/Capability annotations are not allowed on function parameters/i) end end + + context "@shared capability annotation on a function parameter" do + let(:code) { + <<~FLUX + STRUCT Point { x: Float64 } + FN ok(p: Point @shared) RETURNS Float64 -> RETURN 0; END + FLUX + } + + it "is accepted as the explicit shared function-boundary contract" do + expect { ast }.not_to raise_error + end + end end describe "@locked (mutex-protected Locked(T) wrapper)" do diff --git a/spec/mir_checker_spec.rb b/spec/mir_checker_spec.rb index 72e751d3..97c3882d 100644 --- a/spec/mir_checker_spec.rb +++ b/spec/mir_checker_spec.rb @@ -530,6 +530,23 @@ def loop_restore_defer expect(errors.select { |e| e.include?("UNHOISTED_ALLOC") }).to be_empty end + it "passes: SharePromote as Let.init" do + body = [ + MIR::Let.new("s", MIR::SharePromote.new(MIR::Ident.new("rc"), "User", :heap), false, nil, nil), + ] + errors = checker.check_fn!(fn_def("ok_share_promote", body), strict: true) + expect(errors.select { |e| e.include?("UNHOISTED_ALLOC") }).to be_empty + end + + it "flags: SharePromote as Call argument" do + promote = MIR::SharePromote.new(MIR::Ident.new("rc"), "User", :heap) + body = [ + MIR::ExprStmt.new(MIR::Call.new("useShared", [promote], false), false), + ] + errors = checker.check_fn!(fn_def("share_promote_arg", body), strict: true) + expect(errors.any? { |e| e.include?("UNHOISTED_ALLOC") && e.include?("SharePromote") }).to be true + end + it "flags: MakeList in ExprStmt (discarded)" do body = [ MIR::ExprStmt.new(MIR::MakeList.new("i64", [], :heap), true), diff --git a/spec/mir_emitter_spec.rb b/spec/mir_emitter_spec.rb index 7a9b12e3..60bd3d7a 100644 --- a/spec/mir_emitter_spec.rb +++ b/spec/mir_emitter_spec.rb @@ -572,6 +572,18 @@ end end + describe "SharePromote" do + it "emits Rc to Arc promotion with Rc release" do + node = MIR::SharePromote.new(MIR::Ident.new("ref"), "User", :heap) + zig = e.emit(node) + + expect(zig).to include("const __share_src = ref;") + expect(zig).to include("CheatLib.dupeValue(User, __share_src.ctrl.data.*, rt.heapAlloc())") + expect(zig).to include("CheatLib.rcRelease(User, rt.heapAlloc(), __share_src);") + expect(zig).to include("CheatLib.arcCreate(User, rt.heapAlloc(), __share_val)") + end + end + describe "MakeList" do it "emits makeList with items" do node = MIR::MakeList.new("i64", [MIR::Lit.new("1"), MIR::Lit.new("2")], :frame) diff --git a/spec/mir_lowering_spec.rb b/spec/mir_lowering_spec.rb index 035f1e3b..4bf6c6f0 100644 --- a/spec/mir_lowering_spec.rb +++ b/spec/mir_lowering_spec.rb @@ -812,6 +812,84 @@ def node.reassign_cleanup; @reassign_cleanup; end expect(emit(result)).to eq("handle") end + it "lowers CLONE of a shared handle to Arc retain" do + source_type = Type.new(:Box, ownership: :shared) + inner = make_id("box", full_type: source_type) + node = AST::CloneNode.new(tok, inner) + node.full_type = source_type + + result = lowering.lower(node) + expect(result).to be_a(MIR::RcRetain) + expect(result.func).to eq("arcRetain") + expect(result.zig_base).to eq("Box") + expect(emit(result)).to eq("CheatLib.arcRetain(Box, box)") + end + + it "lowers SHARE of a bare value to Arc creation" do + inner = make_id("box", full_type: :Box) + shared_type = Type.new(:Box, ownership: :shared) + node = AST::ShareNode.new(tok, inner) + node.full_type = shared_type + + result = lowering.lower(node) + expect(result).to be_a(MIR::CapWrap) + expect(result.own_fn).to eq("arcCreate") + expect(result.zig_base).to eq("Box") + expect(emit(result)).to eq("try CheatLib.arcCreate(Box, rt.heapAlloc(), box)") + end + + it "lowers SHARE of an existing shared handle to Arc retain" do + source_type = Type.new(:Box, ownership: :shared) + inner = make_id("box", full_type: source_type) + node = AST::ShareNode.new(tok, inner) + node.full_type = source_type + + result = lowering.lower(node) + expect(result).to be_a(MIR::RcRetain) + expect(result.func).to eq("arcRetain") + expect(result.zig_base).to eq("Box") + expect(emit(result)).to eq("CheatLib.arcRetain(Box, box)") + end + + it "lowers SHARE of a multiowned handle to Rc-to-Arc promotion" do + source_type = Type.new(:Box, ownership: :multiowned) + shared_type = Type.new(:Box, ownership: :shared) + inner = make_id("box", full_type: source_type) + node = AST::ShareNode.new(tok, inner) + node.full_type = shared_type + + result = lowering.lower(node) + expect(result).to be_a(MIR::SharePromote) + expect(result.zig_base).to eq("Box") + expect(emit(result)).to include("CheatLib.rcRelease(Box, rt.heapAlloc(), __share_src);") + end + + it "records cleanup metadata for hoisted SharePromote allocations" do + source_type = Type.new(:Box, ownership: :multiowned) + shared_type = Type.new(:Box, ownership: :shared) + inner = make_id("box", full_type: source_type) + node = AST::ShareNode.new(tok, inner) + node.full_type = shared_type + + promote = MIR::SharePromote.new(MIR::Ident.new("box"), "Box", :heap) + l = lowering + expect(l.send(:mir_allocates?, promote)).to be true + entry = l.send(:hoist_cleanup_entry, promote, node) + expect(entry).to include(kind: :rc, alloc: :heap, zig_type: "CheatLib.Arc(Box)") + end + + it "lowers CLONE of a multiowned handle to Rc retain" do + source_type = Type.new(:Box, ownership: :multiowned) + inner = make_id("box", full_type: source_type) + node = AST::CloneNode.new(tok, inner) + node.full_type = source_type + + result = lowering.lower(node) + expect(result).to be_a(MIR::RcRetain) + expect(result.func).to eq("rcRetain") + expect(result.zig_base).to eq("Box") + end + it "lowers COPY of union" do inner = make_id("val", full_type: :Value) node = AST::CopyNode.new(tok, inner) diff --git a/spec/ownership_graph_spec.rb b/spec/ownership_graph_spec.rb index 4b8e55f8..81ba4cf8 100644 --- a/spec/ownership_graph_spec.rb +++ b/spec/ownership_graph_spec.rb @@ -1,4 +1,5 @@ require "rspec" +require_relative "../src/ast/lexer" require_relative "../src/mir/ownership_graph" RSpec.describe OwnershipGraph do @@ -38,6 +39,28 @@ expect(graph.moved?("x.child.name")).to be true end + it "records the move site and action on source and children" do + token = Lexer::Token.new(:VAR_ID, "x", 7, 11) + graph.declare("x.child") + graph.transfer("x", "y", at_token: token, action: :share) + + expect(graph["x"].move_line).to eq(7) + expect(graph["x"].move_col).to eq(11) + expect(graph["x"].move_action).to eq(:share) + expect(graph["x.child"].move_line).to eq(7) + expect(graph["x.child"].move_action).to eq(:share) + end + + it "records move metadata for direct mark_moved" do + token = Lexer::Token.new(:VAR_ID, "x", 8, 5) + graph.mark_moved("x", at_token: token, action: :give) + + expect(graph.moved?("x")).to be true + expect(graph["x"].move_line).to eq(8) + expect(graph["x"].move_col).to eq(5) + expect(graph["x"].move_action).to eq(:give) + end + it "returns nil for undeclared source" do result = graph.transfer("ghost", "y") expect(result).to be_nil @@ -200,6 +223,27 @@ expect(graph.live?("x")).to be true end + it "restores move metadata from lightweight snapshots" do + token = Lexer::Token.new(:VAR_ID, "x", 12, 4) + graph.declare("x") + graph.mark_moved("x", at_token: token, action: :share) + snapshot = graph.fork_lightweight + graph["x"].state = :live + graph["x"].move_line = nil + graph["x"].move_action = nil + + graph.restore_lightweight(snapshot) + expect(graph.moved?("x")).to be true + expect(graph["x"].move_line).to eq(12) + expect(graph["x"].move_action).to eq(:share) + end + + it "restores legacy state-only lightweight snapshots" do + graph.declare("x") + graph.restore_lightweight({ node_states: { "x" => :moved }, edge_count: 0 }) + expect(graph.moved?("x")).to be true + end + it "captures edge count for restoration" do graph.declare("x") snapshot = graph.fork_lightweight @@ -237,6 +281,20 @@ graph.merge(other) expect(graph.moved?("x")).to be true end + + it "copies move metadata from the moved branch on merge" do + token = Lexer::Token.new(:VAR_ID, "x", 21, 9) + other = OwnershipGraph.new + graph.declare("x") + other.declare("x") + other.mark_moved("x", at_token: token, action: :share) + + graph.merge(other) + expect(graph.moved?("x")).to be true + expect(graph["x"].move_line).to eq(21) + expect(graph["x"].move_col).to eq(9) + expect(graph["x"].move_action).to eq(:share) + end end describe "#owned_children" do diff --git a/spec/share_spec.rb b/spec/share_spec.rb new file mode 100644 index 00000000..c146c026 --- /dev/null +++ b/spec/share_spec.rb @@ -0,0 +1,262 @@ +require "rspec" +require_relative "../src/ast/lexer" +require_relative "../src/ast/parser" +require_relative "../src/annotator" +require_relative "../src/backends/transpiler" + +RSpec.describe "SHARE keyword" do + def parse(src) + tokens = Lexer.new(src).tokenize + Parser.new(tokens, src).parse + end + + def annotate(src) + ast = parse(src) + SemanticAnnotator.new.annotate!(ast) + ast + end + + def transpile(src) + ZigTranspiler.new.transpile(src) + end + + it "lexes SHARE as a keyword" do + tokens = Lexer.new("x = SHARE y;").tokenize + expect(tokens.map(&:value)).to include("SHARE") + expect(tokens.find { |t| t.value == "SHARE" }.type).to eq(:KEYWORD) + end + + it "parses SHARE as an expression" do + ast = parse("x = SHARE y;") + bind = ast.statements.first + + expect(bind.value).to be_a(AST::ShareNode) + expect(bind.value.value).to be_a(AST::Identifier) + expect(bind.value.value.name).to eq("y") + end + + it "infers SHARE as a shared value" do + ast = annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN main() RETURNS Void -> + b = Box{ value: 1 }; + s = SHARE b; + RETURN; + END + CLEAR + + share = ast.statements.last.body[1].value + expect(share.type_info).to be_shared + end + + it "rejects bare values passed to T@shared parameters and suggests SHARE" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 }; + takes_shared(b); + RETURN; + END + CLEAR + }.to raise_error(CompilerError, /expects Box @shared.*got Box.*SHARE b/m) + end + + it "rejects @multiowned values passed to T@shared parameters and suggests SHARE" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 } @multiowned; + takes_shared(b); + RETURN; + END + CLEAR + }.to raise_error(CompilerError, /expects Box @shared.*SHARE b/m) + end + + it "suggests SHARE expression syntax for non-identifier shared arguments" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + takes_shared(Box{ value: 1 }); + RETURN; + END + CLEAR + }.to raise_error(CompilerError, /expects Box @shared.*Use SHARE /m) + end + + it "accepts shared values passed to T@shared parameters" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 } @shared; + takes_shared(b); + RETURN; + END + CLEAR + }.not_to raise_error + end + + it "accepts SHARE values passed to T@shared parameters" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 }; + takes_shared(SHARE b); + RETURN; + END + CLEAR + }.not_to raise_error + end + + it "accepts explicit SHARE promotion from @multiowned to @shared" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 } @multiowned; + takes_shared(SHARE b); + RETURN; + END + CLEAR + }.not_to raise_error + end + + it "accepts SHARE into a T@shared function that only clones the handle" do + zig = transpile(<<~CLEAR) + STRUCT Box { value: Int64 } + + FN clone_only(b: Box @shared) RETURNS Void -> + retained = CLONE b; + RETURN; + END + + FN main() RETURNS Void -> + b = Box{ value: 1 }; + clone_only(SHARE b); + RETURN; + END + CLEAR + + expect(zig).to include("CheatLib.arcCreate(Box") + expect(zig).to include("CheatLib.arcRetain(Box") + expect(zig).to include("fn clone_only(rt: *Runtime") + end + + it "accepts SHARE into a T@shared function that crosses a BG boundary" do + expect { + transpile(<<~CLEAR) + STRUCT Box { value: Int64 } + + FN crosses_boundary(b: Box @shared) RETURNS !Void -> + p: ~Int64 = BG { b.value; }; + value = NEXT p; + RETURN; + END + + FN main() RETURNS !Void -> + b = Box{ value: 1 }; + crosses_boundary(SHARE b) OR EXIT; + RETURN; + END + CLEAR + }.not_to raise_error + end + + it "rejects CLONE on a bare non-shared value" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN main() RETURNS Void -> + b = Box{ value: 1 }; + c = CLONE b; + RETURN; + END + CLEAR + }.to raise_error(CompilerError, /CLONE is only supported/) + end + + it "records GIVE as the move action for explicit GIVE expressions" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN main() RETURNS Void -> + b = Box{ value: 1 }; + moved = GIVE b; + x = b.value; + RETURN; + END + CLEAR + }.to raise_error(CompilerError, /Use of moved value 'b'.*moved at line 4 by GIVE/m) + end + + it "consumes a bare source passed through SHARE" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 }; + takes_shared(SHARE b); + x = b.value; + RETURN; + END + CLEAR + }.to raise_error(CompilerError, /Use of moved value 'b'.*moved at line 5 by SHARE/m) + end + + it "reports the earlier SHARE site when sharing a consumed source again" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 }; + takes_shared(SHARE b); + takes_shared(SHARE b); + RETURN; + END + CLEAR + }.to raise_error(CompilerError, /Use of moved value 'b'.*moved at line 5 by SHARE/m) + end + + it "does not consume the source when SHARE wraps COPY" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 }; + takes_shared(SHARE COPY b); + x = b.value; + RETURN; + END + CLEAR + }.not_to raise_error + end + + it "does not consume an already shared handle" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 } @shared; + takes_shared(SHARE b); + takes_shared(b); + RETURN; + END + CLEAR + }.not_to raise_error + end +end diff --git a/spec/use_after_move_dataflow_spec.rb b/spec/use_after_move_dataflow_spec.rb index 39cc5f1b..378f4d64 100644 --- a/spec/use_after_move_dataflow_spec.rb +++ b/spec/use_after_move_dataflow_spec.rb @@ -250,6 +250,103 @@ def analyze_state(src, fn_name = "main") expect(df.exit_states["a"]).to eq(:moved) expect(df.exit_states["b"]).to eq(:owned) end + + it "exit_states show moved after SHARE into a shared parameter" do + df = analyze_state(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 }; + takes_shared(SHARE b); + RETURN; + END + CLEAR + expect(df.exit_states["b"]).to eq(:moved) + end + + it "exit_states show moved after SHARE in a binding RHS" do + df = analyze_state(<<~CLEAR) + STRUCT Box { value: Int64 } + FN main() RETURNS Void -> + b = Box{ value: 1 }; + s = SHARE b; + RETURN; + END + CLEAR + expect(df.exit_states["b"]).to eq(:moved) + expect(df.exit_states["s"]).to eq(:owned) + end + + it "exit_states preserve source ownership when SHARE wraps COPY" do + df = analyze_state(<<~CLEAR) + STRUCT Box { value: Int64 } + FN main() RETURNS Void -> + b = Box{ value: 1 }; + s = SHARE COPY b; + RETURN; + END + CLEAR + expect(df.exit_states["b"]).to eq(:owned) + expect(df.exit_states["s"]).to eq(:owned) + end + + it "exit_states show moved for nested affine values in complex SHARE expressions" do + df = analyze_state(<<~CLEAR) + STRUCT Inner { value: Int64 } + STRUCT Box { inner: Inner } + FN main() RETURNS Void -> + inner = Inner{ value: 1 }; + s = SHARE Box{ inner: inner }; + RETURN; + END + CLEAR + expect(df.exit_states["inner"]).to eq(:moved) + expect(df.exit_states["s"]).to eq(:owned) + end + end + + describe "SHARE read checks" do + it "reports SHARE COPY reads of already moved values in call arguments" do + errors = check_errors(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 }; + moved = b; + takes_shared(SHARE COPY b); + RETURN; + END + CLEAR + expect(errors.any? { |e| e.include?("USE_AFTER_MOVE") && e.include?("b") }).to be true + end + + it "reports SHARE COPY reads of already moved values in expressions" do + errors = check_errors(<<~CLEAR) + STRUCT Box { value: Int64 } + FN main() RETURNS Void -> + b = Box{ value: 1 }; + moved = b; + s = SHARE COPY b; + RETURN; + END + CLEAR + expect(errors.any? { |e| e.include?("USE_AFTER_MOVE") && e.include?("b") }).to be true + end + + it "treats SHARE of an existing shared handle as a read" do + token = Lexer::Token.new(:VAR_ID, "shared", 7, 3) + ident = AST::Identifier.new(token, "shared") + ident.full_type = Type.new(:Box, ownership: :shared) + share = AST::ShareNode.new(token, ident) + checker = UseAfterMoveChecker.new(double(name: "main"), double) + state = { + "shared" => OwnershipDataflow::OwnerEntry.new(state: :moved, allocator: :heap, needs_cleanup: true) + } + + checker.send(:check_share_reads, share, state) + expect(checker.errors.first).to include("USE_AFTER_MOVE") + expect(checker.errors.first).to include("shared") + end end # ========================================================================= diff --git a/spec/use_after_move_spec.rb b/spec/use_after_move_spec.rb index 341a99dc..a4464915 100644 --- a/spec/use_after_move_spec.rb +++ b/spec/use_after_move_spec.rb @@ -58,6 +58,27 @@ def expect_no_error(src) CLEAR end + it "reports TAKES when a method argument consumes a value" do + expect_error(<<~CLEAR, /Use of moved value 'item'.*moved at line 5 by TAKES/m) + STRUCT Item { v: Int64 } + FN main() RETURNS Void -> + MUTABLE pool: Item[10]@pool = []; + item = Item{ v: 1 }; + pool.insert(item); + x = item.v; + RETURN; + END + CLEAR + end + + it "maps move actions to user-facing labels" do + annotator = SemanticAnnotator.new + + expect(annotator.send(:ownership_move_action_label, :return)).to eq("RETURN") + expect(annotator.send(:ownership_move_action_label, :collect)).to eq("COLLECT") + expect(annotator.send(:ownership_move_action_label, :capture)).to eq("capture") + end + # ========================================================================= # 3. Struct literal consumes captured variables. # ========================================================================= diff --git a/src/annotator-helpers/capabilities.rb b/src/annotator-helpers/capabilities.rb index 08756b7c..c318b1d5 100644 --- a/src/annotator-helpers/capabilities.rb +++ b/src/annotator-helpers/capabilities.rb @@ -902,9 +902,9 @@ def _bg_walk(node, scope, locally_bound) ti = info.type if @og.live?(name) if kind == :resource || kind == :affine - og_set_moved(name) + og_set_moved(name, at_token: node.token, action: :capture) elsif ti.is_a?(Type) && ti.needs_escape_promotion? - og_set_moved(name) + og_set_moved(name, at_token: node.token, action: :capture) end end return diff --git a/src/annotator-helpers/fixable_helpers.rb b/src/annotator-helpers/fixable_helpers.rb index 5b03b652..5fb8fc1b 100644 --- a/src/annotator-helpers/fixable_helpers.rb +++ b/src/annotator-helpers/fixable_helpers.rb @@ -222,6 +222,9 @@ def emit_use_of_moved_error!(use_node, og_node) return error!(use_node, "Use of moved value '#{name}'") unless og_node.move_line && og_node.move_col fixes = [] + move_action = ownership_move_action_label(og_node.move_action) + move_suffix = move_action ? " by #{move_action}" : "" + fixes << Fix.new( description: "Wrap the consuming reference with COPY at line #{og_node.move_line} " \ "(the original survives for the later use).", @@ -262,13 +265,26 @@ def emit_use_of_moved_error!(use_node, og_node) end fixable!(use_node, - message: "Use of moved value '#{name}' (moved at line #{og_node.move_line})", + message: "Use of moved value '#{name}' (moved at line #{og_node.move_line}#{move_suffix})", category: :ownership, level: :error, fixes: fixes, raise_in_collector: true) end + def ownership_move_action_label(action) + case action + when :share then "SHARE" + when :give then "GIVE" + when :takes then "TAKES" + when :return then "RETURN" + when :next then "NEXT" + when :collect then "COLLECT" + when :capture then "capture" + else nil + end + end + # Type: `Integer literal N overflows T (range ...)`. When the # literal is written in suffixed form (`1000_u8`) and there's a # wider known type that fits the value, emit an :auto fix that diff --git a/src/annotator-helpers/function_analysis.rb b/src/annotator-helpers/function_analysis.rb index c1d53ffb..60ef417f 100644 --- a/src/annotator-helpers/function_analysis.rb +++ b/src/annotator-helpers/function_analysis.rb @@ -447,6 +447,27 @@ def verify_function_signature!(node, signature) end end + # Case 0b: strict shared-handle boundary. Type#== intentionally + # compares only the resolved base type for backward compatibility, + # so `Point@shared` would otherwise accept bare `Point`. Keep this + # check local to function calls: a `T@shared` parameter promises + # that the callee can retain/cross execution boundaries, so callers + # must pass a real shared handle or explicitly write SHARE x. + actual_type_obj = arg_ti.is_a?(Type) ? arg_ti : Type.new(actual || :Any) + if !match && expected_type_obj.shared? + unless actual_type_obj.shared? + hint = if arg_node.is_a?(AST::Identifier) + " Use SHARE #{arg_node.name} to create a shared handle." + else + " Use SHARE to create a shared handle." + end + error!(arg_node, + "Type Error: Argument #{i + 1} to '#{node.name}' expects #{expected_type_obj} @shared, " \ + "got #{actual_type_obj}.#{hint}") + end + match = true if expected_type_obj.resolved == actual_type_obj.resolved + end + # Case 1: Exact Match or Any if !match && (expected == :Any || actual == :Any || expected == actual) match = true diff --git a/src/annotator-helpers/generic_analysis.rb b/src/annotator-helpers/generic_analysis.rb index 99142a21..1c93e813 100644 --- a/src/annotator-helpers/generic_analysis.rb +++ b/src/annotator-helpers/generic_analysis.rb @@ -69,11 +69,14 @@ def validate_type_annotation!(node, type_obj, is_param: false) # --- Capability validation (moved from parser for separation of concerns) --- - # Ownership/sync capabilities are not allowed on function parameters. - # :affine is the default (not a user-set capability). :link is structural (allowed on params). + # Ownership/sync capabilities are not allowed on function parameters, + # except plain @shared. `T@shared` is the explicit function-boundary + # shared-handle contract; callers with bare/local/multiowned values + # must use SHARE at the call site. :affine is the default (not a + # user-set capability). :link is structural (allowed on params). # @raw is structural (byte buffer). Collections, @soa, @indirect are also structural. if is_param - has_ownership_cap = %i[multiowned shared split].include?(type_obj.ownership) + has_ownership_cap = %i[multiowned split].include?(type_obj.ownership) has_sync_cap = type_obj.sync && !%i[raw symbol].include?(type_obj.sync) if has_ownership_cap || has_sync_cap error!(node, "Capability annotations are not allowed on function parameters. Use the plain type (e.g., 'Node' not 'Node @multiowned').") diff --git a/src/annotator-helpers/method_analysis.rb b/src/annotator-helpers/method_analysis.rb index 66d19ed7..7a4e23a8 100644 --- a/src/annotator-helpers/method_analysis.rb +++ b/src/annotator-helpers/method_analysis.rb @@ -127,7 +127,7 @@ def resolve_typed_method(node, obj_type, registry, tag_field, type_label) arg_node = node.args[arg_idx] next unless arg_node if arg_node.is_a?(AST::Identifier) - og_set_moved(arg_node.name) + og_set_moved(arg_node.name, at_token: arg_node.token, action: :takes) end arg_node.was_moved = true end diff --git a/src/annotator-helpers/pipe_analysis.rb b/src/annotator-helpers/pipe_analysis.rb index 8ac1ef23..b59f93e0 100644 --- a/src/annotator-helpers/pipe_analysis.rb +++ b/src/annotator-helpers/pipe_analysis.rb @@ -211,7 +211,7 @@ def analyze_collect_op(node) end # Mark the source binding as consumed -- COLLECT is the explicit # join, equivalent to NEXT for the future-consume check. - og_set_moved(node.left.name) if node.left.is_a?(AST::Identifier) + og_set_moved(node.left.name, at_token: node.left.token, action: :collect) if node.left.is_a?(AST::Identifier) inner = lhs_t&.tense_type # Collection observable (DISTINCT producing `~T[]@set:observable`): # COLLECT yields an owned `T[]` snapshot via `materializeNext`, not diff --git a/src/annotator.rb b/src/annotator.rb index c8e772ce..4fdb1d09 100644 --- a/src/annotator.rb +++ b/src/annotator.rb @@ -1209,11 +1209,19 @@ def analyze_control_flow_branches(branches, merge_to_parent: true) next if branch_terminates[i] next unless snap # Lightweight merge: just apply moved states - snap[:node_states].each do |path, state| + snap[:node_states].each do |path, saved| + state = saved.is_a?(Hash) ? saved[:state] : saved node = @og.nodes[path] next unless node if node.state != state - node.state = :moved if state == :moved + if state == :moved + node.state = :moved + if saved.is_a?(Hash) + node.move_line = saved[:move_line] + node.move_col = saved[:move_col] + node.move_action = saved[:move_action] + end + end end end end @@ -1469,7 +1477,7 @@ def visit_MatchStatement(node) source_name = node.expr.name if @og[source_name] && @og[source_name].kind != :borrowed node.expr.was_moved = true - og_set_moved(source_name) + og_set_moved(source_name, at_token: node.expr.token, action: :takes) end end @@ -1766,7 +1774,8 @@ def visit_WhileLoop(node) # loop (e.g. MATCH struct bindings with field extraction) and aren't consumed by iteration. loop_body_names = collect_body_identifier_names(node.do_branch) current_scope.locals.each do |name, _entry| - was_live = pre_loop_states&.dig(:node_states, name) == :live + saved = pre_loop_states&.dig(:node_states, name) + was_live = (saved.is_a?(Hash) ? saved[:state] : saved) == :live is_moved = @og&.moved?(name) if was_live && is_moved next unless loop_body_names.include?(name) @@ -1840,7 +1849,8 @@ def visit_WhileBindLoop(node) loop_body_names = collect_body_identifier_names(node.do_branch) current_scope.locals.each do |name, _entry| next if name == node.binding_name - was_live = pre_loop_states&.dig(:node_states, name) == :live + saved = pre_loop_states&.dig(:node_states, name) + was_live = (saved.is_a?(Hash) ? saved[:state] : saved) == :live is_moved = @og&.moved?(name) if was_live && is_moved next unless loop_body_names.include?(name) @@ -2087,7 +2097,7 @@ def visit_ReturnNode(node) # gets a chance to run. Mirrors NEXT's `og_set_moved` (line ~4388). vt = vti.is_a?(Type) ? vti : (vti ? Type.new(vti) : nil) if vt&.future? - og_set_moved(node.value.name) + og_set_moved(node.value.name, at_token: node.value.token, action: :return) end end @@ -3908,7 +3918,7 @@ def visit_MoveNode(node) node.storage = node.value.storage # Consume the source variable — it is affinely transferred - og_set_moved(node.value.name) + og_set_moved(node.value.name, at_token: node.value.token, action: :give) end # Ensure a value node is owned data suitable for storage in structs, unions, @@ -4103,12 +4113,36 @@ def visit_CloneNode(node) visit(node.value) type = node.value.type_info - unless type&.split_open_stream? || type&.shared_promise? - error!(node, "CLONE is only supported on @split streams and @shared promises, got '#{node.value.resolved_type}'") + unless type&.split_open_stream? || type&.shared_promise? || type&.any_rc? + error!(node, "CLONE is only supported on @split streams, @shared promises, and owned shared handles, got '#{node.value.resolved_type}'") end node.full_type = node.value.full_type node.storage = node.value.storage + current_fn_ctx.needs_rt = true if current_fn_ctx && type&.any_rc? + end + + def visit_ShareNode(node) + visit(node.value) + source_type = node.value.type_info + source_type = Type.new(source_type) if source_type && !source_type.is_a?(Type) + error!(node, "SHARE requires a typed value") unless source_type + + result = Type.new(source_type, ownership: :shared) + result.provenance = :heap + node.full_type = result + node.storage = :heap + + if share_consumes_source?(node.value) + root = get_root_object(node.value) + if root.is_a?(AST::Identifier) + og_set_moved(root.name, at_token: root.token, action: :share) + root.was_moved = true + end + end + + current_fn_ctx.heap_count += 1 if current_fn_ctx + record_effect(EffectTracker::HEAP) end def visit_OptionalUnwrap(node) @@ -5043,7 +5077,7 @@ def visit_NextExpr(node) # NEXT on ~T[]@list: await all promises, return T[]@list. # The promise list is linearly consumed — each inner promise is freed by its next() call. if node.expr.is_a?(AST::Identifier) - og_set_moved(node.expr.name) + og_set_moved(node.expr.name, at_token: node.expr.token, action: :next) end elem_sym = promise_type.tense_type.element_type.to_sym node.full_type = Type.new(:"#{elem_sym}[]", collection: :list) @@ -5060,7 +5094,7 @@ def visit_NextExpr(node) # done after the first call, so a second NEXT would just # re-take the same snapshot, violating the consume-or-transfer # semantics. Match scalar-NEXT behavior: linearly consume. - og_set_moved(node.expr.name) if node.expr.is_a?(AST::Identifier) + og_set_moved(node.expr.name, at_token: node.expr.token, action: :next) if node.expr.is_a?(AST::Identifier) elem_sym = promise_type.tense_type.element_type.to_sym node.full_type = Type.new(:"#{elem_sym}[]") node.storage = :heap @@ -5092,7 +5126,7 @@ def visit_NextExpr(node) else # NEXT on ~T: returns T, marks the promise as linearly consumed. if node.expr.is_a?(AST::Identifier) - og_set_moved(node.expr.name) + og_set_moved(node.expr.name, at_token: node.expr.token, action: :next) end node.full_type = promise_type.tense_type.to_sym end @@ -5190,7 +5224,7 @@ def handle_assign_move(node) if Type.new(node.value.resolved_type).requires_move? graph_path = path.map(&:to_s).join(".") @og.declare(graph_path, kind: :affine, scope_depth: @og_scope_depth) unless @og[graph_path] - og_set_moved(graph_path) + og_set_moved(graph_path, at_token: node.value.token, action: :move) end return end @@ -6163,8 +6197,18 @@ def og_declare(name, node, type_info) scope_depth: @og_scope_depth, line: node&.respond_to?(:line) ? node.line : 0) end - def og_move(from, to, at_token: nil) = @og.transfer(from, to, at_token: at_token) - def og_set_moved(name) = (@og[name]&.state = :moved) + def og_move(from, to, at_token: nil, action: :move) = @og.transfer(from, to, at_token: at_token, action: action) + def og_set_moved(name, at_token: nil, action: :move) = @og.mark_moved(name, at_token: at_token, action: action) + + def share_consumes_source?(node) + return false if node.is_a?(AST::CopyNode) + + ti = node.type_info + ti = Type.new(ti) if ti && !ti.is_a?(Type) + return false if ti&.shared? + + true + end # Mark an identifier as moved if its type is non-Copy. # Skips generic type params (can't determine copyability at annotation time). @@ -6175,7 +6219,7 @@ def move_if_not_copyable!(node) return if vt.nil? return if current_fn_ctx&.type_params&.include?(vt.resolved) return if vt.implicitly_copyable? { |t| lookup_type_schema(t) rescue nil } - og_set_moved(node.name) + og_set_moved(node.name, at_token: node.token, action: :move) node.was_moved = true end diff --git a/src/ast/ast.rb b/src/ast/ast.rb index 59e9f11c..2454fe01 100644 --- a/src/ast/ast.rb +++ b/src/ast/ast.rb @@ -891,6 +891,7 @@ def name; target.respond_to?(:name) ? target.name : nil end MoveNode = Struct.new(:token, :value) { include Locatable } # MOVE expr -> transfer Rc/Arc handle without retain CopyNode = Struct.new(:token, :value) { include Locatable; attr_accessor :deep_copy } # COPY expr -> explicit deep-copy; deep_copy: true for unions with heap variants CloneNode = Struct.new(:token, :value) { include Locatable } # CLONE expr -> explicit handle retain for non-affine replay/shared futures + ShareNode = Struct.new(:token, :value) { include Locatable } # SHARE expr -> promote/retain as T@shared (semantic lowering follows) LinkNode = Struct.new(:token, :value) { include Locatable } # LINK expr -> downgrade Rc/Arc to WeakRc/WeakArc ResolveNode = Struct.new(:token, :value) { include Locatable } # RESOLVE expr -> upgrade WeakRc/WeakArc to ?Rc/?Arc FreezeNode = Struct.new(:token, :value) { include Locatable } # FREEZE expr -> compact @multiowned tree into contiguous buffer diff --git a/src/ast/lexer.rb b/src/ast/lexer.rb index b9877d9c..02db0ee2 100644 --- a/src/ast/lexer.rb +++ b/src/ast/lexer.rb @@ -16,7 +16,7 @@ class Lexer MOD OR REQUIRE SELECT WHERE INDEX REDUCE ORDER_BY LIMIT SKIP UNNEST DISTINCT EACH TAP FIND ANY ALL COUNT SUM AVERAGE MIN MAX CONCURRENT SHARD TAKE_WHILE WINDOW JOIN RECOVER COLLECT - GIVE TAKES COPY MOVE CLONE LINK RESOLVE FREEZE + GIVE TAKES COPY MOVE CLONE SHARE LINK RESOLVE FREEZE WITH EXCLUSIVE RESTRICT BORROWED ON RETRY POSSIBLE_DEADLOCK POSSIBLE_LOCK_CYCLE VIEW MATERIALIZED SNAPSHOT POLYMORPHIC SYNC POLICY REQUIRES diff --git a/src/ast/parser.rb b/src/ast/parser.rb index 27b4146f..e9482a7a 100644 --- a/src/ast/parser.rb +++ b/src/ast/parser.rb @@ -152,6 +152,7 @@ def peek_at(n) primary(:KEYWORD, 'GIVE', AST::MoveNode, ['GIVE', :expression]) primary(:KEYWORD, 'COPY', AST::CopyNode, ['COPY', :expression]) primary(:KEYWORD, 'CLONE', AST::CloneNode, ['CLONE', :expression]) + primary(:KEYWORD, 'SHARE', AST::ShareNode, ['SHARE', :expression]) primary(:KEYWORD, 'LINK', AST::LinkNode, ['LINK', :expression]) primary(:KEYWORD, 'RESOLVE', AST::ResolveNode, ['RESOLVE', :expression]) primary(:KEYWORD, 'FREEZE', AST::FreezeNode, ['FREEZE', :expression]) diff --git a/src/mir/control_flow.rb b/src/mir/control_flow.rb index 145e6eb8..5975291e 100644 --- a/src/mir/control_flow.rb +++ b/src/mir/control_flow.rb @@ -680,6 +680,9 @@ def collect_ownership_transfers(node, state, consumed) consumed << name if state[name] end + when AST::ShareNode + collect_share_transfer(node, state, consumed) + when AST::CopyNode, AST::CloneNode, AST::FreezeNode # COPY / FREEZE do NOT move the source. @@ -713,13 +716,14 @@ def collect_explicit_in(node, state, consumed) next if copy_type?(n) consumed << name end + collect_share_transfers_in(node, state, consumed) end # Collect only explicitly moved identifiers (was_moved set by annotator). # Used for function calls where non-TAKES args are borrowed, not moved. def collect_explicit_moves(node, state) return [] unless node - consumed = [] + consumed = Set.new walk_expr_skip_copy(node) do |n| next unless n.is_a?(AST::Identifier) && n.was_moved name = n.name.to_s @@ -727,7 +731,29 @@ def collect_explicit_moves(node, state) next if copy_type?(n) consumed << name end - consumed + collect_share_transfers_in(node, state, consumed) + consumed.to_a + end + + def collect_share_transfers_in(node, state, consumed) + walk_expr(node) do |n| + collect_share_transfer(n, state, consumed) if n.is_a?(AST::ShareNode) + end + end + + def collect_share_transfer(node, state, consumed) + source = node.value + return if source.is_a?(AST::CopyNode) + + if source.is_a?(AST::Identifier) + ti = Type.from_node(source) + return if ti&.shared? + name = source.name.to_s + consumed << name if state[name] + return + end + + collect_ownership_transfers(source, state, consumed) end # Collect resource captures from BG blocks nested in function/method call args. @@ -828,6 +854,8 @@ def walk_expr(node, &block) node.pairs&.each { |_k, v| walk_expr(v.is_a?(Array) ? v[1] : v, &block) } when AST::CopyNode, AST::CloneNode, AST::FreezeNode walk_expr(node.value, &block) + when AST::ShareNode + walk_expr(node.value, &block) when AST::MoveNode walk_expr(node.value, &block) when AST::CapabilityWrap @@ -849,6 +877,8 @@ def walk_expr_skip_copy(node, &block) case node when AST::CopyNode, AST::CloneNode, AST::FreezeNode # Do not recurse: COPY/FREEZE does not consume the source. + when AST::ShareNode + walk_expr_skip_copy(node.value, &block) when AST::BinaryOp walk_expr_skip_copy(node.left, &block) walk_expr_skip_copy(node.right, &block) @@ -1015,6 +1045,8 @@ def check_call_reads(call_node, state) elsif arg.is_a?(AST::MoveNode) # GIVE wrapper: inner is being moved, not read. next + elsif arg.is_a?(AST::ShareNode) + check_share_reads(arg, state) elsif arg.is_a?(AST::CopyNode) || arg.is_a?(AST::CloneNode) || arg.is_a?(AST::FreezeNode) # COPY/FREEZE: the source IS read (must be live to copy/freeze from). check_reads_in_expr(arg.value, state) @@ -1043,6 +1075,9 @@ def check_reads_in_expr(node, state) check_reads_in_expr(node.value, state) end + when AST::ShareNode + check_share_reads(node, state) + when AST::BinaryOp check_reads_in_expr(node.left, state) check_reads_in_expr(node.right, state) @@ -1085,6 +1120,18 @@ def check_reads_in_expr(node, state) end end + def check_share_reads(node, state) + source = node.value + if source.is_a?(AST::CopyNode) + check_reads_in_expr(source.value, state) + elsif source.is_a?(AST::Identifier) + ti = Type.from_node(source) + check_identifier_read(source.name.to_s, state, source.token) if ti&.shared? + else + check_reads_in_expr(source, state) + end + end + # Check a single identifier read against the ownership state. def check_identifier_read(name, state, token) entry = state[name] @@ -1726,6 +1773,8 @@ def _collect_moves(node, names) when AST::MoveNode inner = node.value names << inner.name.to_s if inner.is_a?(AST::Identifier) + when AST::ShareNode + _collect_share_moves(node, names) when AST::CopyNode, AST::CloneNode, AST::FreezeNode # COPY/FREEZE does NOT move the source. when AST::CapabilityWrap @@ -1745,6 +1794,21 @@ def _collect_was_moved(node, names) end end + def _collect_share_moves(node, names) + source = node.value + return if source.is_a?(AST::CopyNode) + + if source.is_a?(AST::Identifier) + ti = source.type_info rescue nil + ti = Type.new(ti) if ti && !ti.is_a?(Type) + return if ti&.shared? + names << source.name.to_s + return + end + + _collect_moves(source, names) + end + # Walk expression tree for was_moved identifiers, skipping CopyNode. def walk_for_was_moved(node, &block) return unless node @@ -1772,6 +1836,8 @@ def walk_for_was_moved(node, &block) node.items&.each { |i| walk_for_was_moved(i, &block) } when AST::MoveNode walk_for_was_moved(node.value, &block) + when AST::ShareNode + walk_for_was_moved(node.value, &block) when AST::CapabilityWrap walk_for_was_moved(node.value, &block) end diff --git a/src/mir/escape_analysis.rb b/src/mir/escape_analysis.rb index 681b686e..59e8792c 100644 --- a/src/mir/escape_analysis.rb +++ b/src/mir/escape_analysis.rb @@ -397,7 +397,7 @@ def self.analyze!(fn_nodes, heap_fns:, promotion_plans: {}) e2_walk_calls_in_expr(node.value, &blk) when AST::ReturnNode e2_walk_calls_in_expr(node.value, &blk) - when AST::MoveNode, AST::CopyNode, AST::CloneNode, AST::FreezeNode, AST::CapabilityWrap + when AST::MoveNode, AST::CopyNode, AST::CloneNode, AST::FreezeNode, AST::ShareNode, AST::CapabilityWrap e2_walk_calls_in_expr(node.value, &blk) when AST::BinaryOp e2_walk_calls_in_expr(node.left, &blk) diff --git a/src/mir/mir.rb b/src/mir/mir.rb index c0bc1364..ce21354c 100644 --- a/src/mir/mir.rb +++ b/src/mir/mir.rb @@ -1040,6 +1040,12 @@ def kind; :cond_jump; end include Expr end + # Promote a consumed Rc(T) handle into a fresh Arc(T). + # Zig: copy rc.ctrl.data.* into a new Arc, then release the consumed Rc. + SharePromote = Struct.new(:source, :zig_base, :alloc) do + include Expr + end + # Rc/Arc retain (reference count increment). # Zig: CheatLib.arcRetain(T, name) or CheatLib.rcRetain(T, name) RcRetain = Struct.new(:source, :zig_base, :func) do diff --git a/src/mir/mir_checker.rb b/src/mir/mir_checker.rb index 3bbac88b..c39a0e2a 100644 --- a/src/mir/mir_checker.rb +++ b/src/mir/mir_checker.rb @@ -641,6 +641,7 @@ def error(kind, name, msg) # # Allocating types: # DupeSlice, HeapCreate, ConcatStr, AllocSlice, MakeList, CapWrap, + # SharePromote, # DeepCopy (strategy != :passthrough), ContainerInit (alloc != nil) # # Called only when strict: true because the codebase still has open @@ -756,6 +757,7 @@ def allocating_expr?(expr) when MIR::MakeList then expr.alloc == :heap when MIR::ConcatStr then expr.alloc == :heap when MIR::CapWrap then expr.alloc == :heap + when MIR::SharePromote then expr.alloc == :heap when MIR::ContainerInit then expr.alloc == :heap when MIR::DeepCopy expr.strategy != :passthrough && expr.alloc == :heap @@ -777,6 +779,7 @@ def each_sub_expr(expr) when MIR::DestroyPtr then yield expr.ptr if expr.ptr when MIR::DeepCopy then yield expr.source if expr.source when MIR::CapWrap then yield expr.inner if expr.inner + when MIR::SharePromote then yield expr.source if expr.source when MIR::ContainerInit then yield expr.capacity if expr.capacity when MIR::ConcatStr then expr.parts&.each { |p| yield p } when MIR::MakeList then expr.items&.each { |i| yield i } diff --git a/src/mir/mir_emitter.rb b/src/mir/mir_emitter.rb index cd11b008..a728a0a2 100644 --- a/src/mir/mir_emitter.rb +++ b/src/mir/mir_emitter.rb @@ -91,6 +91,7 @@ def emit(node) when MIR::DeepCopy then emit_deep_copy(node) when MIR::ContainerInit then emit_container_init(node) when MIR::CapWrap then emit_cap_wrap(node) + when MIR::SharePromote then emit_share_promote(node) when MIR::RcRetain then emit_rc_retain(node) when MIR::RcDowngrade then emit_rc_downgrade(node) when MIR::WeakUpgrade then emit_weak_upgrade(node) @@ -939,6 +940,22 @@ def emit_cap_wrap(node) end end + def emit_share_promote(node) + source = emit(node.source) + alloc = alloc_zig(node.alloc) + <<~ZIG.chomp + blk_share: { + const __share_src = #{source}; + errdefer CheatLib.rcRelease(#{node.zig_base}, #{alloc}, __share_src); + var __share_val = try CheatLib.dupeValue(#{node.zig_base}, __share_src.ctrl.data.*, #{alloc}); + errdefer CheatLib.cleanup(#{node.zig_base}, #{alloc}, &__share_val); + const __share_arc = try CheatLib.arcCreate(#{node.zig_base}, #{alloc}, __share_val); + CheatLib.rcRelease(#{node.zig_base}, #{alloc}, __share_src); + break :blk_share __share_arc; + } + ZIG + end + def emit_rc_retain(node) "CheatLib.#{node.func}(#{node.zig_base}, #{emit(node.source)})" end diff --git a/src/mir/mir_lowering.rb b/src/mir/mir_lowering.rb index 2059c7b5..fda99a12 100644 --- a/src/mir/mir_lowering.rb +++ b/src/mir/mir_lowering.rb @@ -109,6 +109,7 @@ def mir_allocates?(node) when MIR::MakeList then node.alloc == :heap when MIR::HeapCreate then true # always heap by definition when MIR::CapWrap then node.alloc == :heap + when MIR::SharePromote then node.alloc == :heap when MIR::DeepCopy then node.alloc == :heap when MIR::ConcatStr then node.alloc == :heap when MIR::ContainerInit @@ -194,6 +195,13 @@ def hoist_cleanup_entry(mir, ast_node) # :passthrough / :local -- inner value passes through; no additional cleanup. nil end + when MIR::SharePromote + ti = Type.from_node(ast_node) + zig_t = ti&.zig_type + raise "hoist_cleanup_entry: MIR::SharePromote has no zig_type -- " \ + "ast_node type_info unavailable" unless zig_t + { kind: :rc, alloc: :heap, has_moved_guard: false, zig_type: zig_t, + rc_variant: :standard, rc_alloc: :heap } when MIR::Cast # Cast is a transparent wrapper; the cleanup is the same as the inner expr. hoist_cleanup_entry(mir.expr, ast_node) @@ -296,6 +304,7 @@ def lower(node) when AST::CopyNode then lower_copy(node) when AST::CloneNode then lower_clone(node) when AST::MoveNode then lower_move(node) + when AST::ShareNode then lower_share(node) when AST::CapabilityWrap then lower_cap_wrap(node) when AST::LinkNode then lower_link(node) when AST::ResolveNode then lower_resolve(node) @@ -5166,8 +5175,10 @@ def lower_clone(node) ti = node.value.type_info func = if ti&.split_open_stream? "splitRetain" - elsif ti&.shared_promise? + elsif ti&.shared_promise? || ti&.shared? "arcRetain" + elsif ti&.multiowned? + "rcRetain" else raise "Internal: lower_clone on unsupported type #{ti&.resolved || node.value.resolved_type}" end @@ -5185,6 +5196,24 @@ def lower_move(node) end end + def lower_share(node) + source_ti = node.value.type_info + source_ti = Type.new(source_ti) if source_ti && !source_ti.is_a?(Type) + raise "Internal: lower_share requires typed source" unless source_ti + + zig_base = rc_payload_zig_type(source_ti) + + if source_ti.shared? + return MIR::RcRetain.new(lower(node.value), zig_base, "arcRetain") + end + + if source_ti.multiowned? + return MIR::SharePromote.new(lower(node.value), zig_base, :heap) + end + + MIR::CapWrap.new(lower(node.value), zig_base, :own_only, nil, nil, "arcCreate", :heap) + end + def lower_cap_wrap(node) inner = lower(node.value) base_type = node.value.resolved_type.to_s @@ -5264,6 +5293,14 @@ def lower_freeze(node) MIR::FreezeExpr.new(rc_data, zig_base) end + def rc_payload_zig_type(ti) + payload = Type.new(ti) + payload.ownership = :affine + payload.provenance = nil + payload.instance_variable_set(:@zig_type_cache, nil) + payload.zig_type + end + # ================================================================ # Declarations # ================================================================ diff --git a/src/mir/ownership_graph.rb b/src/mir/ownership_graph.rb index 484d6fbc..4fc350d9 100644 --- a/src/mir/ownership_graph.rb +++ b/src/mir/ownership_graph.rb @@ -14,7 +14,7 @@ class OwnershipGraph Node = Struct.new(:path, :kind, :state, :type_info, :scope_depth, :line, - :move_line, :move_col, + :move_line, :move_col, :move_action, keyword_init: true) do def live?; state == :live; end def moved?; state == :moved; end @@ -66,7 +66,7 @@ def declare(path, kind: :affine, type_info: nil, scope_depth: 0, line: 0) end # Move ownership from source to target. Invalidates source and all children. - def transfer(from, to, at_token: nil) + def transfer(from, to, at_token: nil, action: :move) source = @nodes[from] return unless source @@ -79,11 +79,15 @@ def transfer(from, to, at_token: nil) # Invalidate source and all owned children; record the move site # (when a token is given) so `clear fix` can locate where the # consuming reference lives. - if at_token - source.move_line = at_token.respond_to?(:line) ? at_token.line : nil - source.move_col = at_token.respond_to?(:column) ? at_token.column : nil - end - invalidate(from) + record_move_site(source, at_token, action) + invalidate(from, source) + end + + def mark_moved(path, at_token: nil, action: :move) + source = @nodes[path] + return unless source + record_move_site(source, at_token, action) + invalidate(path, source) end # Add a borrow edge. Returns nil on success, error string on conflict. @@ -159,15 +163,31 @@ def moved?(path) # Use for branches that won't declare new nodes (IF/ELSE in flat code). def fork_lightweight states = {} - @nodes.each { |k, v| states[k] = v.state } + @nodes.each do |k, v| + states[k] = { + state: v.state, + move_line: v.move_line, + move_col: v.move_col, + move_action: v.move_action, + } + end { node_states: states, edge_count: @edges.size } end # Restore from lightweight snapshot: reset states and truncate edges. def restore_lightweight(snapshot) - snapshot[:node_states].each do |path, state| + snapshot[:node_states].each do |path, saved| node = @nodes[path] - node.state = state if node + next unless node + + if saved.is_a?(Hash) + node.state = saved[:state] + node.move_line = saved[:move_line] + node.move_col = saved[:move_col] + node.move_action = saved[:move_action] + else + node.state = saved + end end target_count = snapshot[:edge_count] while @edges.size > target_count @@ -194,7 +214,12 @@ def merge(other) errors << "variable '#{path}' is moved in one branch but live in the other" when [:live, :dropped], [:dropped, :live] end - mine.state = :moved if theirs.moved? + if theirs.moved? + mine.state = :moved + mine.move_line = theirs.move_line + mine.move_col = theirs.move_col + mine.move_action = theirs.move_action + end end end @@ -227,16 +252,28 @@ def owned_children(path) private - def invalidate(path) + def invalidate(path, move_source = nil) node = @nodes[path] return unless node + if move_source && node != move_source + node.move_line = move_source.move_line + node.move_col = move_source.move_col + node.move_action = move_source.move_action + end node.state = :moved @children[path].each do |child| - @nodes[child]&.state = :moved - invalidate(child) # recurse for nested children + invalidate(child, move_source) # recurse for nested children end end + def record_move_site(node, at_token, action) + node.move_action = action + return unless at_token + + node.move_line = at_token.respond_to?(:line) ? at_token.line : nil + node.move_col = at_token.respond_to?(:column) ? at_token.column : nil + end + def collect_descendants(path, result) @children[path].each do |child| result << child diff --git a/transpile-tests/201_capability_passthrough.cht b/transpile-tests/201_capability_passthrough.cht index f2bc8429..f3bebe7c 100644 --- a/transpile-tests/201_capability_passthrough.cht +++ b/transpile-tests/201_capability_passthrough.cht @@ -1,5 +1,5 @@ -- P1.6 / P1.7 fixture: a caller's @shared:locked binding flows --- transparently to bumpIt's bare param. Inside bumpIt, WITH EXCLUSIVE +-- transparently to bumpIt's bare param. Inside bumpIt, WITH POLYMORPHIC EXCLUSIVE -- on the parameter works because: -- -- - EscapeAnalysis.propagate_caller_sync! (P1.4) stamps the param's @@ -17,7 +17,7 @@ STRUCT Counter { value: Int64 } FN bumpIt(c: Counter) RETURNS Void REQUIRES c: LOCKED -> - WITH EXCLUSIVE c AS inner { + WITH POLYMORPHIC EXCLUSIVE c AS inner { inner.value = inner.value + 1; } END diff --git a/transpile-tests/216_loop_carry_nested.cht b/transpile-tests/216_loop_carry_nested.cht index 84b94a98..c5fc58f4 100644 --- a/transpile-tests/216_loop_carry_nested.cht +++ b/transpile-tests/216_loop_carry_nested.cht @@ -14,7 +14,7 @@ -- block-index state corrupts the GPA free list and crashes on subsequent -- trimExcess -> pop calls. -FN processLines!(n: Int64) RETURNS Void -> +FN processLines!(n: Int64) RETURNS !Void -> MUTABLE outer: Int64 = 0; WHILE outer < 10 DO -- Outer frame-local: forces mark_per_iter on the outer loop. @@ -39,12 +39,12 @@ FN processLines!(n: Int64) RETURNS Void -> END END -FN main() RETURNS Void -> +FN main() RETURNS !Void -> -- Run many times: if restoreLoopMark corrupts the frame arena the crash -- occurs inside this loop. MUTABLE k: Int64 = 0; WHILE k < 200 DO - processLines!(20); + processLines!(20) OR EXIT; k += 1; END print("PASS"); diff --git a/transpile-tests/217_loop_carry_overflow_blocks.cht b/transpile-tests/217_loop_carry_overflow_blocks.cht index f48e2d71..d7806888 100644 --- a/transpile-tests/217_loop_carry_overflow_blocks.cht +++ b/transpile-tests/217_loop_carry_overflow_blocks.cht @@ -15,7 +15,7 @@ -- blocks are kept alive until restoreFrameMark fires at function exit, and the -- block-index arithmetic in getMark/trimExcess is never invoked per-iteration. -FN heavyInner!(outer: Int64) RETURNS String -> +FN heavyInner!(outer: Int64) RETURNS !Void -> MUTABLE resp = ""; MUTABLE i: Int64 = 0; WHILE i < 8 DO @@ -31,16 +31,16 @@ FN heavyInner!(outer: Int64) RETURNS String -> resp = resp + i.toString() + ","; i += 1; END - RETURN resp; + ASSERT resp == "0,1,2,3,4,5,6,7,", "wrong result"; + RETURN; END -FN main() RETURNS Void -> +FN main() RETURNS !Void -> -- 50 outer iterations. Each calls heavyInner! which exercises the doubly- -- nested rewind path with overflow blocks. MUTABLE outer: Int64 = 0; WHILE outer < 50 DO - result = heavyInner!(outer); - ASSERT result == "0,1,2,3,4,5,6,7,", "wrong result"; + heavyInner!(outer) OR EXIT; outer += 1; END print("PASS"); From 56ddebbbe0335353d58355c2fcc1cb7663e85c4f Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 03:22:35 +0000 Subject: [PATCH 02/10] test(coverage): backfill Ruby backend gaps --- spec/mir_lowering_spec.rb | 80 ++++++++ spec/pipeline_backend_coverage_spec.rb | 271 +++++++++++++++++++++++++ spec/thunk_transform_spec.rb | 196 ++++++++++++++++++ src/backends/pipeline_host.rb | 4 +- 4 files changed, 549 insertions(+), 2 deletions(-) create mode 100644 spec/pipeline_backend_coverage_spec.rb diff --git a/spec/mir_lowering_spec.rb b/spec/mir_lowering_spec.rb index 4bf6c6f0..3bfbd579 100644 --- a/spec/mir_lowering_spec.rb +++ b/spec/mir_lowering_spec.rb @@ -2294,3 +2294,83 @@ def make_error_expr(name) end end end + +RSpec.describe "MIRLowering allocation cleanup classification" do + let(:tok) { Lexer::Token.new(:KEYWORD, "test", 1, 1) } + + def lowering(**opts) + MIRLowering.new(**opts) + end + + def typed_node(type) + AST::Identifier.new(tok, "value").tap { |node| node.full_type = type } + end + + it "only treats heap-backed MIR allocation nodes as cleanup-relevant" do + l = lowering + + expect(l.send(:mir_allocates?, MIR::DupeSlice.new(MIR::Ident.new("s"), :heap))).to be(true) + expect(l.send(:mir_allocates?, MIR::DupeSlice.new(MIR::Ident.new("s"), :frame))).to be(false) + expect(l.send(:mir_allocates?, MIR::AllocSlice.new("i64", MIR::Lit.new("4"), :heap))).to be(true) + expect(l.send(:mir_allocates?, MIR::AllocSlice.new("i64", MIR::Lit.new("4"), :frame))).to be(false) + expect(l.send(:mir_allocates?, MIR::HeapCreate.new("Node", MIR::StructInit.new("Node", []), :frame, nil))).to be(true) + end + + it "recurses through casts when deciding whether an expression allocates" do + l = lowering + heap_copy = MIR::DeepCopy.new(MIR::Ident.new("s"), nil, nil, :string, :heap) + frame_copy = MIR::DeepCopy.new(MIR::Ident.new("s"), nil, nil, :string, :frame) + + expect(l.send(:mir_allocates?, MIR::Cast.new(heap_copy, "[]const u8", :as))).to be(true) + expect(l.send(:mir_allocates?, MIR::Cast.new(frame_copy, "[]const u8", :as))).to be(false) + end + + it "classifies direct allocation cleanup entries by allocation shape" do + l = lowering + + expect(l.send(:hoist_cleanup_entry, MIR::DupeSlice.new(MIR::Ident.new("s"), :heap), nil)).to include(kind: :heap_string) + expect(l.send(:hoist_cleanup_entry, MIR::ConcatStr.new([MIR::Ident.new("a"), MIR::Ident.new("b")], :heap, "rt"), nil)).to include(kind: :heap_string) + expect(l.send(:hoist_cleanup_entry, MIR::AllocSlice.new("i64", MIR::Lit.new("4"), :heap), nil)).to include(kind: :takes_slice, elem_zig_type: "i64") + expect(l.send(:hoist_cleanup_entry, MIR::MakeList.new("i64", [MIR::Lit.new("1")], :heap), nil)).to include(kind: :list, zig_type: "std.ArrayListUnmanaged(i64)") + expect(l.send(:hoist_cleanup_entry, MIR::HeapCreate.new("Node", MIR::StructInit.new("Node", []), :heap, nil), nil)).to include(kind: :heap_struct_plain, zig_type: "Node") + expect(l.send(:hoist_cleanup_entry, MIR::ContainerInit.new("std.ArrayListUnmanaged(i64)", :list_empty, :heap, nil), nil)).to include(kind: :list) + end + + it "classifies DeepCopy cleanup entries by copy strategy" do + l = lowering + + expect(l.send(:hoist_cleanup_entry, MIR::DeepCopy.new(MIR::Ident.new("s"), nil, nil, :string, :heap), nil)).to include(kind: :heap_string) + expect(l.send(:hoist_cleanup_entry, MIR::DeepCopy.new(MIR::Ident.new("xs"), nil, "i64", :list_shallow, :heap), nil)).to include(kind: :takes_slice, elem_zig_type: "i64") + expect(l.send(:hoist_cleanup_entry, MIR::DeepCopy.new(MIR::Ident.new("xs"), nil, "Value", :list_deep, :heap), nil)).to include(kind: :takes_slice, elem_zig_type: "Value") + expect(l.send(:hoist_cleanup_entry, MIR::DeepCopy.new(MIR::Ident.new("v"), "Value", nil, :union, :heap), nil)).to include(kind: :non_copy_union, zig_type: "Value") + end + + it "raises when a heap DeepCopy strategy lacks a cleanup mapping" do + expect { + lowering.send(:hoist_cleanup_entry, MIR::DeepCopy.new(MIR::Ident.new("x"), nil, nil, :full_value, :heap), nil) + }.to raise_error(/DeepCopy with unknown strategy :full_value/) + end + + it "classifies capability wrappers and share promotion cleanup entries" do + l = lowering + + locked = MIR::CapWrap.new(MIR::Ident.new("box"), "Box", :sync_only, "lockedCreate", "CheatLib.Locked(Box)", nil, :heap) + rw_locked = MIR::CapWrap.new(MIR::Ident.new("box"), "Box", :sync_only, "rwLockedCreate", "CheatLib.RwLocked(Box)", nil, :heap) + owned = MIR::CapWrap.new(MIR::Ident.new("box"), "Box", :own_only, nil, nil, "arcCreate", :heap) + passthrough = MIR::CapWrap.new(MIR::Ident.new("box"), "Box", :passthrough, nil, nil, nil, :heap) + shared_node = typed_node(Type.new(:Box, ownership: :shared)) + + expect(l.send(:hoist_cleanup_entry, locked, nil)).to include(kind: :locked, zig_type: "CheatLib.Locked(Box)") + expect(l.send(:hoist_cleanup_entry, rw_locked, nil)).to include(kind: :write_locked, zig_type: "CheatLib.RwLocked(Box)") + expect(l.send(:hoist_cleanup_entry, owned, shared_node)).to include(kind: :rc, zig_type: "CheatLib.Arc(Box)") + expect(l.send(:hoist_cleanup_entry, passthrough, nil)).to be_nil + expect(l.send(:hoist_cleanup_entry, MIR::SharePromote.new(MIR::Ident.new("box"), "Box", :heap), shared_node)).to include(kind: :rc, zig_type: "CheatLib.Arc(Box)") + end + + it "delegates cleanup classification through Cast wrappers" do + l = lowering + inner = MIR::DeepCopy.new(MIR::Ident.new("s"), nil, nil, :string, :heap) + + expect(l.send(:hoist_cleanup_entry, MIR::Cast.new(inner, "[]const u8", :as), nil)).to include(kind: :heap_string) + end +end diff --git a/spec/pipeline_backend_coverage_spec.rb b/spec/pipeline_backend_coverage_spec.rb new file mode 100644 index 00000000..d40d50d8 --- /dev/null +++ b/spec/pipeline_backend_coverage_spec.rb @@ -0,0 +1,271 @@ +require "rspec" +require "ostruct" +require_relative "../src/ast/ast" +require_relative "../src/ast/lexer" +require_relative "../src/ast/type" +require_relative "../src/backends/pipeline_generator" +require_relative "../src/backends/pipeline_host" +require_relative "../src/mir/mir" +require_relative "../src/mir/mir_emitter" + +class PipelineBackendCoverageHost + include PipelineGenerator + + attr_accessor :named_bindings + + def initialize + @named_bindings = {} + @soa_rewrite_active = false + @soa_needed_fields = Set.new + end + + def visit(node) + case node + when AST::Identifier then node.name.to_s + when AST::Literal then node.value.inspect + when AST::BinaryOp then "(#{visit(node.left)} #{node.op} #{visit(node.right)})" + when AST::GetField then "#{visit(node.target)}.#{node.field}" + when AST::FuncCall then "#{node.name}(#{node.args.map { |a| visit(a) }.join(', ')})" + else node.respond_to?(:name) ? node.name.to_s : "expr" + end + end + + def transpile_type(type) + case type.to_s + when "Int64" then "i64" + when "Float64" then "f64" + when "Bool", "Boolean" then "bool" + else type.to_s + end + end + + def with_named_binding(clear_name, zig_var) + prev = @named_bindings[clear_name] + @named_bindings[clear_name] = zig_var + yield + ensure + prev.nil? ? @named_bindings.delete(clear_name) : @named_bindings[clear_name] = prev + end + + def with_fiber_capture_map(_entries) + yield + end +end + +RSpec.describe "pipeline backend coverage" do + let(:tok) { Lexer::Token.new(:VAR_ID, "x", 1, 1) } + let(:host) { PipelineBackendCoverageHost.new } + + def elem(resolved = :Int64, zig = "i64") + OpenStruct.new(resolved: resolved, zig_type: zig) + end + + def ptype(**opts) + defaults = { + pool?: false, sharded?: false, soa?: false, list_collection?: false, + fixed_soa?: false, set_collection?: false, dynamic_stream?: false, + open_stream?: false, inf_stream?: false, bounded_stream?: false, + shard_count: 4, element_type: elem, + stream_element_type: elem, open_stream_element_type: elem, + inf_stream_element_type: elem, tense_type: OpenStruct.new(element_type: elem) + } + OpenStruct.new(defaults.merge(opts)) + end + + def id(name, type: nil) + node = AST::Identifier.new(tok, name) + if type + node.define_singleton_method(:full_type) { type } + node.define_singleton_method(:type_info) { type } + end + node + end + + def lit(value, type = :Int64) + node = AST::Literal.new(tok, :NUMBER, value, nil) + node.full_type = type + node + end + + describe PipelineGenerator do + it "restores full pipeline context including explicit SOA mode" do + host.instance_variable_set(:@placeholder_name, "outer") + host.instance_variable_set(:@acc_placeholder, "outer_acc") + host.instance_variable_set(:@soa_rewrite_active, false) + host.instance_variable_set(:@soa_needed_fields, Set[:old]) + + result = host.with_pipeline_context(placeholder: "inner", acc: "acc", soa: true) do + expect(host.instance_variable_get(:@placeholder_name)).to eq("inner") + expect(host.instance_variable_get(:@acc_placeholder)).to eq("acc") + expect(host.instance_variable_get(:@soa_rewrite_active)).to be true + host.instance_variable_get(:@soa_needed_fields) << :field + "ok" + end + + expect(result).to eq("ok") + expect(host.instance_variable_get(:@placeholder_name)).to eq("outer") + expect(host.instance_variable_get(:@acc_placeholder)).to eq("outer_acc") + expect(host.instance_variable_get(:@soa_rewrite_active)).to be false + expect(host.instance_variable_get(:@soa_needed_fields)).to eq(Set[:old]) + end + + it "builds pipe item materializers for every collection layout branch" do + expect(host.build_pipe_items_block(ptype(pool?: true, sharded?: true), "rt.frameAlloc()")).to include("shards[__psi].slots") + expect(host.build_pipe_items_block(ptype(pool?: true, soa?: true), "rt.frameAlloc()")).to include("data.get(__psi)") + expect(host.build_pipe_items_block(ptype(list_collection?: true, soa?: true), "rt.frameAlloc()")).to include("pipe_src_list.data.len") + expect(host.build_pipe_items_block(ptype(pool?: true), "rt.frameAlloc()")).to include("pipe_src_list.slots") + expect(host.build_pipe_items_block(ptype(list_collection?: true, sharded?: true), "rt.frameAlloc()")).to include("appendSlice") + expect(host.build_pipe_items_block(ptype, "rt.frameAlloc()")).to include("pipe_src_list[0..]") + end + + it "emits standard and SOA pipeline macro wrappers" do + list = id("items", type: ptype(list_collection?: true)) + storage = OpenStruct.new(storage: :heap) + zig = host.transpile_pipeline_macro(list, storage, res_type: "Int64") { |alloc| "break :#{host.instance_variable_get(:@current_pipe_label)} #{alloc};" } + expect(zig).to include("rt.heapAlloc()") + expect(zig).to include("CheatLib.makeList(i64") + + soa_type = ptype(list_collection?: true, soa?: true) + soa_list = id("points", type: soa_type) + host.instance_variable_set(:@soa_needed_fields, Set[:x]) + soa_zig = host.transpile_pipeline_macro(soa_list, OpenStruct.new(storage: :frame)) { "for (pipe_items) |it| {\n _ = it;\n}" } + expect(soa_zig).to include("const __soa_x") + expect(soa_zig).to include("for (0..@intCast(__soa_src.data.len)) |__soa_i|") + end + + it "parses batch-window time options" do + expect(host.batch_window_timeout_ns(OpenStruct.new(options: {}))).to eq("0") + expect(host.batch_window_timeout_ns(OpenStruct.new(options: { "time" => OpenStruct.new(value: "2.5s") }))).to eq("2500000000") + expect(host.batch_window_timeout_ns(OpenStruct.new(options: { "time" => OpenStruct.new(value: "bad") }))).to eq("0") + end + + it "uses numeric sentinel values by result family" do + expect(host.agg_minmax_sentinels("f64", :Float64)).to eq(["std.math.floatMax(f64)", "-std.math.floatMax(f64)"]) + expect(host.agg_minmax_sentinels("i64", :Int64)).to eq(["std.math.maxInt(i64)", "std.math.minInt(i64)"]) + expect(host.agg_minmax_sentinels("u64", :UInt64)).to eq(["std.math.maxInt(u64)", "0"]) + expect(host.agg_minmax_sentinels("Custom", :Custom)).to eq(["std.math.floatMax(f64)", "-std.math.floatMax(f64)"]) + end + + it "emits representative operator bodies" do + list_type = ptype(list_collection?: true) + list = id("items", type: list_type) + expr = id("_", type: Type.new(:Int64)) + expr.full_type = Type.new(:Int64) + smooth = OpenStruct.new(storage: :frame, full_type: Type.new(:Int64), observable_dest: nil) + + expect(host.transpile_where_filter(list, expr)).to include("const matches") + expect(host.transpile_take_while(list, expr, smooth)).to include("if (!matches) break") + expect(host.transpile_skip(list, OpenStruct.new(count: lit(2)), smooth)).to include("skip_actual") + expect(host.transpile_limit(list, OpenStruct.new(count: lit(2)), smooth)).to include("lim_actual") + expect(host.transpile_sum(list, OpenStruct.new(expression: expr), smooth)).to include("sum_result") + expect(host.transpile_any(list, OpenStruct.new(expression: expr), smooth)).to include("any_result") + expect(host.transpile_all(list, OpenStruct.new(expression: expr), smooth)).to include("all_result") + expect(host.transpile_count(list, OpenStruct.new(expression: expr), smooth)).to include("count_result") + end + + it "raises if observable destinations reach legacy aggregate emitters" do + list = id("items", type: ptype(list_collection?: true)) + expr = id("_", type: Type.new(:Int64)) + smooth = OpenStruct.new(storage: :frame, full_type: Type.new(:Int64), observable_dest: true) + + expect { host.transpile_sum(list, OpenStruct.new(expression: expr), smooth) }.to raise_error(/observable_dest/) + expect { host.transpile_count(list, OpenStruct.new(expression: expr), smooth) }.to raise_error(/observable_dest/) + expect { host.transpile_any(list, OpenStruct.new(expression: expr), smooth) }.to raise_error(/observable_dest/) + expect { host.transpile_all(list, OpenStruct.new(expression: expr), smooth) }.to raise_error(/observable_dest/) + end + end + + describe PipelineHost do + let(:lowering) do + Class.new do + attr_accessor :fn_sigs, :shard_context + + def initialize + @fn_sigs = {} + end + + def lower(node) + MIR::Ident.new(node.respond_to?(:name) ? node.name.to_s : "lowered") + end + + def lower_body(nodes) + nodes.map { |n| lower(n) } + end + + def with_fiber_capture_map(_entries, capture_symbols: nil, rt_override: "__rt") + yield + end + + def task_config_zig(_stack_size, _computed_tier = nil) + ".{}" + end + end.new + end + + let(:pipeline_host) { PipelineHost.new(lowering: lowering, emitter: MIREmitter.new) } + + it "scopes optional named bindings" do + expect(pipeline_host.with_optional_named_binding(nil, "__ignored") { pipeline_host.instance_variable_get(:@named_bindings).dup }).to eq({}) + result = pipeline_host.with_optional_named_binding("@u", "__pipe_u") do + pipeline_host.send(:substitute_placeholders, id("@u")).name + end + expect(result).to eq("__pipe_u") + expect(pipeline_host.instance_variable_get(:@named_bindings)).to eq({}) + end + + it "visits placeholders, join params, named bindings, SOA fields, and accumulators" do + pipeline_host.instance_variable_set(:@placeholder_name, "__it") + expect(pipeline_host.visit(id("_"))).to eq("__it") + + pipeline_host.instance_variable_set(:@join_param_map, { "left" => "__jl" }) + expect(pipeline_host.visit(id("left"))).to eq("__jl") + + pipeline_host.instance_variable_set(:@named_bindings, { "@u" => "__pipe_u" }) + expect(pipeline_host.visit(id("@u"))).to eq("__pipe_u") + + pipeline_host.instance_variable_set(:@soa_rewrite_active, true) + expect(pipeline_host.visit(AST::GetField.new(tok, id("_"), :x))).to eq("__soa_x[__soa_i]") + + assign = AST::Assignment.new(tok, AST::GetField.new(tok, id("_"), :x), lit(1)) + expect(pipeline_host.visit(assign)).to include("__soa_x[__soa_i] =") + + pipeline_host.instance_variable_set(:@acc_placeholder, "__acc") + expect(pipeline_host.visit(id("acc"))).to eq("__acc") + end + + it "substitutes placeholders through common expression nodes" do + pipeline_host.instance_variable_set(:@placeholder_name, "__it") + pipeline_host.instance_variable_set(:@acc_placeholder, "__acc") + pipeline_host.instance_variable_set(:@join_param_map, { "r" => "__jr" }) + pipeline_host.instance_variable_set(:@named_bindings, { "@u" => "__pipe_u" }) + + expect(pipeline_host.send(:substitute_placeholders, AST::FuncCall.new(tok, "f", [id("_")])).args.first.name).to eq("__it") + expect(pipeline_host.send(:substitute_placeholders, AST::MethodCall.new(tok, id("_"), "m", [id("acc")])).object.name).to eq("__it") + expect(pipeline_host.send(:substitute_placeholders, AST::BinaryOp.new(tok, id("_"), :ADD, id("acc"))).right.name).to eq("__acc") + expect(pipeline_host.send(:substitute_placeholders, AST::GetIndex.new(tok, id("@u"), id("r"))).target.name).to eq("__pipe_u") + expect(pipeline_host.send(:substitute_placeholders, AST::UnaryOp.new(tok, :NOT, id("_"))).right.name).to eq("__it") + expect(pipeline_host.send(:substitute_placeholders, AST::StructLit.new(tok, "Box", { "x" => id("_") })).fields["x"].name).to eq("__it") + expect(pipeline_host.send(:substitute_placeholders, AST::HashLit.new(tok, { "k" => id("_") })).pairs["k"].name).to eq("__it") + expect(pipeline_host.send(:substitute_placeholders, AST::Assert.new(tok, id("_"), nil)).condition.name).to eq("__it") + end + + it "substitutes assignment and bind targets plus SOA EACH fields" do + pipeline_host.instance_variable_set(:@placeholder_name, "__it") + pipeline_host.instance_variable_set(:@soa_each_mode, true) + gf = AST::GetField.new(tok, id("_"), :x) + expect(pipeline_host.send(:substitute_placeholders, gf).name).to eq("__soa_x[__soa_i]") + + bind = AST::BindExpr.new(tok, AST::GetField.new(tok, id("_"), :x), nil, id("_")) + expect(pipeline_host.send(:substitute_placeholders, bind).name.name).to eq("__soa_x[__soa_i]") + + assign = AST::Assignment.new(tok, AST::GetField.new(tok, id("_"), :x), id("_")) + expect(pipeline_host.send(:substitute_placeholders, assign).name.name).to eq("__soa_x[__soa_i]") + end + + it "detects placeholder usage in nested statement trees" do + stmt = AST::FuncCall.new(tok, "f", [AST::BinaryOp.new(tok, id("x"), :ADD, id("_"))]) + expect(pipeline_host.send(:ast_stmts_use_placeholder?, [stmt])).to be true + expect(pipeline_host.send(:ast_stmts_use_placeholder?, [AST::FuncCall.new(tok, "f", [id("x")])])).to be false + end + end +end diff --git a/spec/thunk_transform_spec.rb b/spec/thunk_transform_spec.rb index eae0ec7a..453fe1d4 100644 --- a/spec/thunk_transform_spec.rb +++ b/spec/thunk_transform_spec.rb @@ -1,4 +1,5 @@ require "rspec" +require "ostruct" require_relative "../src/mir/thunk_transform" require_relative "../src/ast/lexer" require_relative "../src/ast/parser" @@ -91,6 +92,201 @@ end end +RSpec.describe "ThunkTransform emit coverage" do + let(:tok) { Lexer::Token.new(:IDENT, "x", 1, 1) } + + class FakeThunkLowering + OP_TO_ZIG = { + ADD: "+", + SUB: "-", + MUL: "*", + DIV: "/", + LT_EQ: "<=", + }.freeze + + def lower(ast) + case ast + when AST::Identifier + MIR::Ident.new(ast.name) + when AST::Literal + MIR::Lit.new(ast.value.to_s) + when AST::BinaryOp + MIR::BinOp.new(OP_TO_ZIG.fetch(ast.op, ast.op.to_s), lower(ast.left), lower(ast.right)) + else + MIR::Ident.new(ast.to_s) + end + end + + def emit_expr(mir) + case mir + when MIR::Ident then mir.name + when MIR::Lit then mir.value + when MIR::BinOp then "#{emit_expr(mir.left)} #{mir.op} #{emit_expr(mir.right)}" + else mir.to_s + end + end + end + + def id(name) + AST::Identifier.new(tok, name) + end + + def int(value) + AST::Literal.new(tok, :INT64, value) + end + + def bin(left, op, right) + AST::BinaryOp.new(tok, left, op, right) + end + + def fn(name, params:, return_type: "i64", plan: nil, mutual_plan: nil, tight: false) + OpenStruct.new( + name: name, + params: params, + return_type: return_type, + thunk_plan: plan, + mutual_thunk_plan: mutual_plan, + tight_reentrance: tight + ) + end + + def param(name, type = "i64") + { name: name, type: type } + end + + it "emits heap-CPS trampoline scaffolding for simple recursive thunks" do + plan = OpenStruct.new( + base_cases: [{ cond_ast: bin(id("n"), :LT_EQ, int(1)), value_ast: int(1) }], + recurse_args: [bin(id("n"), :SUB, int(1))], + combine_lhs: id("n"), + combine_op: :MUL + ) + zig = ThunkTransform::Emit.emit_trampoline( + fn("factorial", params: [param("n")], plan: plan), + FakeThunkLowering.new + ) + + expect(zig).to include("const Frame = struct") + expect(zig).to include("rt.checkYield();") + expect(zig).to include("const child = rt.heapAlloc().create(Frame) catch unreachable;") + expect(zig).to include(".n = current.n - 1") + expect(zig).to include("const result: i64 = current.n * current.child_result;") + expect(zig).to include("rt.heapAlloc().destroy(current);") + end + + it "skips the scheduler yield line for tight thunk trampolines" do + plan = OpenStruct.new( + base_cases: [{ cond_ast: id("done"), value_ast: id("acc") }], + recurse_args: [id("n"), id("acc")], + combine_lhs: id("acc"), + combine_op: :ADD + ) + zig = ThunkTransform::Emit.emit_trampoline( + fn("sum", params: [param("n"), param("acc")], plan: plan, tight: true), + FakeThunkLowering.new + ) + + expect(zig).to include("// (TIGHT: scheduler yield-check skipped)") + expect(zig).not_to include("rt.checkYield();") + end + + it "raises directed errors for invalid simple thunk plans" do + expect { + ThunkTransform::Emit.emit_trampoline(fn("missing", params: []), FakeThunkLowering.new) + }.to raise_error(/has no thunk_plan/) + + bad_op = OpenStruct.new(base_cases: [], recurse_args: [], combine_lhs: id("x"), combine_op: :MOD) + expect { + ThunkTransform::Emit.emit_trampoline(fn("bad_op", params: [], plan: bad_op), FakeThunkLowering.new) + }.to raise_error(/unsupported op MOD/) + + bad_arity = OpenStruct.new(base_cases: [], recurse_args: [id("x")], combine_lhs: id("x"), combine_op: :ADD) + expect { + ThunkTransform::Emit.emit_trampoline(fn("bad_arity", params: [], plan: bad_arity), FakeThunkLowering.new) + }.to raise_error(/arg\/param count mismatch/) + end + + it "qualifies only bare frame parameters" do + fn_node = fn("sample", params: [param("n"), param("name")]) + + expect(ThunkTransform::Emit.qualify_params("n + obj.n + name_extra + name", fn_node)). + to eq("current.n + obj.n + name_extra + current.name") + end + + it "emits mutual-recursion trampolines with tagged frame variants" do + even = fn("even", params: [param("n")]) + odd = fn("odd", params: [param("n")]) + even_plan = OpenStruct.new( + base_cases: [{ cond_ast: bin(id("n"), :LT_EQ, int(0)), value_ast: int(1) }], + target_fn: "odd", + target_args: [bin(id("n"), :SUB, int(1))] + ) + odd_plan = OpenStruct.new( + base_cases: [{ cond_ast: bin(id("n"), :LT_EQ, int(0)), value_ast: int(0) }], + target_fn: "even", + target_args: [bin(id("n"), :SUB, int(1))] + ) + cycle = [even, odd] + even.mutual_thunk_plan = OpenStruct.new(cycle_fns: cycle, own_plan: even_plan) + odd.mutual_thunk_plan = OpenStruct.new(cycle_fns: cycle, own_plan: odd_plan) + + zig = ThunkTransform::Emit.emit_mutual_trampoline(even, FakeThunkLowering.new) + + expect(zig).to include("const Frame = union(enum)") + expect(zig).to include("even: struct") + expect(zig).to include("odd: struct") + expect(zig).to include("var current: Frame = .{ .even") + expect(zig).to include("current = .{ .odd = .{ .n = f.n - 1 } };") + end + + it "raises directed errors for invalid mutual thunk plans" do + missing = fn("missing", params: []) + expect { + ThunkTransform::Emit.emit_mutual_trampoline(missing, FakeThunkLowering.new) + }.to raise_error(/has no mutual_thunk_plan/) + + cf = fn("even", params: [param("n")]) + cf.mutual_thunk_plan = OpenStruct.new( + cycle_fns: [cf], + own_plan: OpenStruct.new(base_cases: [], target_fn: "odd", target_args: []) + ) + expect { + ThunkTransform::Emit.emit_mutual_arm(cf, cf.mutual_thunk_plan, "i64", FakeThunkLowering.new) + }.to raise_error(/cycle member 'odd' not found/) + + target = fn("odd", params: [param("n")]) + cf.mutual_thunk_plan = OpenStruct.new( + cycle_fns: [cf, target], + own_plan: OpenStruct.new(base_cases: [], target_fn: "odd", target_args: []) + ) + expect { + ThunkTransform::Emit.emit_mutual_arm(cf, cf.mutual_thunk_plan, "i64", FakeThunkLowering.new) + }.to raise_error(/target arg\/param count mismatch/) + end + + it "keeps the legacy build hook as a no-op" do + expect(ThunkTransform::Emit.build([], nil, FakeThunkLowering.new, nil)).to be_nil + end +end + +RSpec.describe "ThunkTransform recursive splitter helpers" do + let(:tok) { Lexer::Token.new(:IDENT, "f", 1, 1) } + + it "walks arrays while looking for mutual-recursion calls" do + call = AST::FuncCall.new(tok, "even", []) + + expect(ThunkTransform::RecursiveSplitter.contains_any_call?([AST::Identifier.new(tok, "x"), call], ["even"])).to be(true) + expect(ThunkTransform::RecursiveSplitter.contains_any_call?([AST::Identifier.new(tok, "x")], ["even"])).to be(false) + end + + it "walks arrays while looking for self-recursion calls" do + call = AST::FuncCall.new(tok, "fact", []) + + expect(ThunkTransform::RecursiveSplitter.contains_self_call?([AST::Identifier.new(tok, "x"), call], "fact")).to be(true) + expect(ThunkTransform::RecursiveSplitter.contains_self_call?([AST::Identifier.new(tok, "x")], "fact")).to be(false) + end +end + RSpec.describe "EFFECTS REENTRANT:THUNK validation" do def annotate(source) tokens = Lexer.new(source).tokenize diff --git a/src/backends/pipeline_host.rb b/src/backends/pipeline_host.rb index f57a5208..5914da67 100644 --- a/src/backends/pipeline_host.rb +++ b/src/backends/pipeline_host.rb @@ -280,8 +280,8 @@ def substitute_placeholders(node) return new_assign end when AST::UnaryOp - new_operand = substitute_placeholders(node.operand) - if new_operand != node.operand + new_operand = substitute_placeholders(node.right) + if new_operand != node.right new_uo = AST::UnaryOp.new(node.token, node.op, new_operand) copy_type_info(node, new_uo) return new_uo From 67200639e2b86f65c194d37557f25a621040adf4 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 03:27:06 +0000 Subject: [PATCH 03/10] test(tools): move doctor into tools --- clear | 2 +- docs/agents/atomicptr-review.md | 4 +- spec/atomic_migration_suggester_spec.rb | 2 +- spec/atomic_ptr_migration_suggester_spec.rb | 2 +- spec/doctor_spec.rb | 80 +++++++++++++++++++++ src/{ => tools}/doctor.rb | 8 +-- 6 files changed, 89 insertions(+), 9 deletions(-) create mode 100644 spec/doctor_spec.rb rename src/{ => tools}/doctor.rb (99%) diff --git a/clear b/clear index 02a99599..d5c1a80c 100755 --- a/clear +++ b/clear @@ -1111,7 +1111,7 @@ when 'profile' puts " MVCC: #{mvcc_file}" if File.exist?(mvcc_file) when 'doctor' - require_relative 'src/doctor' + require_relative 'src/tools/doctor' Doctor.run(ARGV[1]) when 'fix' diff --git a/docs/agents/atomicptr-review.md b/docs/agents/atomicptr-review.md index 36da5f7f..9870893f 100644 --- a/docs/agents/atomicptr-review.md +++ b/docs/agents/atomicptr-review.md @@ -271,7 +271,7 @@ does single-cell whole-struct commits) is deferred. 2. Extend `AtomicPtrMigrationSuggester` to recognize `@shared:versioned` sources whose WITH SNAPSHOT MUTABLE bodies are whole-struct replace. -3. New `emit_atomic_ptr_upgrade_from_mvcc!` in doctor.rb. +3. New `emit_atomic_ptr_upgrade_from_mvcc!` in `src/tools/doctor.rb`. **Risk:** Medium — touches mvcc-profile + the suggester. Not a correctness concern; a feature gap. @@ -359,7 +359,7 @@ is intentional — different migration targets. | C. Consolidate two migration suggesters | Medium | 2-4h | src/tools/ | | J. Dedicated `:atomic_ptr` cleanup kind | Medium | 1h | promotion_plan.rb, mir_emitter.rb | | E. Escape-via-RETURN test + investigate | Medium | 2h | transpile-tests/, possibly promotion_plan.rb | -| H. Doctor MVCC→AtomicPtr upgrade signal | Medium | 3h | mvcc-profile.zig, suggester, doctor.rb | +| H. Doctor MVCC→AtomicPtr upgrade signal | Medium | 3h | mvcc-profile.zig, suggester, src/tools/doctor.rb | | G. VOPR test for AtomicPtr | n/a | n/a | (no action — deferred correctly) | | K. Consolidate doctor candidate lists | n/a | n/a | (no action — intentional parallelism) | diff --git a/spec/atomic_migration_suggester_spec.rb b/spec/atomic_migration_suggester_spec.rb index 81331faf..f1af9301 100644 --- a/spec/atomic_migration_suggester_spec.rb +++ b/spec/atomic_migration_suggester_spec.rb @@ -4,7 +4,7 @@ # Atomics M1.9 / M1.10: static eligibility check for the # @shared:locked -> @shared:atomic migration. Tested in isolation; -# the doctor wires the runtime contention signal in src/doctor.rb. +# the doctor wires the runtime contention signal in src/tools/doctor.rb. RSpec.describe "AtomicMigrationSuggester (M1.9/M1.10 static eligibility)" do def candidates(src) AtomicMigrationSuggester.analyze(src) diff --git a/spec/atomic_ptr_migration_suggester_spec.rb b/spec/atomic_ptr_migration_suggester_spec.rb index 7c42120a..f3bfde09 100644 --- a/spec/atomic_ptr_migration_suggester_spec.rb +++ b/spec/atomic_ptr_migration_suggester_spec.rb @@ -5,7 +5,7 @@ # AtomicPtr M3.15: static eligibility check for the @shared:writeLocked # / @shared:locked (struct) -> @indirect:atomic migration. Tested in # isolation; the doctor wires the runtime contention signal in -# src/doctor.rb (M3.16). +# src/tools/doctor.rb (M3.16). RSpec.describe "AtomicPtrMigrationSuggester (M3.15 static eligibility)" do def candidates(src) AtomicPtrMigrationSuggester.analyze(src) diff --git a/spec/doctor_spec.rb b/spec/doctor_spec.rb new file mode 100644 index 00000000..1afdcd24 --- /dev/null +++ b/spec/doctor_spec.rb @@ -0,0 +1,80 @@ +require "rspec" +require "tmpdir" +require "stringio" +require_relative "../src/tools/doctor" + +RSpec.describe Doctor do + def capture_stdout + old_stdout = $stdout + out = StringIO.new + $stdout = out + yield + out.string + ensure + $stdout = old_stdout + end + + it "loads from the tools path" do + expect(defined?(Doctor)).to eq("constant") + end + + it "returns the first profile saturation warning without the comment prefix" do + Dir.mktmpdir do |dir| + file = File.join(dir, "alloc.txt") + File.write(file, <<~PROFILE) + # header + # WARNING: 12 samples dropped because the table saturated + # WARNING: ignored second warning + PROFILE + + expect(Doctor.saturation_warning(file)).to eq("WARNING: 12 samples dropped because the table saturated") + expect(Doctor.saturation_warning(File.join(dir, "missing.txt"))).to be_nil + end + end + + it "prints a usage error and exits for a missing profile directory" do + old_stderr = $stderr + stderr = StringIO.new + $stderr = stderr + expect { + Doctor.run(nil) + }.to raise_error(SystemExit) { |err| expect(err.status).to eq(1) } + $stderr = old_stderr + + expect(stderr.string).to include("Usage: clear doctor ") + ensure + $stderr = old_stderr if old_stderr + end + + it "prints a clear no-profile message for an empty profile directory" do + Dir.mktmpdir do |dir| + out = capture_stdout { Doctor.section_heap(dir, nil) } + + expect(out).to include("No heap profile found") + expect(out).to include(File.join(dir, "alloc.txt")) + end + end + + it "parses heap profile rows, sorts by bytes, and surfaces saturation warnings" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "alloc.txt"), <<~PROFILE) + # total_allocs 12345 + # WARNING: 2 allocation samples dropped + 0xaaa 10 3200 10 3200 0 + 0xbbb 200 4000 0 0 4000 + PROFILE + + sites = nil + out = capture_stdout do + sites, = Doctor.section_heap(dir, nil) + end + + expect(sites.map { |s| s[:addr] }).to eq(["0xbbb", "0xaaa"]) + expect(sites.first).to include(allocs: 200, bytes: 4000, frees: 0, free_bytes: 0, live: 4000) + expect(out).to include("Allocation Profile (12,345 allocs)") + expect(out).to include("*** WARNING: 2 allocation samples dropped") + expect(out).to include("Top sites by bytes:") + expect(out).to include("(heap rc) = @multiowned RC allocation tracked by rcCreate") + end + end +end diff --git a/src/doctor.rb b/src/tools/doctor.rb similarity index 99% rename from src/doctor.rb rename to src/tools/doctor.rb index bf97824e..3d0b6dd5 100644 --- a/src/doctor.rb +++ b/src/tools/doctor.rb @@ -500,7 +500,7 @@ def emit_atomic_migration!(profile_dir) src_path = File.join(profile_dir, 'source.cht') return unless File.exist?(src_path) - require_relative "tools/atomic_migration_suggester" + require_relative "atomic_migration_suggester" candidates = AtomicMigrationSuggester.analyze(File.read(src_path)) return if candidates.empty? @@ -536,7 +536,7 @@ def emit_atomic_ptr_migration!(profile_dir) src_path = File.join(profile_dir, 'source.cht') return unless File.exist?(src_path) - require_relative "tools/atomic_ptr_migration_suggester" + require_relative "atomic_ptr_migration_suggester" candidates = AtomicPtrMigrationSuggester.analyze(File.read(src_path)) return if candidates.empty? @@ -725,7 +725,7 @@ def emit_atomic_ptr_upgrade_from_mvcc!(profile_dir) src_path = File.join(profile_dir, 'source.cht') return unless File.exist?(src_path) - require_relative "tools/atomic_ptr_migration_suggester" + require_relative "atomic_ptr_migration_suggester" candidates = AtomicPtrMigrationSuggester.analyze(File.read(src_path)) versioned_candidates = candidates.select { |c| c[:sync] == :versioned } return if versioned_candidates.empty? @@ -767,7 +767,7 @@ def section_atomic_escape(profile_dir) src_path = File.join(profile_dir, 'source.cht') return unless File.exist?(src_path) - require_relative "tools/atomic_escape_suggester" + require_relative "atomic_escape_suggester" findings = AtomicEscapeSuggester.analyze(File.read(src_path)) return if findings.empty? From 21c0cac8379fe5d1fc4815ad9371b62711893acc Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 12:26:34 +0000 Subject: [PATCH 04/10] fix(ci): keep SplitStream hammer out of fast Zig lane --- zig/runtime/stream-test.zig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/zig/runtime/stream-test.zig b/zig/runtime/stream-test.zig index 0fab2825..2cf1dc08 100644 --- a/zig/runtime/stream-test.zig +++ b/zig/runtime/stream-test.zig @@ -12,6 +12,7 @@ const CheatLib = CheatHeader.CheatLib; const Runtime = CheatHeader.Runtime; const EbrContext = CheatHeader.EbrContext; const compat = @import("../lib/compat.zig"); +const build_options = @import("build_options"); const fc = @import("fiber-core.zig"); const fp = CheatHeader.scheduler; const fm = CheatHeader.fiber_memory; @@ -24,7 +25,7 @@ const qs = @import("queues.zig"); // the 64 KB Large tier so the test is exercising the data structure, // not the instrumented stack budget. const test_stack_size: fc.StackSize = - if (@import("build_options").coverage or @import("build_options").tsan) .Large else .Standard; + if (build_options.coverage or build_options.tsan) .Large else .Standard; fn fakeSched() *CheatHeader.scheduler.Scheduler { return @ptrFromInt(@as(usize, @alignOf(CheatHeader.scheduler.Scheduler))); @@ -661,10 +662,12 @@ test "SplitStream wakes multiple waiting fibers as items arrive" { } test "SplitStream survives multithreaded spawnBest pubsub hammer" { + if (!build_options.tsan and !build_options.coverage) return error.SkipZigTest; + const allocator = std.testing.allocator; - const subscriber_count = 16; - const message_count = 4096; - const worker_count = 7; + const subscriber_count = if (build_options.coverage) 3 else 16; + const message_count = if (build_options.coverage) 64 else 4096; + const worker_count = if (build_options.coverage) 2 else 7; var global_ctx = EbrContext{}; defer global_ctx.deinit(allocator); From a45335c7849815cb7b5912dc5f3add4e4faa6607 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 13:55:31 +0000 Subject: [PATCH 05/10] feat(sync): preserve polymorphic shared returns --- spec/generics_spec.rb | 156 +++++++++++++++++++++ spec/primitive_atomic_function_spec.rb | 134 ++++++++++++++++++ spec/share_spec.rb | 62 ++++++++ src/annotator-helpers/capabilities.rb | 13 +- src/annotator-helpers/function_analysis.rb | 67 +++++++++ src/annotator-helpers/generic_analysis.rb | 98 ++++++++++++- src/annotator.rb | 103 +++++++++++--- src/ast/parser.rb | 57 ++++++-- src/ast/source_error.rb | 7 +- src/ast/type.rb | 35 ++++- src/mir/escape_analysis.rb | 27 +++- src/mir/mir_lowering.rb | 53 ++++++- 12 files changed, 764 insertions(+), 48 deletions(-) create mode 100644 spec/primitive_atomic_function_spec.rb diff --git a/spec/generics_spec.rb b/spec/generics_spec.rb index 0ba559da..cd52717d 100644 --- a/spec/generics_spec.rb +++ b/spec/generics_spec.rb @@ -463,6 +463,116 @@ def call_src(fn_code, call_code) call = fn.body[1].value expect(call.generic_type_args).to eq([:Bool]) end + + it "preserves capability axes when T is inferred directly from an argument" do + src = <<~CLEAR + STRUCT Box { value: Int64 } + FN identity(x: T) RETURNS T -> RETURN x; END + FN main() RETURNS Void -> + b = Box{ value: 1 } @shared:locked; + got = identity(b); + RETURN; + END + CLEAR + + ast = run(src) + got = ast.statements.last.body[1] + call = got.value + + expect(got.type_info).to be_shared + expect(got.type_info.sync).to eq(:locked) + expect(call.generic_type_args.first).to be_a(Type) + expect(call.generic_type_args.first).to be_shared + expect(call.generic_type_args.first.sync).to eq(:locked) + end + + it "preserves capability axes through Cache get/set" do + src = <<~CLEAR + STRUCT Box { value: Int64 } + STRUCT Cache { value: T } + FN get(c: Cache) RETURNS T -> RETURN c.value; END + FN set!(MUTABLE c: Cache, TAKES v: T) RETURNS Void -> + c.value = v; + RETURN; + END + FN main() RETURNS Void -> + b = Box{ value: 1 } @shared:locked; + MUTABLE c = Cache{ value: b }; + got = get(c); + set!(c, got); + RETURN; + END + CLEAR + + ast = run(src) + main = ast.statements.last + cache = main.body[1] + got = main.body[2] + set_call = main.body[3] + + cache_arg = cache.type_info.generic_args.first + expect(cache_arg).to be_shared + expect(cache_arg.sync).to eq(:locked) + + expect(got.type_info).to be_shared + expect(got.type_info.sync).to eq(:locked) + + expect(got.value.generic_type_args.first).to be_shared + expect(got.value.generic_type_args.first.sync).to eq(:locked) + expect(set_call.generic_type_args.first).to be_shared + expect(set_call.generic_type_args.first.sync).to eq(:locked) + end + + it "infers implicit T for shared-family input and return" do + src = <<~CLEAR + STRUCT Box { value: Int64 } + FN keep(x: T @shared) RETURNS T @shared + REQUIRES x: LOCKED | VERSIONED + -> + RETURN x; + END + FN main() RETURNS Void -> + b = Box{ value: 1 } @shared:locked; + got = keep(b); + RETURN; + END + CLEAR + + ast = run(src) + keep = ast.statements.find { |s| s.is_a?(AST::FunctionDef) && s.name == "keep" } + got = ast.statements.last.body[1] + + expect(keep.type_params).to eq(["T"]) + expect(got.type_info).to be_shared + expect(got.type_info.sync).to eq(:locked) + expect(got.value.generic_type_args.first).to be_a(Type) + end + + it "returns bare T when copying out through a polymorphic shared access gate" do + src = <<~CLEAR + STRUCT Box { value: Int64 } + FN copyOut(x: T @shared) RETURNS !T -> + WITH POLYMORPHIC x AS y { RETURN COPY y; } + END + FN main() RETURNS !Void -> + b = Box{ value: 1 } @shared:locked; + got = copyOut(b) OR EXIT; + RETURN; + END + CLEAR + + ast = run(src) + copy_out = ast.statements.find { |s| s.is_a?(AST::FunctionDef) && s.name == "copyOut" } + ret = copy_out.body.first.body.first + got = ast.statements.last.body[1] + + expect(ret.value.type_info.resolved).to eq(:T) + expect(ret.value.type_info.ownership).to eq(:affine) + expect(ret.value.type_info.sync).to be_nil + expect(got.type_info.resolved).to eq(:Box) + expect(got.type_info.ownership).to eq(:affine) + expect(got.type_info.sync).to be_nil + end end describe "generic function error messages" do @@ -550,6 +660,52 @@ def fn_err_src(fn_code, call_code = "PASS") out = ZigTranspiler.new.transpile(src) expect(out).to include("Pair(T)") end + + it "emits capability-preserving type args for Cache get/set" do + src = <<~CLEAR + STRUCT Box { value: Int64 } + STRUCT Cache { value: T } + FN get(c: Cache) RETURNS T -> RETURN c.value; END + FN set!(MUTABLE c: Cache, TAKES v: T) RETURNS Void -> + c.value = v; + RETURN; + END + FN main() RETURNS Void -> + b = Box{ value: 1 } @shared:locked; + MUTABLE c = Cache{ value: b }; + got = get(c); + set!(c, got); + RETURN; + END + CLEAR + + out = ZigTranspiler.new.transpile(src) + expect(out).to include("Cache(CheatLib.Arc(CheatLib.Locked(Box)))") + expect(out).to include("get(CheatLib.Arc(CheatLib.Locked(Box)), c)") + expect(out).to include("set(CheatLib.Arc(CheatLib.Locked(Box)), c, got)") + expect(out).to include("CheatLib.arcRetain(CheatLib.Locked(Box), b)") + end + + it "monomorphizes shared-family returns from the input synchronization strategy" do + src = <<~CLEAR + STRUCT Box { value: Int64 } + FN keep(x: T @shared) RETURNS T @shared + REQUIRES x: LOCKED | VERSIONED + -> + RETURN x; + END + FN main() RETURNS Void -> + b = Box{ value: 1 } @shared:versioned; + got = keep(b); + RETURN; + END + CLEAR + + out = ZigTranspiler.new.transpile(src) + expect(out).to include("fn keep(comptime T: type, x: CheatLib.Arc(T)) @TypeOf(x)") + expect(out).to include("keep(CheatLib.Versioned(Box), b)") + expect(out).to include("CheatLib.arcRetain(T, x)") + end end # -------------------------------------------------- diff --git a/spec/primitive_atomic_function_spec.rb b/spec/primitive_atomic_function_spec.rb new file mode 100644 index 00000000..e4fd771b --- /dev/null +++ b/spec/primitive_atomic_function_spec.rb @@ -0,0 +1,134 @@ +require "rspec" +require_relative "../src/backends/transpiler" + +RSpec.describe "primitive non-shared atomics in functions" do + def transpile(src) + ZigTranspiler.new.transpile(src) + end + + it "passes a primitive @atomic cell to a plain Int64 parameter as one loaded value" do + out = transpile(<<~CLEAR) + FN id(x: Int64) RETURNS Int64 -> + RETURN x + x; + END + FN main() RETURNS Void -> + MUTABLE c: Int64 = 0 @atomic; + y = id(c); + RETURN; + END + CLEAR + + expect(out).to include("fn id(x: i64) i64") + expect(out).to include("return CheatLib.intAdd(x, x);") + expect(out).to include("const y: i64 = id(c.load())") + end + + it "passes a primitive @atomic cell through an explicit Int64 @atomic parameter" do + out = transpile(<<~CLEAR) + FN read(c: Int64 @atomic) RETURNS Int64 -> + RETURN c; + END + FN main() RETURNS Void -> + MUTABLE c: Int64 = 0 @atomic; + y = read(c); + RETURN; + END + CLEAR + + expect(out).to include("fn read(c: anytype) i64") + expect(out).to include("return c.load();") + expect(out).to include("const y: i64 = read(c)") + end + + it "rejects a bare primitive passed to an explicit Int64 @atomic parameter" do + expect { + transpile(<<~CLEAR) + FN read(c: Int64 @atomic) RETURNS Int64 -> + RETURN c; + END + FN main() RETURNS Void -> + c: Int64 = 0; + y = read(c); + RETURN; + END + CLEAR + }.to raise_error(CompilerError, /expects an @atomic Int64 cell/) + end + + it "warns when a call reads multiple atomic cells as independent bare values" do + warnings = [] + allow($stderr).to receive(:puts) { |msg| warnings << msg } + + out = transpile(<<~CLEAR) + FN add(x: Int64, y: Int64) RETURNS Int64 -> + RETURN x + y; + END + FN main() RETURNS Void -> + MUTABLE a: Int64 = 1 @atomic; + MUTABLE b: Int64 = 2 @atomic; + z = add(a, b); + RETURN; + END + CLEAR + + expect(out).to include("const z: i64 = add(a.load(), b.load())") + expect(warnings.join("\n")).to include("reads multiple atomic values independently") + expect(warnings.join("\n")).to include("STRICT/STRICT EXTREME") + end + + it "does not warn for a single atomic cell loaded into a bare parameter" do + warnings = [] + allow($stderr).to receive(:puts) { |msg| warnings << msg } + + out = transpile(<<~CLEAR) + FN inc(x: Int64) RETURNS Int64 -> + RETURN x + 1; + END + FN main() RETURNS Void -> + MUTABLE a: Int64 = 1 @atomic; + z = inc(a); + RETURN; + END + CLEAR + + expect(out).to include("const z: i64 = inc(a.load())") + expect(warnings.join("\n")).not_to include("reads multiple atomic values independently") + end + + it "does not warn when explicit Int64 @atomic parameters receive cells" do + warnings = [] + allow($stderr).to receive(:puts) { |msg| warnings << msg } + + out = transpile(<<~CLEAR) + FN sum_loaded(a: Int64 @atomic, b: Int64 @atomic) RETURNS Int64 -> + RETURN a + b; + END + FN main() RETURNS Void -> + MUTABLE a: Int64 = 1 @atomic; + MUTABLE b: Int64 = 2 @atomic; + z = sum_loaded(a, b); + RETURN; + END + CLEAR + + expect(out).to include("const z: i64 = sum_loaded(a, b)") + expect(out).to include("return CheatLib.intAdd(a.load(), b.load());") + expect(warnings.join("\n")).not_to include("reads multiple atomic values independently") + end + + it "returns a primitive @atomic cell as a loaded fallible Int64 value" do + out = transpile(<<~CLEAR) + FN get() RETURNS !Int64 -> + MUTABLE c: Int64 = 0 @atomic; + RETURN c; + END + FN main() RETURNS !Void -> + y = get() OR EXIT; + RETURN; + END + CLEAR + + expect(out).to include("fn get(rt: *Runtime) !i64") + expect(out).to include("return c.load();") + end +end diff --git a/spec/share_spec.rb b/spec/share_spec.rb index c146c026..c6f3ec95 100644 --- a/spec/share_spec.rb +++ b/spec/share_spec.rb @@ -104,6 +104,20 @@ def transpile(src) }.not_to raise_error end + it "accepts synchronized shared-family values passed to plain @shared parameters" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN takes_shared(b: Box @shared) RETURNS Void -> RETURN; END + FN main() RETURNS Void -> + b = Box{ value: 1 } @shared:locked; + takes_shared(b); + RETURN; + END + CLEAR + }.not_to raise_error + end + it "accepts SHARE values passed to T@shared parameters" do expect { annotate(<<~CLEAR) @@ -259,4 +273,52 @@ def transpile(src) CLEAR }.not_to raise_error end + + it "rejects returning a shared handle from a bare return type" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN unwrap_bad(b: Box @shared) RETURNS Box -> + RETURN b; + END + CLEAR + }.to raise_error(CompilerError, /expected to return 'Box'.*returned 'Box @shared'/m) + end + + it "accepts returning a shared handle from a shared return type" do + zig = transpile(<<~CLEAR) + STRUCT Box { value: Int64 } + FN retain_shared(b: Box @shared) RETURNS Box @shared -> + RETURN b; + END + CLEAR + + expect(zig).to include("fn retain_shared") + expect(zig).to include("CheatLib.Arc(Box)") + expect(zig).to include("CheatLib.arcRetain(Box") + end + + it "rejects returning a locked shared handle from a concrete unspecialized shared return type" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN retain_shared() RETURNS Box @shared -> + b = Box{ value: 1 } @shared:locked; + RETURN b; + END + CLEAR + }.to raise_error(CompilerError, /expected to return 'Box @shared'.*returned 'Box @shared @locked'/m) + end + + it "rejects returning a multiowned handle from a bare return type" do + expect { + annotate(<<~CLEAR) + STRUCT Box { value: Int64 } + FN unwrap_bad() RETURNS Box -> + b = Box{ value: 1 } @multiowned; + RETURN b; + END + CLEAR + }.to raise_error(CompilerError, /expected to return 'Box'.*returned 'Box @multiOwned'/m) + end end diff --git a/src/annotator-helpers/capabilities.rb b/src/annotator-helpers/capabilities.rb index c318b1d5..c9e69527 100644 --- a/src/annotator-helpers/capabilities.rb +++ b/src/annotator-helpers/capabilities.rb @@ -462,7 +462,7 @@ def declare_capability_scope!(cap) if cap[:alias] && !syn alias_name = cap[:alias] is_mutable = !!cap[:alias_mutable] - resolved_type = cap[:resolved_type] || cap[:old_scope]&.resolve_type(var_name) || :Any + resolved_type = capability_alias_type(cap[:resolved_type] || cap[:old_scope]&.resolve_type(var_name) || :Any) current_scope.declare(alias_name, nil, resolved_type, is_mutable, false, nil, :stack) sym = current_scope.locals[alias_name] sym.non_escaping = true @@ -540,7 +540,7 @@ def declare_capability_scope!(cap) end end alias_name = cap[:alias] || var_name - resolved_type = cap[:resolved_type] || cap[:old_scope]&.resolve_type(var_name) || :Any + resolved_type = capability_alias_type(cap[:resolved_type] || cap[:old_scope]&.resolve_type(var_name) || :Any) current_scope.declare(alias_name, nil, resolved_type, false, false, nil, :stack) sym = current_scope.locals[alias_name] sym.non_escaping = true @@ -550,6 +550,15 @@ def declare_capability_scope!(cap) end end + def capability_alias_type(type) + t = type.is_a?(Type) ? Type.new(type) : Type.new(type) + if t.any_sync? || t.ownership != :affine + t.bare_data_type + else + t + end + end + # --- Fiber capture analysis (shared by BG and DO blocks) --- # Result of analyzing a fiber body's captured variables. diff --git a/src/annotator-helpers/function_analysis.rb b/src/annotator-helpers/function_analysis.rb index 60ef417f..718eb549 100644 --- a/src/annotator-helpers/function_analysis.rb +++ b/src/annotator-helpers/function_analysis.rb @@ -355,6 +355,7 @@ def verify_function_signature!(node, signature) # For alias overlap encountered_args = [] + atomic_bare_value_args = [] node.args.each_with_index do |arg_node, i| param = params[i] @@ -454,6 +455,21 @@ def verify_function_signature!(node, signature) # that the callee can retain/cross execution boundaries, so callers # must pass a real shared handle or explicitly write SHARE x. actual_type_obj = arg_ti.is_a?(Type) ? arg_ti : Type.new(actual || :Any) + if explicit_primitive_atomic_param?(expected_type_obj) + unless atomic_cell_arg?(arg_node) + arg_name = arg_node.respond_to?(:name) ? arg_node.name : "Expression" + error!(arg_node, + "Type Error: Argument #{i + 1} to '#{node.name}' expects an @atomic #{expected_type_obj.resolved} cell, " \ + "but '#{arg_name}' is #{actual_type_obj.resolved}. Pass an @atomic binding, or change the parameter to bare #{expected_type_obj.resolved} to load a value.") + end + arg_node.atomic_borrow = true if arg_node.respond_to?(:atomic_borrow=) + end + if atomic_cell_to_bare_value_param?(arg_node, expected_type_obj, param) + atomic_bare_value_args << arg_node + end + if atomic_cell_to_atomic_param?(arg_node, param, signature) + arg_node.atomic_borrow = true if arg_node.respond_to?(:atomic_borrow=) + end if !match && expected_type_obj.shared? unless actual_type_obj.shared? hint = if arg_node.is_a?(AST::Identifier) @@ -511,6 +527,57 @@ def verify_function_signature!(node, signature) name: arg_node.respond_to?(:name) ? arg_node.name : "arg" } end + + warn_multi_atomic_bare_value_call!(node, atomic_bare_value_args) + end + + def atomic_cell_to_bare_value_param?(arg_node, expected_type_obj, param) + return false unless arg_node.is_a?(AST::Identifier) + sym = arg_node.respond_to?(:symbol) ? arg_node.symbol : nil + return false unless sym&.sync == :atomic + return false if sym.respond_to?(:layout) && sym.layout == :indirect + return false if param[:sync] == :atomic + return false if param[:symbol]&.respond_to?(:sync) && param[:symbol].sync == :atomic + return false if expected_type_obj.any? || expected_type_obj.fn_type? + return false if expected_type_obj.shared? || expected_type_obj.any_sync? + + expected_type_obj.primitive? + end + + def atomic_cell_to_atomic_param?(arg_node, param, signature) + return false unless arg_node.is_a?(AST::Identifier) + sym = arg_node.respond_to?(:symbol) ? arg_node.symbol : nil + return false unless sym&.sync == :atomic + ptype = param[:type] + return true if ptype.is_a?(Type) && ptype.sync == :atomic + return true if param[:sync] == :atomic + return true if param[:symbol]&.respond_to?(:sync) && param[:symbol].sync == :atomic + + requires = signature.respond_to?(:requires) ? signature.requires : signature[:requires] + families = requires && requires[param[:name].to_s] + families.respond_to?(:include?) && families.include?(:ATOMIC) + end + + def atomic_cell_arg?(arg_node) + return false unless arg_node.is_a?(AST::Identifier) + sym = arg_node.respond_to?(:symbol) ? arg_node.symbol : nil + sym&.sync == :atomic && !(sym.respond_to?(:layout) && sym.layout == :indirect) + end + + def explicit_primitive_atomic_param?(type) + type.is_a?(Type) && type.sync == :atomic && type.primitive? + end + + def warn_multi_atomic_bare_value_call!(node, atomic_args) + unique_args = atomic_args.compact + return if unique_args.length < 2 + + names = unique_args.map { |arg| arg.respond_to?(:name) ? arg.name : "" } + warning!(node, + "Call to '#{node.name}' reads multiple atomic values independently " \ + "(#{names.join(', ')}). This is not a multi-object-consistent snapshot; " \ + "default mode allows it as ordinary atomic loads. STRICT/STRICT EXTREME " \ + "will require an explicit @inconsistent call-site annotation.") end # TODO: Needs updated once lifetimes are complex diff --git a/src/annotator-helpers/generic_analysis.rb b/src/annotator-helpers/generic_analysis.rb index 1c93e813..3d69774f 100644 --- a/src/annotator-helpers/generic_analysis.rb +++ b/src/annotator-helpers/generic_analysis.rb @@ -77,7 +77,8 @@ def validate_type_annotation!(node, type_obj, is_param: false) # @raw is structural (byte buffer). Collections, @soa, @indirect are also structural. if is_param has_ownership_cap = %i[multiowned split].include?(type_obj.ownership) - has_sync_cap = type_obj.sync && !%i[raw symbol].include?(type_obj.sync) + primitive_atomic_param = type_obj.sync == :atomic && type_obj.primitive? + has_sync_cap = type_obj.sync && !primitive_atomic_param && !%i[raw symbol].include?(type_obj.sync) if has_ownership_cap || has_sync_cap error!(node, "Capability annotations are not allowed on function parameters. Use the plain type (e.g., 'Node' not 'Node @multiowned').") end @@ -250,7 +251,11 @@ def infer_generic_type_args!(node, signature, actual_args, type_params) arg = actual_args[i] next unless arg param_type = param[:type].is_a?(Type) ? param[:type] : Type.new(param[:type] || :Any) - actual_type = Type.new(arg.resolved_type || :Any) + actual_type = if arg.respond_to?(:type_info) && arg.type_info.is_a?(Type) + arg.type_info + else + Type.new(arg.resolved_type || :Any) + end extract_type_bindings!(node, param_type, actual_type, type_params, subst) end type_params.each do |tp| @@ -267,11 +272,17 @@ def extract_type_bindings!(node, param_type, actual_type, type_params, subst) p_res = param_type.resolved a_res = actual_type.resolved if type_params.include?(p_res) + actual_binding = if generic_shared_family_param?(param_type) && actual_type.shared? + generic_shared_payload_binding(actual_type) + else + generic_binding_value(actual_type) + end existing = subst[p_res] - if existing && existing != a_res - error!(node, :GENERIC_FN_CONFLICT, p_res, node.name, existing, a_res) + if existing && !same_generic_binding?(existing, actual_binding) + error!(node, :GENERIC_FN_CONFLICT, p_res, node.name, + generic_binding_source(existing), generic_binding_source(actual_binding)) end - subst[p_res] = a_res + subst[p_res] = actual_binding elsif param_type.generic_instance? && actual_type.generic_instance? && param_type.generic_base == actual_type.generic_base param_type.generic_args.zip(actual_type.generic_args).each do |p_arg, a_arg| @@ -292,9 +303,20 @@ def apply_type_subst(type_obj, subst) t = type_obj.is_a?(Type) ? type_obj : Type.new(type_obj) resolved = t.resolved if subst.key?(resolved) - Type.new(subst[resolved]) + substituted = Type.new(subst[resolved]) + if generic_type_has_capabilities?(t) + merged = Type.new(substituted) + merged.ownership = t.ownership if t.ownership != :affine + merged.sync = t.sync if t.sync + merged.layout = t.layout if t.layout + merged.elem_ownership = t.elem_ownership if t.elem_ownership + merged.elem_sync = t.elem_sync if t.elem_sync + merged + else + substituted + end elsif t.generic_instance? - new_args = t.generic_args.map { |arg| apply_type_subst(arg, subst).resolved } + new_args = t.generic_args.map { |arg| generic_binding_source(apply_type_subst(arg, subst)) } Type.new(:"#{t.generic_base}<#{new_args.join(',')}>") else # Handle array suffix: T[] → String[] when T → String @@ -321,6 +343,68 @@ def apply_type_subst(type_obj, subst) end end + def generic_binding_value(type) + t = type.is_a?(Type) ? type : Type.new(type) + generic_type_has_capabilities?(t) ? Type.new(t) : t.resolved + end + + def generic_shared_family_param?(type) + type.is_a?(Type) && type.shared? && type.resolved.to_s.match?(/\A[A-Z]\z/) + end + + def generic_shared_payload_binding(type) + t = type.is_a?(Type) ? Type.new(type) : Type.new(type) + t.ownership = :affine + t.provenance = nil if t.respond_to?(:provenance=) + t.instance_variable_set(:@generic_payload_type_arg, true) + t + end + + def same_generic_binding?(left, right) + l = left.is_a?(Type) ? left : Type.new(left) + r = right.is_a?(Type) ? right : Type.new(right) + l.resolved == r.resolved && + l.ownership == r.ownership && + l.sync == r.sync && + l.layout == r.layout && + l.elem_ownership == r.elem_ownership && + l.elem_sync == r.elem_sync + end + + def generic_type_has_capabilities?(type) + type.ownership != :affine || + !type.sync.nil? || + !type.layout.nil? || + !type.elem_ownership.nil? || + !type.elem_sync.nil? + end + + def generic_binding_source(type) + t = type.is_a?(Type) ? type : Type.new(type) + parts = [t.resolved.to_s] + + ownership = case t.ownership + when :shared then "@shared" + when :multiowned then "@multiowned" + when :link then "@link" + when :split then "@split" + when :frozen then "@frozen" + end + parts << ownership if ownership + + sync = case t.sync + when :locked then "@locked" + when :write_locked then "@writeLocked" + when :versioned then "@versioned" + when :atomic then "@atomic" + when :local then "@local" + when :always_mutable then "@alwaysMutable" + end + parts << sync if sync + + parts.join("") + end + # Build a concrete copy of a generic function signature with all type params # replaced by their inferred concrete types. def substitute_type_params(signature, subst) diff --git a/src/annotator.rb b/src/annotator.rb index 4fdb1d09..0ec07f39 100644 --- a/src/annotator.rb +++ b/src/annotator.rb @@ -597,6 +597,7 @@ def visit_FunctionDef(node) # 1. Setup metadata is_implicit_return = node.return_type.nil? + node.type_params = infer_implicit_type_params(node) if node.respond_to?(:type_params=) declared_return = node.return_type || :Any lifetime_paths = get_lifetime_paths(node) fn_type_params = (node.type_params || []).map(&:to_sym) @@ -2079,6 +2080,7 @@ def visit_ReturnNode(node) verify_tied_return!(node) actual = node.value.resolved_type + actual_full = return_value_type(node.value) expected = current_fn_ctx.return_type # 2. Move marking: returning a non-Copy value moves it out of the function. @@ -2136,11 +2138,9 @@ def visit_ReturnNode(node) end end - if expected && expected != :Void && expected != :Any && actual != expected - # Basic check (you might want to allow Number[3] -> Number[] coercion) - if !is_safe_autocast?(actual, expected) - error!(node, :RETURN_MISMATCH, expected, actual) - end + if expected && expected != :Void && expected != :Any && !return_type_compatible?(actual_full, expected) + error!(node, :RETURN_MISMATCH, type_display(expected), type_display(actual_full)) + elsif expected && expected != :Void && expected != :Any && actual != expected node.value.coerced_type = expected # Don't coerce EXPLICIT returns check_prefixed_int_range!(node.value, expected) end @@ -2154,6 +2154,87 @@ def visit_ReturnNode(node) @branch_terminated = true end + def return_value_type(value) + ti = value.respond_to?(:type_info) ? value.type_info : nil + return ti if ti.is_a?(Type) + Type.new(value.resolved_type || :Any) + end + + def return_type_compatible?(actual_type, expected_type) + expected_t = expected_type.is_a?(Type) ? expected_type : Type.new(expected_type) + actual_t = actual_type.is_a?(Type) ? actual_type : Type.new(actual_type) + + return true if expected_t.any? || actual_t.any? + return expected_t.accepts?(actual_t) if expected_t.fn_type? + return false unless same_return_capabilities?(expected_t, actual_t) + + is_safe_autocast?(actual_t, expected_t) + end + + def same_return_capabilities?(expected_t, actual_t) + name = expected_t.resolved.to_s + if name.match?(/\A[A-Z]\z/) && !lookup_type_schema(name.to_sym) && + expected_t.shared? && actual_t.shared? && + expected_t.sync.nil? && expected_t.resolved == actual_t.resolved + return true + end + expected_t.ownership == actual_t.ownership && + expected_t.sync == actual_t.sync && + expected_t.layout == actual_t.layout && + expected_t.elem_ownership == actual_t.elem_ownership && + expected_t.elem_sync == actual_t.elem_sync + end + + def type_display(type) + t = type.is_a?(Type) ? type : Type.new(type) + parts = [t.resolved.to_s] + + ownership = case t.ownership + when :multiowned then "@multiOwned" + when :shared then "@shared" + when :split then "@split" + when :link then "@link" + when :frozen then "@frozen" + end + parts << ownership if ownership + + sync = case t.sync + when :locked then "@locked" + when :write_locked then "@writeLocked" + when :versioned then "@versioned" + when :atomic then "@atomic" + when :always_mutable then "@alwaysMutable" + when :local then "@local" + end + parts << sync if sync + + parts.join(" ") + end + + def infer_implicit_type_params(fn_node) + explicit = (fn_node.type_params || []).map(&:to_s) + return explicit unless explicit.empty? + inferred = [] + ([fn_node.return_type] + (fn_node.params || []).map { |p| p[:type] }).each do |type| + collect_implicit_type_params(type, inferred, explicit) + end + (explicit + inferred).uniq + end + + def collect_implicit_type_params(type, out, explicit) + return unless type.is_a?(Type) + name = type.resolved.to_s + if name.match?(/\A[A-Z]\z/) && !explicit.include?(name) && !lookup_type_schema(name.to_sym) + out << name + end + if type.generic_instance? + type.generic_args.each { |arg| collect_implicit_type_params(arg, out, explicit) } + end + collect_implicit_type_params(type.payload_type, out, explicit) if type.respond_to?(:error_union?) && type.error_union? + collect_implicit_type_params(type.wrapped_type, out, explicit) if type.respond_to?(:optional?) && type.optional? + collect_implicit_type_params(type.element_type, out, explicit) if type.respond_to?(:array?) && type.array? + end + def visit_StaticCall(node) node.args.each { |arg| visit(arg) } @@ -2262,18 +2343,6 @@ def visit_FuncCall(node) node.collapsed_errors = collapse_errors_for_call(sig, node.args) end - # Atomics M1.7: stamp atomic_borrow on Identifier args whose binding - # is sync == :atomic. The MIR-lowering's load wrap (`c.ctrl.data - # .load()`) is suppressed for these so the callee receives the cell - # ref and can dispatch through @hasDecl(...) probes per family. - node.args.each do |arg| - next unless arg.is_a?(AST::Identifier) - sym = arg.respond_to?(:symbol) ? arg.symbol : nil - if sym&.sync == :atomic - arg.atomic_borrow = true if arg.respond_to?(:atomic_borrow=) - end - end - # True-Sync-Polymorphism Gate 3 plain-T auto-borrow stamping # lives in WithMatchCheck.check_call_sites! (runs after # universal-poly REQUIRES is finalized) so we don't need to diff --git a/src/ast/parser.rb b/src/ast/parser.rb index e9482a7a..48e6644d 100644 --- a/src/ast/parser.rb +++ b/src/ast/parser.rb @@ -2224,24 +2224,29 @@ def parse_primary error!(current, "Unexpected token #{current.value} (#{current.type}) line #{current.line}") end - # Returns true if, starting from current position '<', the token stream matches: - # < TYPE_ID (, TYPE_ID)* > end_char + # Returns true if, starting from current position '<', the token stream matches + # a generic argument list followed by end_char. Kept as a token-level peek so + # expression parsing can disambiguate `Pair{...}` from `<`/`>`. # Used to disambiguate generic annotations from comparison operators. def peek_generic_angle_params?(end_char) saved = @pos begin return false unless current.type == :CHAR && current.value == '<' @pos += 1 # skip '<' + depth = 1 loop do - return false unless current.type == :TYPE_ID - @pos += 1 # skip TYPE_ID - if current.type == :CHAR && current.value == ',' - @pos += 1 # skip ',' + return false if current.nil? + if current.type == :CHAR && current.value == '<' + depth += 1 elsif current.type == :CHAR && current.value == '>' - @pos += 1 # skip '>' + depth -= 1 + @pos += 1 + return current.type == :CHAR && current.value == end_char if depth == 0 + next + end + @pos += 1 + if depth == 0 return current.type == :CHAR && current.value == end_char - else - return false end end ensure @@ -2285,7 +2290,7 @@ def parse_lit(storage) consume(:CHAR, '<') type_args = [] until match?(:CHAR, '>') - type_args << consume(:TYPE_ID).value + type_args << type_annotation_source(parse_type_annotation) match!(:CHAR, ',') end consume(:CHAR, '>') @@ -2495,13 +2500,15 @@ def parse_type_annotation base = consume(:TYPE_ID).value inner = "" - # Generic type arguments: Pair or Map + # Generic type arguments: Pair or Map. + # Type arguments are full type annotations, so Cache + # preserves the synchronization family as part of T. # In type-annotation context, '<' is always a generic argument list, never a comparison. if match?(:CHAR, '<') consume(:CHAR, '<') type_args = [] until match?(:CHAR, '>') - type_args << consume(:TYPE_ID).value + type_args << type_annotation_source(parse_type_annotation) match!(:CHAR, ',') end consume(:CHAR, '>') @@ -2641,6 +2648,32 @@ def parse_type_annotation t end + def type_annotation_source(type) + t = type.is_a?(Type) ? type : Type.new(type) + parts = [t.resolved.to_s] + + ownership = case t.ownership + when :shared then "@shared" + when :multiowned then "@multiowned" + when :link then "@link" + when :split then "@split" + when :frozen then "@frozen" + end + parts << ownership if ownership + + sync = case t.sync + when :locked then "@locked" + when :write_locked then "@writeLocked" + when :versioned then "@versioned" + when :atomic then "@atomic" + when :local then "@local" + when :always_mutable then "@alwaysMutable" + end + parts << sync if sync + + parts.join("") + end + # Parses `CONCURRENT(workers: N)? SELECT|WHERE|EACH ...` def parse_concurrent_op token = consume(:KEYWORD, 'CONCURRENT') diff --git a/src/ast/source_error.rb b/src/ast/source_error.rb index 3fc30d7a..7cbfe6b8 100644 --- a/src/ast/source_error.rb +++ b/src/ast/source_error.rb @@ -122,6 +122,12 @@ def note!(node_or_token, message) $stderr.puts "\e[36m[Note]\e[0m #{message}#{loc}" end + def warning!(node_or_token, message) + token = node_or_token.respond_to?(:token) ? node_or_token.token : node_or_token + loc = token ? " (line #{token.line})" : "" + $stderr.puts "\e[33m[Warning]\e[0m #{message}#{loc}" + end + # Emit a fixable finding — a diagnostic with one or more suggested # edits. # @@ -222,4 +228,3 @@ def error_type; "Parser Error"; end class CompilerError < SourceError def error_type; "Compiler Error"; end end - diff --git a/src/ast/type.rb b/src/ast/type.rb index c906a5ee..6cdf4074 100644 --- a/src/ast/type.rb +++ b/src/ast/type.rb @@ -1531,6 +1531,12 @@ def parse_raw_input # Type alias: Number → Float64 (canonical internal name for f64). # Both Number and Float64 are accepted; the type system uses :Float64 everywhere. str = str.gsub(/\bNumber\b/, 'Float64') + + suffix_ownership = nil + suffix_sync = nil + unless str.include?("<") + str, suffix_ownership, suffix_sync = strip_capability_suffix(str) + end @raw = str.to_sym # A0. Detect Tense prefix: ~T (Future/Promise — a BG task producing T) @@ -1582,8 +1588,8 @@ def parse_raw_input end # C. Capability fields default — callers pass ownership:/sync:/location:/collection: as keyword args. - @ownership = :affine - @sync = nil + @ownership = suffix_ownership || :affine + @sync = suffix_sync @collection = nil # D. Detect Array Structure @@ -1653,6 +1659,31 @@ def parse_raw_input @resolved_cache = @raw.to_sym end + def strip_capability_suffix(str) + return [str, nil, nil] unless str.include?("@") + + base, *caps = str.gsub(/\s+/, "").split("@") + ownership = nil + sync = nil + caps.flat_map { |cap| cap.split(":") }.each do |cap| + case cap + when "shared" then ownership = :shared + when "multiOwned", "multiowned" then ownership = :multiowned + when "link" then ownership = :link + when "split" then ownership = :split + when "frozen" then ownership = :frozen + when "locked" then sync = :locked + when "writeLocked", "writelocked" then sync = :write_locked + when "versioned" then sync = :versioned + when "atomic" then sync = :atomic + when "local" then sync = :local + when "alwaysMutable", "alwaysmutable" then sync = :always_mutable + end + end + + [base, ownership, sync] + end + # Computes the Zig type string for this CHEAT type. # Handles: error unions, optionals, multiowned (Rc), pointers, arrays, hashmaps, primitives, structs. def compute_zig_type(is_param: false, is_field: false) diff --git a/src/mir/escape_analysis.rb b/src/mir/escape_analysis.rb index 59e8792c..de4e7d64 100644 --- a/src/mir/escape_analysis.rb +++ b/src/mir/escape_analysis.rb @@ -660,7 +660,7 @@ def self.propagate_caller_sync!(fn_nodes) # ── sync axis ──────────────────────────────────────────────── unless entry.sync && param_sync_was_declared?(param) unified = unify_caller_attr(sites, idx) { |s| s&.sync } - if unified && entry.sync != unified + if unified && entry.sync != unified && param_accepts_caller_sync?(callee_fn, param, unified) entry.sync = unified changed = true end @@ -734,6 +734,31 @@ def self.propagate_caller_sync!(fn_nodes) t.is_a?(Type) && t.any_sync? end + private_class_method def self.param_accepts_caller_sync?(fn_node, param, sync) + t = param[:type] + return true if t.is_a?(Type) && (t.shared? || t.any_sync?) + return true unless sync == :atomic + + requires = fn_node.respond_to?(:requires) ? fn_node.requires : nil + families = requires && requires[param[:name].to_s] + return false unless families.respond_to?(:include?) + + case sync + when :atomic + families.include?(:ATOMIC) || families.include?(:SNAPSHOTTED) + when :versioned + families.include?(:VERSIONED) || families.include?(:SNAPSHOTTED) + when :locked + families.include?(:LOCKED) + when :write_locked + families.include?(:LOCKED) + when :local + families.include?(:LOCAL) + else + false + end + end + # E3b: Stamp heap provenance on call expressions that call heap-carry-return functions. # Must run AFTER E2 (which sets fn.heap_carry_return) and BEFORE CleanupClassifier. # Replaces MIRPass#mark_heap_carry_call_sites! diff --git a/src/mir/mir_lowering.rb b/src/mir/mir_lowering.rb index fda99a12..8c292772 100644 --- a/src/mir/mir_lowering.rb +++ b/src/mir/mir_lowering.rb @@ -996,7 +996,9 @@ def lower_function_def(node) sym = param[:symbol] atomic_sync = sym && (sym.sync == :atomic || (sym.sync_families && sym.sync_families.include?(:ATOMIC))) - zig_t = if is_user_struct + zig_t = if p_type_obj.shared? && p_type_obj.resolved.to_s.match?(/\A[A-Z]\z/) + "CheatLib.Arc(#{p_type_obj.resolved})" + elsif is_user_struct "anytype" elsif p_type_obj.collection? "anytype" @@ -1018,7 +1020,10 @@ def lower_function_def(node) # Build return type string. The error prefix is baked into the string, # so can_fail on MIR::FnDef is always false (emitter would double it). - return_type_str = if fn_can_fail + tied_shared_return = tied_shared_family_return_param(node, mutable_scalar_params) + return_type_str = if tied_shared_return + tied_shared_return + elsif fn_can_fail # If the user already declared `RETURNS !T`, `final_type` already # carries the error union (post-#338 migration the canonical # form is `anyerror!T`, but bare `!T` is also accepted). Don't @@ -1429,7 +1434,7 @@ def lower_func_call(node) # Generic type args type_args = if node.respond_to?(:generic_type_args) && node.generic_type_args&.any? - node.generic_type_args.map { |t| MIR::Ident.new(Type.new(t).zig_type) } + node.generic_type_args.map { |t| MIR::Ident.new(generic_type_arg_zig(t)) } else [] end @@ -1494,7 +1499,7 @@ def lower_method_call(node) can_fail = callee_can_fail?(node.name) type_args = if node.respond_to?(:generic_type_args) && node.generic_type_args&.any? - node.generic_type_args.map { |t| MIR::Ident.new(Type.new(t).zig_type) } + node.generic_type_args.map { |t| MIR::Ident.new(generic_type_arg_zig(t)) } else [] end @@ -5182,7 +5187,7 @@ def lower_clone(node) else raise "Internal: lower_clone on unsupported type #{ti&.resolved || node.value.resolved_type}" end - zig_base = ti&.split_open_stream? ? ti.zig_type : transpile_type(ti.resolved.to_s) + zig_base = ti&.split_open_stream? ? ti.zig_type : rc_payload_zig_type(ti) MIR::RcRetain.new(lower(node.value), zig_base, func) end @@ -5294,13 +5299,49 @@ def lower_freeze(node) end def rc_payload_zig_type(ti) + if ti.resolved.to_s.match?(/\A[A-Z]\z/) && ti.shared? + return ti.resolved.to_s + end payload = Type.new(ti) payload.ownership = :affine payload.provenance = nil payload.instance_variable_set(:@zig_type_cache, nil) + if payload.any_sync? && !(payload.map? && payload.striped?) + inner = payload.bare_data_type.zig_type + inner = "CheatLib.Locked(#{inner})" if payload.sync == :locked + inner = "CheatLib.RwLocked(#{inner})" if payload.sync == :write_locked + inner = "CheatLib.RefCell(#{inner})" if payload.sync == :always_mutable + inner = "CheatLib.Versioned(#{inner})" if payload.sync == :versioned + if payload.sync == :atomic + inner = payload.layout == :indirect ? "CheatLib.AtomicPtr(#{inner})" : "CheatLib.Atomic(#{inner})" + end + return inner + end payload.zig_type end + def generic_type_arg_zig(type) + if type.is_a?(Type) && type.instance_variable_get(:@generic_payload_type_arg) + return rc_payload_zig_type(type) + end + t = type.is_a?(Type) ? Type.new(type) : Type.new(type) + t.zig_type + end + + def tied_shared_family_return_param(node, mutable_scalar_params) + ret = node.return_type + return nil unless ret.is_a?(Type) && ret.shared? + return nil unless ret.resolved.to_s.match?(/\A[A-Z]\z/) + params = (node.params || []).select do |p| + pt = p[:type].is_a?(Type) ? p[:type] : Type.new(p[:type] || :Any) + pt.shared? && pt.resolved == ret.resolved + end + return nil unless params.size == 1 + name = params.first[:name] + zig_name = mutable_scalar_params.include?(name) ? "_m_#{name}" : name + "@TypeOf(#{zig_name})" + end + # ================================================================ # Declarations # ================================================================ @@ -6854,7 +6895,7 @@ def rc_retain_needed?(value_node) def make_rc_retain(value_node) ti = value_node.type_info func = ti.shared? ? "arcRetain" : "rcRetain" - zig_base = transpile_type(ti.resolved.to_s) + zig_base = rc_payload_zig_type(ti) MIR::RcRetain.new(lower(value_node), zig_base, func) end From 284c67f6e5c2a86a2e1430433a119e4bcc9e0503 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 14:37:57 +0000 Subject: [PATCH 06/10] fix(ci): bound kcov scheduler-thread stress --- zig/parking-lot-cycle-test.zig | 8 ++++++-- zig/runtime/stream-test.zig | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/zig/parking-lot-cycle-test.zig b/zig/parking-lot-cycle-test.zig index b94ad518..9ede327a 100644 --- a/zig/parking-lot-cycle-test.zig +++ b/zig/parking-lot-cycle-test.zig @@ -48,7 +48,11 @@ const ParkingRwLock = pl.ParkingRwLock; // acquires per test. Empirically reproduces the false positive in <1s when // the bug is present. const NUM_LOCKS: usize = if (build_options.coverage) 4 else 8; -const NUM_SCHEDULERS: usize = if (build_options.coverage) 2 else 4; +// kcov ptraces every OS thread. In this parking/wakeup stress shape that can +// stall a worker thread long enough to look like a hung test even though the +// real multi-thread proof is covered by the TSan lane. Keep coverage mode as a +// line/surface smoke and leave cross-thread contention to non-coverage runs. +const NUM_SCHEDULERS: usize = if (build_options.coverage) 1 else 4; const FIBERS_PER_SCHEDULER: usize = if (build_options.coverage) 2 else 8; const NUM_FIBERS: usize = NUM_SCHEDULERS * FIBERS_PER_SCHEDULER; const ITERS_PER_FIBER: usize = if (build_options.coverage) 50 else 2_000; @@ -64,7 +68,7 @@ const ITERS_PER_FIBER: usize = if (build_options.coverage) 50 else 2_000; // (16M paired) requires either much longer iter counts or a different // shape (rwlock-shared outer + mutex inner). See the test header. const NUM_LOCKS_BIG: usize = if (build_options.coverage) 8 else 64; -const NUM_SCHEDULERS_BIG: usize = if (build_options.coverage) 2 else 32; +const NUM_SCHEDULERS_BIG: usize = if (build_options.coverage) 1 else 32; const FIBERS_PER_SCHEDULER_BIG: usize = if (build_options.coverage) 2 else 4; const NUM_FIBERS_BIG: usize = NUM_SCHEDULERS_BIG * FIBERS_PER_SCHEDULER_BIG; const ITERS_PER_FIBER_BIG: usize = if (build_options.coverage) 50 else 10_000; diff --git a/zig/runtime/stream-test.zig b/zig/runtime/stream-test.zig index 2cf1dc08..04c5cdd0 100644 --- a/zig/runtime/stream-test.zig +++ b/zig/runtime/stream-test.zig @@ -667,7 +667,10 @@ test "SplitStream survives multithreaded spawnBest pubsub hammer" { const allocator = std.testing.allocator; const subscriber_count = if (build_options.coverage) 3 else 16; const message_count = if (build_options.coverage) 64 else 4096; - const worker_count = if (build_options.coverage) 2 else 7; + // kcov ptraces every scheduler OS thread. This hammer's real cross-thread + // coverage belongs to the TSan lane; under kcov keep the same spawnBest / + // SplitStream surface on the active scheduler so coverage stays bounded. + const worker_count = if (build_options.coverage) 0 else 7; var global_ctx = EbrContext{}; defer global_ctx.deinit(allocator); From e30cfd8a73e4166007a3458faa822172251eeba9 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 14:58:01 +0000 Subject: [PATCH 07/10] fix(runtime): reset FSM task wait fields atomically --- zig/parking-lot-loom-test.zig | 1 + zig/runtime/parking-lot-loom.zig | 115 +++++++++++++++++++++++++++++++ zig/runtime/scheduler.zig | 29 ++++++-- 3 files changed, 139 insertions(+), 6 deletions(-) diff --git a/zig/parking-lot-loom-test.zig b/zig/parking-lot-loom-test.zig index 323dff0f..64d34618 100644 --- a/zig/parking-lot-loom-test.zig +++ b/zig/parking-lot-loom-test.zig @@ -46,6 +46,7 @@ const tests = [_]Test{ .{ .name = "parking VOPR: address-ordered nested mutex, no false positive (2000 seeds)", .func = &ploom.testVoprAddressOrderedNoFalsePositive }, .{ .name = "parking timeout-atomic: parker/scanner handshake (4096 schedules)", .func = &ploom.testTimeoutAtomicCoverage }, .{ .name = "parking fsm-timeout-atomic: FsmTask parker/scanner handshake (4096 schedules)", .func = &ploom.testFsmTimeoutAtomicCoverage }, + .{ .name = "parking fsm-reuse-atomic: FsmTask slab reset vs stale scanner (256 schedules)", .func = &ploom.testFsmReuseAtomicCoverage }, .{ .name = "stream close-err-atomic: producer/consumer handshake on closed+err (4096 schedules)", .func = &ploom.testStreamCloseErrAtomicCoverage }, }; diff --git a/zig/runtime/parking-lot-loom.zig b/zig/runtime/parking-lot-loom.zig index 6c928d7d..b8782eca 100644 --- a/zig/runtime/parking-lot-loom.zig +++ b/zig/runtime/parking-lot-loom.zig @@ -2044,6 +2044,121 @@ pub fn testFsmTimeoutAtomicCoverage() !void { } } +// ───────────────────────────────────────────────────────────────────── +// FSM slab-reuse reset coverage +// +// Regression for the TSan failure where scanFsmLockWaiters retained a +// stale *FsmTask in fsm_lock_waiters while the scheduler reused the same +// slab slot for a new FSM task. Bulk struct assignment reset the +// waiter/back-pointer fields non-atomically, racing with the scanner's +// atomic loads. Scheduler.allocFsmTask now resets those fields with +// atomic stores and publishes the new generation last. +// +// This loom scenario drives the real allocFsmTask reset while a stale +// scanner observes the same slot. Once the scanner sees the new +// generation, acquire/release ordering requires all reset fields to be +// visible as clear. +// ───────────────────────────────────────────────────────────────────── + +var g_fsm_reuse_task: *fsm_mod.FsmTask = undefined; +var g_fsm_reuse_allocated: ?*fsm_mod.FsmTask = null; +var g_fsm_reuse_old_generation: u32 = 0; +var g_fsm_reuse_same_slot: bool = false; +var g_fsm_reuse_observed_clear: bool = false; + +fn noopFsmResume(_: *fsm_mod.FsmTask) fsm_mod.YieldReason { + return .Done; +} + +fn entryFsmReuseScanner() callconv(.c) void { + const t = g_fsm_reuse_task; + + while (t.generation.load(.acquire) == g_fsm_reuse_old_generation) { + fc.__fiber.?.yield(); + } + + const clear = + t.seq.load(.acquire) == 0 and + t.lock_waiter.load(.acquire) == null and + t.waiting_for_lock_list.load(.acquire) == null and + t.waiting_for_lock.load(.acquire) == null and + t.waiting_for_fsm_owner.load(.acquire) == null and + t.lock_wait_start_ms.load(.acquire) == 0; + + if (clear) g_fsm_reuse_observed_clear = true; + + harness.done[0] = true; + while (true) fc.__fiber.?.yield(); +} + +fn entryFsmReuseAllocator() callconv(.c) void { + const t = g_sched.allocFsmTask(&noopFsmResume) catch unreachable; + g_fsm_reuse_allocated = t; + g_fsm_reuse_same_slot = (t == g_fsm_reuse_task); + + harness.done[1] = true; + while (true) fc.__fiber.?.yield(); +} + +pub fn testFsmReuseAtomicCoverage() !void { + const allocator = std.heap.c_allocator; + var ebr: ebr_mod.EbrContext = .{}; + var stack_pool = fm.StackPool.init(allocator); + g_sched = try fp.Scheduler.init(allocator, &ebr, &stack_pool); + + var schedule_buf: [8]u8 = undefined; + var h = LoomHarness.initExhaustive(allocator, &schedule_buf); + defer h.deinit(); + harness = &h; + + var failures: usize = 0; + const total: usize = if (build_options.coverage) 16 else 256; + for (0..total) |sched_idx| { + for (0..schedule_buf.len) |bit| { + schedule_buf[bit] = @intCast((sched_idx >> @as(u3, @intCast(bit % 8))) & 1); + } + h.resetExhaustive(&schedule_buf); + + const old = try g_sched.allocFsmTask(&noopFsmResume); + old.seq.store(7, .release); + old.lock_waiter.store(@ptrFromInt(0x11110001), .release); + old.waiting_for_lock_list.store(@ptrFromInt(0x22220001), .release); + old.waiting_for_lock.store(@ptrFromInt(0x33330001), .release); + old.waiting_for_fsm_owner.store(@ptrFromInt(0x44440008), .release); + old.lock_wait_start_ms.store(55, .release); + + g_fsm_reuse_task = old; + g_fsm_reuse_allocated = null; + g_fsm_reuse_old_generation = old.generation.load(.acquire); + g_fsm_reuse_same_slot = false; + g_fsm_reuse_observed_clear = false; + g_sched.fsm_task_slab.destroy(old); + + try h.createThread(0, @intFromPtr(&entryFsmReuseScanner)); + try h.createThread(1, @intFromPtr(&entryFsmReuseAllocator)); + + h.run() catch { + failures += 1; + if (g_fsm_reuse_allocated) |t| g_sched.fsm_task_slab.destroy(t); + continue; + }; + + if (!g_fsm_reuse_same_slot or !g_fsm_reuse_observed_clear) failures += 1; + if (g_fsm_reuse_allocated) |t| g_sched.fsm_task_slab.destroy(t); + } + + const final_b = g_sched.ready_queue.bottom.load(.monotonic); + g_sched.ready_queue.top.store(final_b, .monotonic); + g_sched.deinit(); + stack_pool.deinit(); + ebr.deinit(allocator); + + if (failures != 0) { + std.debug.print("\nfsm-reuse-atomic: {d}/{d} schedules failed\n", .{ failures, total }); + return error.LoomFailures; + } +} + // ───────────────────────────────────────────────────────────────────── // Stream(T) close/err atomic coverage // diff --git a/zig/runtime/scheduler.zig b/zig/runtime/scheduler.zig index 928e8eff..c773c2ce 100644 --- a/zig/runtime/scheduler.zig +++ b/zig/runtime/scheduler.zig @@ -530,15 +530,32 @@ pub const Scheduler = struct { /// Allocate a fresh FsmTask from `fsm_task_slab`, bump its /// generation, and initialize it. The slab gives detectCycleFsm /// its UAF-safe pin protocol (mirrors stackful Task slab). - /// Generation bump happens AFTER the FsmTask{} reset (which - /// zeroes generation): capture pre-reset value, write fresh task, - /// store +1 with .release. Any chain walker holding a stale - /// `(*FsmTask, generation)` pair from the previous slot occupant - /// observes the mismatch and aborts safely. + /// Generation bump happens after field reset: capture the previous + /// generation, reinitialize the slot, then store +1 with .release. + /// Cross-scheduler scanners can retain stale waiter-list pointers briefly, + /// so reset the atomic waiter/back-pointer fields with atomic stores rather + /// than a bulk struct assignment. Any chain walker holding a stale + /// `(*FsmTask, generation)` pair from the previous slot occupant observes + /// the mismatch and aborts safely. pub fn allocFsmTask(self: *Scheduler, resume_fn: fsm_mod.ResumeFn) !*fsm_mod.FsmTask { const t = try self.fsm_task_slab.create(); const prev_gen = t.generation.load(.monotonic); - t.* = fsm_mod.FsmTask.init(resume_fn); + t.resume_fn = resume_fn; + t.status = .Ready; + t.spawn_ns = 0; + t.ctx = null; + t.seq.store(0, .release); + t.waiter = null; + t.lock_waiter.store(null, .release); + t.waiting_for_lock_list.store(null, .release); + t.lock_error = .None; + t.waiting_for_lock.store(null, .release); + t.waiting_for_fsm_owner.store(null, .release); + t.lock_wait_start_ms.store(0, .release); + t.fsm_wake_time = 0; + t.destroy_fn = null; + t.ebr_slot = null; + t.task_runtime = null; t.generation.store(prev_gen +% 1, .release); return t; } From 95838cb437d80e1bfde4e1096248b07c25c39765 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 17:18:48 +0000 Subject: [PATCH 08/10] fix(ci): scale coverage-only partitioned-map stress --- zig/lib/partitioned-map-test.zig | 53 +++++++++++++++++++------------- zig/runtime/fiber-test.zig | 7 +++-- 2 files changed, 36 insertions(+), 24 deletions(-) diff --git a/zig/lib/partitioned-map-test.zig b/zig/lib/partitioned-map-test.zig index f7d25827..941272b9 100644 --- a/zig/lib/partitioned-map-test.zig +++ b/zig/lib/partitioned-map-test.zig @@ -18,6 +18,10 @@ const root = @import("root"); // get/remove diagnostic workers can overflow Standard 16KB fiber stacks. const key_worker_stack_size = if (build_options.coverage or build_options.tsan) .Large else .Standard; +fn coverageIters(comptime normal: usize) usize { + return if (build_options.coverage) 1 else normal; +} + var global_ebr_ctx: ebr.EbrContext = .{}; var global_stack_pool: fm.StackPool = undefined; var global_shutdown = std.atomic.Value(bool).init(false); @@ -570,7 +574,7 @@ fn runTinyGetRemoveLoopWithDelays(get_ctx_delay: bool, remove_ctx_delay: bool, k const FIBERS = 2; const KEYS = 2; - const ITERS = 256; + const ITERS = comptime coverageIters(256); const MainFn = struct { outer_rt: *Runtime, @@ -763,7 +767,7 @@ test "Promise(i64): repeated concurrent worker batches survive reuse" { rt.wireAllocator(); const FIBERS = 4; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const Ctx = struct { inner: *CheatLib.Promise(i64).Inner, @@ -837,7 +841,7 @@ test "RemoteCall: repeated concurrent batches survive reuse" { rt.wireAllocator(); const FIBERS = 4; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const OPS = 200; const RemoteCtx = struct { @@ -1032,7 +1036,7 @@ test "PartitionedStringMap: stack-local remote ops are complete before deinit" { defer rt.deinit(); rt.wireAllocator(); - const ITERS = 2000; + const ITERS = comptime coverageIters(2000); const MainFn = struct { fn run(_: *anyopaque, _: ?*anyopaque) anyerror!void { @@ -1087,7 +1091,7 @@ test "PartitionedStringMap: persistent heap-backed map survives repeated concurr const FIBERS = 4; const KEYS = 200; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -1147,7 +1151,7 @@ test "PartitionedStringMap: persistent heap-backed map survives repeated concurr const FIBERS = 4; const KEYS = 200; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -1207,7 +1211,7 @@ test "PartitionedStringMap: recreated heap-backed map survives repeated concurre const FIBERS = 4; const KEYS = 500; - const ITERS = 6; + const ITERS = comptime coverageIters(6); const MainFn = struct { outer_rt: *Runtime, @@ -1266,7 +1270,7 @@ test "PartitionedStringMap: inline L6-shaped put-get batches survive repeated ru const KEYS = 500; const FIBERS = 4; - const ITERS = 3; + const ITERS = comptime coverageIters(3); const InlineBgWork = struct { inner: *CheatLib.Promise(i64).Inner, @@ -1363,7 +1367,7 @@ test "PartitionedStringMap: persistent heap-backed map survives repeated concurr const FIBERS = 4; const KEYS = 200; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -1422,7 +1426,7 @@ test "PartitionedStringMap: persistent heap-backed map survives repeated concurr const FIBERS = 4; const KEYS = 200; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -1481,7 +1485,7 @@ test "PartitionedStringMap: persistent heap-backed map survives repeated concurr const FIBERS = 4; const KEYS = 200; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -1543,7 +1547,7 @@ test "PartitionedStringMap: persistent heap-backed map survives repeated concurr const FIBERS = 4; const KEYS = 200; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -1605,7 +1609,7 @@ test "PartitionedStringMap: persistent heap-backed map survives repeated concurr const FIBERS = 4; const KEYS = 200; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -1666,7 +1670,7 @@ test "PartitionedStringMap: persistent heap-backed map survives repeated concurr const FIBERS = 4; const KEYS = 200; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -1727,7 +1731,7 @@ test "PartitionedStringMap: persistent heap-backed map survives repeated concurr const FIBERS = 4; const KEYS = 200; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -2223,7 +2227,7 @@ test "PartitionedStringMap: single-worker repeated remote get-remove batches" { rt.wireAllocator(); const KEYS = 128; - const ITERS = 32; + const ITERS = comptime coverageIters(32); const MainFn = struct { outer_rt: *Runtime, @@ -2281,7 +2285,7 @@ test "PartitionedStringMap: same-keys vs fresh-keys get-remove batches" { const FIBERS = 4; const KEYS = 64; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -2355,7 +2359,7 @@ test "PartitionedStringMap: get-remove ctx counters drain after each batch" { const FIBERS = 4; const KEYS = 64; - const ITERS = 16; + const ITERS = comptime coverageIters(16); const MainFn = struct { outer_rt: *Runtime, @@ -2418,7 +2422,7 @@ test "PartitionedStringMap: phased get then remove batches" { const FIBERS = 4; const KEYS = 64; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -2482,7 +2486,7 @@ test "PartitionedStringMap: tiny 2-worker 2-key 1-remote-shard loop" { const FIBERS = 2; const KEYS = 2; - const ITERS = 256; + const ITERS = comptime coverageIters(256); const MainFn = struct { outer_rt: *Runtime, @@ -2544,7 +2548,7 @@ test "PartitionedStringMap: persistent map repeated get-remove with counters" { const FIBERS = 4; const KEYS = 64; - const ITERS = 32; + const ITERS = comptime coverageIters(32); const MainFn = struct { outer_rt: *Runtime, @@ -2606,7 +2610,7 @@ test "PartitionedStringMap: delayed-destroy diagnostic for get-remove" { const FIBERS = 4; const KEYS = 64; - const ITERS = 8; + const ITERS = comptime coverageIters(8); const MainFn = struct { outer_rt: *Runtime, @@ -2682,6 +2686,11 @@ test "PartitionedStringMap: tiny get-remove event log preserves per-op teardown } test "PartitionedStringMap: repeated tiny get-remove batches preserve event-log invariants" { + // kcov already covers this event-log path in the immediately preceding + // test. Running the same diagnostic a second time under ptrace can stop + // making progress in kcov while the normal/TSan lanes still keep the + // larger repetition count as a scheduling stressor. + if (build_options.coverage) return error.SkipZigTest; try runTinyGetRemoveLoopWithEventLog(128); } diff --git a/zig/runtime/fiber-test.zig b/zig/runtime/fiber-test.zig index f5b1dac2..1fe3109a 100644 --- a/zig/runtime/fiber-test.zig +++ b/zig/runtime/fiber-test.zig @@ -369,8 +369,11 @@ test "Non-Blocking Sleep" { std.debug.print("\n--- Total Duration: {d}ms ---\n", .{duration}); - // Assert it ran in parallel (took less than sum of sleeps) - try std.testing.expect(duration < 390); + // Assert it ran in parallel (took less than sum of sleeps). kcov ptraces + // the scheduler thread and can add enough wall-clock delay to exceed the + // normal 400ms serial bound even though the sleep path is non-blocking. + const max_duration_ms: i64 = if (build_options.coverage) 800 else 390; + try std.testing.expect(duration < max_duration_ms); try std.testing.expect(duration >= 300); } From d2c994eb78d6f5f3743a629812f18eb7c8bc4ef3 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 17:38:11 +0000 Subject: [PATCH 09/10] fix(ci): shrink fsm rwlock hammer under coverage --- zig/runtime/fsm-rwlock-hammer-test.zig | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/zig/runtime/fsm-rwlock-hammer-test.zig b/zig/runtime/fsm-rwlock-hammer-test.zig index c68b6a42..fcd08bfc 100644 --- a/zig/runtime/fsm-rwlock-hammer-test.zig +++ b/zig/runtime/fsm-rwlock-hammer-test.zig @@ -65,6 +65,7 @@ const CheatLib = CheatHeader.CheatLib; const Runtime = rt_mod.Runtime; const Scheduler = fp.Scheduler; const StackPool = fm.StackPool; +const build_options = @import("build_options"); const SKIP_BY_DEFAULT = false; @@ -299,14 +300,14 @@ test "FSM RwLock hammer: 8 readers + 4 writers x 5 trials -- bench-17 lost-wakeu stack_pool = StackPool.init(test_alloc); defer stack_pool.deinit(); - try withMainRuntimeN(4, struct { + const workers = if (build_options.coverage) 1 else 4; + try withMainRuntimeN(workers, struct { fn body(rt: *Runtime) !void { - const coverage = @import("build_options").coverage; - const NR = if (coverage) 2 else 8; - const NW = if (coverage) 1 else 4; - const READS = if (coverage) 100 else 5_000; - const WRITES = if (coverage) 10 else 100; - const NUM_TRIALS = if (coverage) 1 else 5; + const NR = if (build_options.coverage) 1 else 8; + const NW = if (build_options.coverage) 1 else 4; + const READS = if (build_options.coverage) 1 else 5_000; + const WRITES = if (build_options.coverage) 1 else 100; + const NUM_TRIALS = if (build_options.coverage) 1 else 5; const PER_TRIAL_DEADLINE_MS: i64 = 5_000; const sa = rt.getSched().allocator; From 3f4555baa7833aaee37ddcef10d6ccbaae58d075 Mon Sep 17 00:00:00 2001 From: Brian Yahn Date: Tue, 5 May 2026 18:19:49 +0000 Subject: [PATCH 10/10] fix(ci): shrink parking rwlock hammer under coverage --- zig/runtime/parking-rwlock-fiber-hammer-test.zig | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/zig/runtime/parking-rwlock-fiber-hammer-test.zig b/zig/runtime/parking-rwlock-fiber-hammer-test.zig index d395c245..3b016f23 100644 --- a/zig/runtime/parking-rwlock-fiber-hammer-test.zig +++ b/zig/runtime/parking-rwlock-fiber-hammer-test.zig @@ -53,6 +53,7 @@ const CheatLib = CheatHeader.CheatLib; const Runtime = rt_mod.Runtime; const Scheduler = fp.Scheduler; const StackPool = fm.StackPool; +const build_options = @import("build_options"); const SKIP_BY_DEFAULT = false; @@ -232,12 +233,12 @@ test "ParkingRwLock fiber hammer: 4 writers + 8 readers, torn-read invariant und stack_pool = StackPool.init(test_alloc); defer stack_pool.deinit(); - try withMainRuntimeN(4, struct { + const workers = if (build_options.coverage) 1 else 4; + try withMainRuntimeN(workers, struct { fn body(rt: *Runtime) !void { - const coverage = @import("build_options").coverage; - const NW = if (coverage) 1 else 4; - const NR = if (coverage) 2 else 8; - const ITERS: usize = if (coverage) 50 else 500; + const NW = if (build_options.coverage) 1 else 4; + const NR = if (build_options.coverage) 1 else 8; + const ITERS: usize = if (build_options.coverage) 1 else 500; var shared = Shared{}; const sa = rt.getSched().allocator;