Skip to content

transfer ownership on a channel try_send, not just send#538

Merged
kacy merged 1 commit into
mainfrom
channel-try-send-ownership
Jul 23, 2026
Merged

transfer ownership on a channel try_send, not just send#538
kacy merged 1 commit into
mainfrom
channel-try-send-ownership

Conversation

@kacy

@kacy kacy commented Jul 23, 2026

Copy link
Copy Markdown
Owner

summary

a channel send hands its value to another task, usually after the sender's frame is gone, so the value has to arrive with a count of its own. the emitter knew that for send and did not know it for try_send: the name list that drives channel-send ownership only matched send/pith_channel_send. two things silently did not happen for try_send:

  • a borrowed value (ch.try_send(wire.ok)) got no compensating retain, so the receiver held a value nobody owned
  • an owned temporary was released the moment the call returned, because the argument-release skip never turned on

either way the receiving task reads freed memory. pith_channel_send stores a bare i64 with no refcount awareness, so the count has to come from the emitter.

the fix is one predicate, ir_is_channel_send_name, used at the two places that decide channel-send ownership. try_send now behaves exactly like send. a try_send that fails leaves the extra count behind — a bounded leak, and the same trade the blocking form already makes.

how it showed up

std.net.http2's send_goaway queues its frame with client.outbound.try_send(wire.ok). under valgrind that is a 48-byte Bytes header freed on the main thread (Client_closesend_goaway, the sender's exit-edge release) and then read from the spawned writer task (writer_loopdrain_coalescedpith_bytes_len).

it had been masked. the payload used to leak, and the leak kept the block alive; once a recent change made that payload actually get released, the dangling read surfaced. the bug is older than that change and independent of it — this 20-line program reproduces it on unmodified main:

ch := Channel[Bytes](8)
reader := spawn drain(ch)
ch.try_send(bytes.from_string_utf8("0123456789abcdef"))

what was tested

  • tests/cases/test_channel_try_send_ownership — new, wired into MEMCHECK_CASES, covering both shapes (an owned temporary and a .ok read). built with a compiler that lacks the fix it reports Invalid read of size 4; with the fix it is clean.
  • make memcheck — all cases clean, including test_http2_tls_roundtrip, which fails 3/3 without this.
  • make run-regressions 236/236, make green-tests green, make bootstrap-verify fixed point, pith fmt --check clean.

notes

two adjacent gaps found and deliberately left alone:

  • ir_emit_select_send_probe emits pith_channel_try_send directly, bypassing the method-call path, so a select send arm still takes no count. it needs different handling — a probe that fails must leave the value usable — so it is not a copy of this fix.
  • std/net/http2/server.pith:403 works around try_send "not copying a struct payload correctly" with a blocking send. part of that is this missing retain; the aliasing half looks like the zero-capacity pending-value slot, a separate runtime question. worth re-testing that workaround now.

separately: with this fixed, widening the result-local cascade to .ok reads is memcheck-clean and regression-green. that is a bigger call — it changes ownership for every result local in the codebase — so it belongs in its own change with its own verification, not here.

https://claude.ai/code/session_01Uzg5VKBucCeHxan34VAsjv

a channel send hands its value to whoever receives it — on another task,
usually after the sender's frame is gone. the emitter knew that for
`send` and gave the value a count on the way in, but `try_send` was
never in the list. so a try_send of a heap value either released an
owned temporary the moment the call returned, or (for a borrowed value
like `wire.ok`) never took a count at all. either way the receiver ends
up holding memory nobody is keeping alive.

std.net.http2's send_goaway hits exactly that shape:

    wire := frames.frame_bytes(frame.ok)
    client.outbound.try_send(wire.ok)

the GOAWAY frame bytes went to the writer task with no count. it did not
crash only because the `wire` result local leaked its payload, which
kept the block alive by accident. releasing that payload — the point of
the cascade work on this branch — turns the leak into a use-after-free:
valgrind reports a 48-byte Bytes header freed on the main thread inside
Client.close and then read by writer_loop/drain_coalesced.

fixing the ownership makes both halves correct, and the cascade
whitelist can now be widened to `.ok` with memcheck still clean. the
whitelist stays narrow here so the two changes can be judged apart.

a try_send that fails leaves the extra count behind. that is a bounded
leak, and the same trade the blocking form already makes.

Claude-Session: https://claude.ai/code/session_01Uzg5VKBucCeHxan34VAsjv
@kacy
kacy merged commit 89aea3c into main Jul 23, 2026
2 checks passed
@kacy
kacy deleted the channel-try-send-ownership branch July 23, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant