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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion clear
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 2 additions & 2 deletions docs/agents/atomicptr-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) |

Expand Down
61 changes: 61 additions & 0 deletions docs/agents/true-synchronization-polymorphism.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion spec/atomic_migration_suggester_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion spec/atomic_ptr_migration_suggester_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions spec/borrow_checker_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 ->
Expand Down
21 changes: 17 additions & 4 deletions spec/capabilities_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1352,7 +1365,7 @@ def with_block(ast)
c = C{ v: 0 } @locked;
WITH EXCLUSIVE c AS inner { inner.v = 1; } ON Transient WAT
FLUX
expect { parse_only(src) }.to raise_error(ParserError, /Expected RAISE, PASS, EXIT/)
expect { parse_only(src) }.to raise_error(ParserError, /Expected RAISE, PASS, RETURN <expr>, EXIT/)
end

it "parses WITH POSSIBLE_DEADLOCK EXCLUSIVE modifier" do
Expand Down
80 changes: 80 additions & 0 deletions spec/doctor_spec.rb
Original file line number Diff line number Diff line change
@@ -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 <profile-dir>")
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
15 changes: 8 additions & 7 deletions spec/error_registry_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@
expect(AST::ERROR_TYPES.key?(:Conflict)).to be false
end

it "user types start at id 8 (after the eight stdlib types)" do
expect(AST::ERROR_NAME_USER_FIRST).to eq(8)
it "user types start at id 9 (after the stdlib/control-flow types)" do
expect(AST::ERROR_NAME_USER_FIRST).to eq(9)
end
end

Expand Down Expand Up @@ -165,14 +165,15 @@
expect(entries).to include([:Deadlock, 3])
end

it "includes user types at >=8 sorted by id" do
it "includes user types at >=9 sorted by id" do
AST.register_type!(:UserA, :Input)
AST.register_type!(:UserB, :Input)
entries = AST.enum_entries
ids = entries.map(&:last)
expect(ids).to eq(ids.sort)
expect(entries).to include([:UserA, 8])
expect(entries).to include([:UserB, 9])
expect(entries).to include([:GuardFail, 8])
expect(entries).to include([:UserA, 9])
expect(entries).to include([:UserB, 10])
end
end

Expand Down Expand Up @@ -226,8 +227,8 @@
end

describe ".types_for_kind" do
it "expands :Transient to the retryable types (lock + MVCC + Atomic contention)" do
expect(AST.types_for_kind(:Transient).to_set).to eq(Set[:LockTimeout, :LockCycle, :MvccConflict, :AtomicConflict])
it "expands :Transient to retryable synchronization and guard-control types" do
expect(AST.types_for_kind(:Transient).to_set).to eq(Set[:LockTimeout, :LockCycle, :MvccConflict, :AtomicConflict, :GuardFail])
end

it "expands :System to include Deadlock" do
Expand Down
Loading