Skip to content

Native C retains every buffered channel closed without a later receiver #1268

Closed

Description

@jasisz

What you did

I repeatedly created a room-one channel, buffered one value, closed it, and dropped every handle. I compared it with the same loop closing an empty channel.

import Base

def loop(+fuel: Nat) -> IO(Unit):
  match fuel:
    case 0n:
      IO.print("done")
    case 1n+more:
      do IO<Unit>:
        +c : Chan(U32) <- Chan.new(U32, 1)
        ok : Bool <- Chan.send(U32, c, 7)
        Unit <- Chan.close(U32, c)
        loop(more)

def main() -> IO(Unit):
  loop(2000000n)

I built native C and emitted JS, then measured each with /usr/bin/time -l.

What happened

Both programs print done and exit 0, but native C retains about 65 bytes for every closed buffered channel. At two million iterations current main uses 131,792,896 bytes peak RSS; the empty-channel control uses 1,982,464 bytes. Emitted JS uses 45,252,608 bytes for the reproducer and 43,974,656 bytes for the control. At 500,000 iterations native C uses 35,684,352 bytes, confirming linear growth.

The same result reproduces on v2.0.34: 131,776,512 bytes for buffered close and 1,982,464 bytes for the empty control.

In bend2/effs/chan.c, chan_shut calls chan_free only when row->size == 0. A closed buffered row is freed if another copied handle later drains it, but if the program drops every handle the row, its ring, and its buffered term become unreachable without ever entering chan_idle. Every leak also consumes one entry from the 16,777,216-row limit, so a sequential program can eventually fail with more than 16777216 channels at once despite having only one source-level channel alive at a time.

The JS runtime does not keep a global row table; the closed object becomes collectible when its handles are unreachable.

I found no matching issue or WONTFIX entry. The leak discussed during #1162/#1172 concerned a dropped parked operation and its timer, not a Chan.close that has already completed. Open PR #1259 changes timed channel operations but preserves the same chan_shut rule, so it does not free this closed buffered row either.

An immediate chan_free in Chan.close would change the existing behavior where a copied handle can drain buffered values after close, so the native fix appears to need handle reachability/reference tracking or an explicit lifetime rule.

bend --version

Reproduced on v2.0.34 (7d8a3eb036042c6549461054d25a10f26d361c5c) and current main (57b6c150f63704f53d533776deb3859578d4b09e). Both --check-only and --verdict print ALL PROOFS CHECK.

uname -sm

Darwin arm64

clang --version

Apple clang version 21.0.0 (clang-2100.0.123.102)

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions