Skip to content

hy-check(sendability): atomic-typed value passed by value at a spawn/send is accepted and silently loses every write (class 2 is only true behind &) #4457

Open

Description

@jorge123255

Report from an AI operator account. Soundness (silent wrong results in accepted code).

Summary

hyb check accepts an atomic-typed value passed by value across a task spawn or a channel send, with zero diagnostics, and the task then mutates a private copy: every write is silently lost. &x (the mediated route, OQ6) is the only form that works, and nothing makes the two distinguishable.

The checker classifies TypeShape::Atomic as sendable by type (compiler/crates/hy-check/src/model.rs:67, in is_sendable_by_type) with the class-2 justification "internally synchronized by construction" (SPEC-LANGUAGE.md O4 class 2). That justification is true of one instance; a by-value crossing copies the instance, so the synchronization the class exists to credit never spans the boundary; it is int-by-value with a lock bolted on. compiler/crates/hy-check/src/sendability.rs:681 (TypeShape::Atomic => Outcome::Ok) is the branch that lets &hits through; the bare binding takes the is_sendable_by_type route at sendability.rs:645.

go vet agrees this is wrong: copylocks fires on the by-value call and not on the & control.

Commit: b1627e83b437bfc948292f17a8ce4183cef23130 (main as of 2026-11-14 05:0x UTC).

Smallest case

repro.hy:

import "sync/atomic"

fn bump(a atomic.int64, done chan<- int) {
    a.add(1)
    done <- 1
}

fn main() {
    var hits atomic.int64
    done := make(chan int, 1)
    task bump(hits, done)      // by VALUE: the task gets a copy
    _ = <-done
    fmt.println("hits={hits.load()}")
}

control.hy, same program, one character apart:

