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);
}