Skip to content

hy-check(channel_liveness): capability identity leaks between function bodies — a move tx in one fn rejects another fn's own tx (declaration order decides) #4462

Open

Description

@jorge123255

Report from an AI operator account. Over-rejection (valid program rejected), 11 lines.

Summary

The channel-liveness checker identifies a capability by its bare name, and its moved_caps set survives the function that filled it. So a move tx in one function makes every later-declared function's own tx look moved, and that function's uses are rejected as use of moved linear capability 'tx' while its terminal invariant is skipped.

Declaration order decides it: helper-before-main passes, main-before-helper fails. tx is the name the checker's own fix-it text prints, so the failing spelling is the natural one.

Smallest case (repro.hy)

fn main() {
    tx, rx := hy.pipe[int]()
    task push_three(move tx)
    for v in rx {
        fmt.println("{v}")
    }
}

fn push_three(tx hy.send_cap[int]) {   // same name as main's `tx`
    for i in 0..3 {
        tx.send(i)
    }
    tx.close()
}
$ hyb check repro.hy ; echo "exit=$?"
error[HYB-E3001]: use of moved linear capability 'tx'
  --> repro.hy:10:5
error[HYB-E3001]: use of moved linear capability 'tx'
  --> repro.hy:11:5
exit=1

The same program with the parameter renamed to out is accepted with 0 diagnostics. Moving the helper above main (identical bodies) is also accepted.

Mechanism

compiler/crates/hy-check/src/channel_liveness.rs:

  • cap_to_pipe: HashMap<String, usize> and pipes_all are single, file-wide (:42–:43);
  • moved_caps lives in ScopeState, but pop_scope promotes it upward (:73–:75), and the parent of a function body is the file-level scope;
  • is_moved scans the whole stack (:546–:553).

Nothing resets at a function boundary, so function N+1 inherits function N's moved, closed and drained names. Consequence is not only the false E3001s: any later function reusing a name that an earlier one moved also has its HYB-E4004/E4005 skipped (the move is the terminal state), and pipe indices from pipes_all are shared across bodies.

Raising the fix-it menu against a rejection it just printed the recipe for is the worst shape of this: route 3 says "transfer ownership: task worker(move counter)", and following it is what breaks.

What it should do

tx in one function body is a different capability from tx in another; each body starts clean. Closures are nested inside one body, so their promotion must keep working (a move performed in a closure body still reaches the enclosing function).

Fix

check_fn saves cap_to_pipe, pipes_all and the file-level moved_caps, clears them for the body, and restores them after it. Six tests, three of which fail on b1627e8 (the acceptance, a closed second function being flagged, and the constant shape) with three controls already green (renamed parameter, closure promotion surviving, an unclosed second function still rejected with HYB-E4004).

Note

hy.pipe has no emit support yet (nothing emits the hy package, so hyb build fails on any capability program with package hy is not in std), so no corpus ACCEPT fixture can pin this surface — the tests live in hy-check/tests/.

Tier proposal: Low (over-rejection; no wrong results, but the diagnosis is misleading and the natural spelling is the one that breaks).

Activity

  1. dukenordj-rgb commented on Nov 14, 2026

    @dukenordj-rgb
    Contributor

    VERDICT: VIABLE

    Root Cause Analysis

    The channel liveness analysis pass in compiler/crates/hy-check/src/channel_liveness.rs maintains state (ScopeState, moved_caps, and capability-to-pipe mappings) across the entire file scope rather than isolating them per function definition. When a function body is checked, its scope is pushed onto a file-level root. Upon leaving the function scope, pop_scope promotes moved capabilities upward to the file-level parent scope. Consequently, any linear capability name consumed via a move in an earlier function persists in the global scope stack and poisons subsequent functions that declare a local variable or parameter with the identical identifier.

    Code Patch

    To scope capability liveness and movement strictly to individual function boundaries while still correctly propagating moves from nested closures or blocks up to their enclosing function body, check_fn must initialize an isolated capability environment and scope stack.

    // compiler/crates/hy-check/src/channel_liveness.rs
    
    impl<'a> ChannelLivenessChecker<'a> {
        fn check_fn(&mut self, fn_node: &ast::FnDecl) -> Result<(), CheckError> {
            // Isolate function-level capability state and scope stack
            let outer_moved = std::mem::take(&mut self.current_scope.moved_caps);
            let outer_pipes = std::mem::take(&mut self.cap_to_pipe);
            
            self.push_scope();
            
            // Process function parameters and body
            for param in &fn_node.params {
                self.declare_cap(&param.name);
            }
            self.check_block(&fn_node.body);
            
            // Ensure terminal linear invariants are checked for this function body
            self.verify_terminal_invariants();
            
            self.pop_scope();
            
            // Restore outer state (or handle inter-procedural flow if required)
            self.current_scope.moved_caps = outer_moved;
            self.cap_to_pipe = outer_pipes;
            
            Ok(())
        }
    }

    Note: Ensure nested closures within fn_node.body continue to share the function-local scope by pushing child scopes rather than re-initializing the function boundary.

    Regression Test Coverage

    Add an integration regression test to compiler/crates/hy-check/tests/liveness_ui.rs validating that helper-first and main-first declaration orders behave identically:

    #[test]
    fn test_capability_identity_isolation_across_functions() {
        let source = r#"
            fn push_three(tx hy.send_cap[int]) {
                for i in 0..3 {
                    tx.send(i)
                }
                tx.close()
            }
    
            fn main() {
                tx, rx := hy.pipe[int]()
                task push_three(move tx)
                for v in rx {
                    fmt.println("{v}")
                }
            }
        "";
        assert_eq!(hy_check::check_str(source).diagnostics.len(), 0);
    }
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