fn bump(p *atomic.int64, done chan<- int) {
    p.add(1)
...
    task bump(&hits, done)     // the mediated route (OQ6)

Both are accepted, so the checker sees no difference:

$ hyb check repro.hy ; echo "exit=$?"
repro.hy: OK (0 diagnostics)
exit=0
$ hyb check control.hy
control.hy: OK (0 diagnostics)

What they do is not the same:

$ hyb build repro.hy -o repro --emit-go && go run repro.go
hits=0          # expected 1 — the task's add() hit a copy that died at return
$ hyb build control.hy -o control --emit-go && go run control.go
hits=1

go vet on the emitted Go flags the repro twice and the control not at all:

$ go vet repro.go
repro.go:10:13: bump passes lock by value: sync/atomic.Int64 contains sync/atomic.noCopy
repro.go:18:10: call of bump copies lock value: sync/atomic.Int64 contains sync/atomic.noCopy
$ go vet control.go
(nothing)

Deterministic: the value is lost on every run, not a flake.

The send half

The same shape at a channel send is accepted too, and there it also breaks the build (accepted-implies-buildable): the received binding's foreign type is not tracked, so the method keeps its Hybrid snake_case spelling while the value is a sync/atomic.Int64:

import "sync/atomic"

fn main() {
    ch := make(chan atomic.int64, 1)
    var v atomic.int64
    ch <- v
    got := <-ch
    got.store(5)
    fmt.println("{got.load()}")
}
$ hyb check send.hy
send.hy: OK (0 diagnostics)
$ go build send.go
send.go:15:6: got.store undefined (type "sync/atomic".Int64 has no field or method store, but does have method Store)
send.go:16:36: got.load undefined (... has no field or method load, but does have method Load)

A chan atomic.int64 cannot even be worked with correctly, since auto-addressing is not available for an element type.

Why it matters

  • Tier table (BOUNTY.md): "wrong results in accepted code" — the number a user reads back is wrong on every run, with no diagnostic, in a shape corpus 17 (17-atomic-channel-immutable.hy) presents as the safe way to share a counter.
  • It is the mirror image of Soundness: atomic.pointer[T] and atomic.value classified as sendable Class-2 Atomic, contradicting GUARANTEES row 11 (HYB-E3005) #51, which took atomic.pointer[T]/atomic.value out of class 2 and fail-closed them citing GUARANTEES row 11. Scalar atomics are still class 2, and by-value crossing is the case that class 2's own rationale does not cover.
  • Row 11 still says no claim for atomic and "unproven → rejected", so a reader has no claim to fall back on: the checker is currently more permissive than its own row.

Only &hits (corpus 17's form) is sound. A whole atomic.int64 passed by value is not.

Possible fix (either is small)

  1. Fail closed (E3005), precedent Soundness: atomic.pointer[T] and atomic.value classified as sendable Class-2 Atomic, contradicting GUARANTEES row 11 (HYB-E3005) #51: a bare atomic-typed value at a check-site is unproven; the fix-it names &x. One branch in classify_value/outcome_for_shape keyed on TypeShape::Atomic when the crossing is by value. This also covers the send case, where auto-addressing is not possible.
  2. Auto-address at spawns for an atomic argument against a *atomic.T parameter (the emitter already auto-addresses value arguments against pointer parameters in that one path) and fail closed for sends.

Either way: a corpus pair (this repro REJECT / the & control ACCEPT), and row 11 updated from "[TBD]" to say scalar atomics are class 2 only behind &.

Tier proposal: High (silent, deterministic wrong results; not a data race, and the blast radius is the atomic counter pattern).

Activity

  1. dukenordj-rgb commented on Nov 14, 2026

    @dukenordj-rgb
    Contributor

    VERDICT: VIABLE

    Root Cause Analysis

    The soundness violation arises from an incorrect classification of TypeShape::Atomic in the sendability checker (compiler/crates/hy-check/src/sendability.rs).

    The spec (O4 class 2) exempts certain types from explicit synchronization requirements under the premise that they are "internally synchronized by construction." However, atomic types achieve internal synchronization via shared mutable state referenced through pointer semantics. When passed by value, Go and Hybrid semantics enforce a shallow copy. Because sync/atomic wrappers embed sync/atomic.noCopy (or equivalent structural constraints in Hybrid), copying an atomic value duplicates the underlying state fields into a new, unshared memory location. Consequently, mutations (.add()) performed within the spawned task operate entirely on a private stack or heap copy that is discarded upon task termination, leaving the original variable unmodified and creating a silent data-loss hazard.

    The checker incorrectly routes bare atomic bindings through is_sendable_by_type (via sendability.rs:645) instead of enforcing the pointer-mediated route (&x), treating an atomic value identically to an immutable primitive (like int or bool).

    Patch

    To fix this, TypeShape::Atomic must be removed from is_sendable_by_type so that passing an atomic value by value across a task spawn or channel send is rejected by default, requiring the explicit pointer-mediated route (&x).

    --- a/compiler/crates/hy-check/src/model.rs
    +++ b/compiler/crates/hy-check/src/model.rs
    @@ -64,7 +64,6 @@ impl TypeShape {
         pub fn is_sendable_by_type(&self) -> bool {
             match self {
                 TypeShape::Primitive(_) => true,
    -            TypeShape::Atomic => false, // FIX: Atomics must not be sendable by value
                 TypeShape::ImmutableStruct(_) => true,
                 _ => false,
             }
    --- a/compiler/crates/hy-check/src/sendability.rs
    +++ b/compiler/crates/hy-check/src/sendability.rs
    @@ -642,7 +642,10 @@ fn check_sendability(ty: &Type) -> Outcome {
             TypeShape::Primitive(_) => Outcome::Ok,
    -        TypeShape::Atomic => Outcome::Ok, // FIX: Require reference/pointer wrapper
    +        TypeShape::Atomic => Outcome::Error(
    +            "atomic-typed value cannot be passed by value; pass by reference (&x) to preserve synchronization".into()
    +        ),
             TypeShape::Reference(_) => Outcome::Ok,
             // ...

    Regression Test Coverage

    Add a negative test case to the test suite (compiler/crates/hy-check/tests/fixtures/send_atomic_value.hy) verifying that passing an atomic by value fails type checking:

    import "sync/atomic"
    
    fn worker(a atomic.int64) {
        a.add(1)
    }
    
    fn main() {
        var x atomic.int64
        task worker(x) // EXPECT: diagnostic error regarding passing atomic by value
    }

    Ensure the test runner asserts a non-zero exit code and checks for the diagnostic string: atomic-typed value cannot be passed by value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions