close method
void
close()
Closes the pull subscriber and releases native resources.
A pending recv completes RecvDisconnected rather than hanging.
Returns even when the channel is full and nothing has been drained.
A ChannelKind.fifo channel in overflow used to hang the calling isolate
here, permanently and unrecoverably; it no longer does, and
test/fifo_close_deadlock_test.dart is what keeps that true.
Any samples still buffered are released with the channel — drain first if you need them, not because closing otherwise misbehaves.
Safe to call multiple times -- subsequent calls are no-ops.
Implementation
void close() {
if (_closed) return;
_closed = true;
// COMPLETE THE WAITER FIRST, before any native drop. `_closed` is already
// set, so nothing this completion runs can re-enter canon through a
// handle we are about to release -- which is what keeps a pending recv()
// from being either a hang or a use-after-free.
final waiter = _pending;
_pending = null;
waiter?.complete(const RecvDisconnected<Sample>());
// CLOSE THE DEMAND GATE NEXT, still before any native drop. Same
// principle as the waiter above: everything Dart-side that could re-enter
// canon through a handle we are about to release is quiesced first, and
// the gate's own closed flag is what stops its loop calling [recv] again.
//
// It also releases whatever the gate still had stashed -- the stash's
// second exit, so the retrieval remedy does not trade a lost sample for a
// leak. On this column the release is a no-op (a Sample holds no native
// resource); on the query column it is `Query.dispose`.
//
// OWNERSHIP, and the general rule this is the fourth instance of: a push
// channel that hands out natively-backed objects must track what it has
// DELIVERED, because only the undelivered ones have no other owner. Here
// the stash is that set. Queued for promotion into
// `development/reference/dart-api-conventions-20260806.md` rather than
// restated per unit -- the release paths being: retrieved through the
// handle, released at close(), released when the controller is already
// closed.
//
// ⚠️ THIS STEP IS LOAD-BEARING IN EXACTLY ONE TEARDOWN STATE, and the
// other two are what make that measurable. With a pull still in flight --
// paused or not -- the waiter completion above reaches the loop's own
// terminal arm and the controller closes without this line (measured:
// `onDone fired=true` either way). But a loop that PAUSED INSIDE `onData`
// and then stashed one further arrival has EXITED; nothing else can close
// the controller. Measured without this line: `onDone fired=false`.
_gate?.close();
// DROP THE HANDLER FIRST, then undeclare, TEE LAST.
//
// ⚠️ THIS COMMENT REPLACES ONE THAT REASONED CORRECTLY AND REACHED A
// DEADLOCKING CONCLUSION. It said the tee drop must come after
// `zd_subscriber_drop` because that call "blocks until executing callbacks
// are destroyed (zenoh-c 1.8.0 #1221), so by here no delivery can still be
// inside the tee." Every clause of that was TRUE. What it never asked was
// what happens if the undeclare NEVER RETURNS -- and on a full fifo it does
// not, because canon's fifo callback is a `send()` on a bounded flume
// channel that blocks when full, and the only consumer that could release
// it is the isolate now parked inside this synchronous FFI call. Three
// reviews passed over it. The `#1221` constraint it encodes is real and is
// preserved on the tee drop below; only the conclusion drawn from it was
// wrong.
//
// HANDLER FIRST, because dropping the receiving end makes a parked send
// fail fast against a dropped receiver -- the callback completes, and the
// undeclare then finds nothing running. That is canon's own construction,
// and `PullReplies.dispose` already relies on it on the third column.
//
// The handler goes through the entry matching the kind it was declared
// with, because the two owned handler types are distinct and releasing
// one through the other's entry is undefined behaviour rather than a
// reported error.
bindings
..zd_pull_handler_drop(_handlerHandle, _kind.value)
..zd_subscriber_drop(_subscriberHandle.cast())
// ⚠️ TEE LAST -- the surviving half of `#1221`, and it now stands on its
// own ground rather than on the undeclare's. The head carries an
// `atomic_int refcount` initialised to 2, decremented once by canon's
// closure drop and once by this handle, last one frees
// (`src/zenoh_dart.c:756-788`, `:813-824`, `:898-904`) -- so the free is
// order-INDEPENDENT by construction, and both decrements still occur
// exactly once after the reorder. Verified as a RESOURCE rather than
// asserted: `ffi_ownership_test.dart`'s counted legs carry a four-way
// injected calibration in this exact overflow configuration.
..zd_pull_tee_drop(_teeHandle);
_receivePort.close();
// Free allocated handle memory (ours; the tee block was the shim's).
calloc
..free(_subscriberHandle)
..free(_handlerHandle);
}