close method

void close()

Undeclares the queryable and releases native resources.

Remote-visible, which is why this is close() and not dispose(): getters stop reaching this queryable.

Returns even when the channel is full and nothing has been recv'd. 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.

Queries still buffered in the channel are released with it — the native channel owns them, and its drop is what frees them, so nothing is orphaned. But they are also not delivered: drain first if you need them, and reply to anything already taken before calling this.

What their getters see, measured: a query undelivered across this call completes its getter promptly — ~20 ms — with no reply of any kind, neither data nor a Timeout error. Nothing is orphaned and nothing hangs. Pinned in test/fifo_close_window_test.dart, whose measurement held the session open six seconds past the close so the completion is attributable to this call rather than to a session teardown behind it.

A pending recv completes RecvDisconnected rather than hanging.

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 release. `_closed` is
  // already set, so nothing this completion runs can re-enter canon through a
  // handle we are about to release.
  final waiter = _pending;
  _pending = null;
  waiter?.complete(const RecvDisconnected<Query>());

  // 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.
  //
  // ⚠️ ON THIS COLUMN THE GATE'S RELEASE IS NOT A NO-OP. It disposes any
  // query still stashed -- the stash's second exit -- and the position
  // matters: the dispose runs while this queryable is still declared, and
  // dropping the query is what sends canon's `ResponseFinal`, so the
  // requester's getter completes promptly rather than waiting out its
  // timeout. Without it the retrieval remedy would trade a lost reply for a
  // leaked clone.
  //
  // 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.
  //
  // The step is also load-bearing for the controller itself in exactly one
  // teardown state -- a loop that paused inside `onData` and then stashed
  // one further arrival has EXITED, and nothing else can close it.
  _gate?.close();

  // DROP THE HANDLER FIRST, then undeclare, TEE LAST.
  //
  // ⚠️ THIS COMMENT REPLACES ONE THAT REASONED CORRECTLY AND REACHED A
  // DEADLOCKING CONCLUSION. It said "Undeclare FIRST: once canon has dropped
  // the closure no further query can be pushed, so the handler drop below
  // faces a bounded buffer rather than a moving target", and that
  // `zd_queryable_drop` "blocks until executing callbacks are destroyed
  // (#1221), which is what makes the tee release safe on this column even
  // before its reference count is considered." Both statements about #1221
  // were TRUE. Neither asked 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.
  //
  // ⭐ AND THE "MOVING TARGET" WORRY IS ANSWERED BY MEASUREMENT, not by
  // argument. After the reorder canon's closure IS still live between the
  // handler drop and the undeclare's return, so a query can be pushed into a
  // dropped receiver in that window. Measured through this binding
  // (`test/fifo_close_window_test.dart`, "the getter observable for a query
  // undelivered across close() is measured and pinned"): the send fails
  // against the dropped receiver, the query is dropped, and its getter is
  // finalized promptly with no reply at all. Nothing is orphaned; the target
  // moves, and moving is harmless.
  //
  // HANDLER FIRST, because dropping the receiving end makes a parked send
  // fail fast -- the callback completes, and the undeclare then finds
  // nothing running. Canon's own construction, already relied on by
  // `PullReplies.dispose` on the third column.
  bindings
    ..zd_query_handler_drop(_handlerHandle, _kind.value)
    ..zd_queryable_drop(_queryableHandle.cast())
    // ⚠️ TEE LAST -- the surviving half of `#1221`, standing on the head's
    // own reference count rather than on the undeclare's blocking: an
    // `atomic_int` initialised to 2, one decrement from canon's closure drop
    // and one from this handle, last one frees. Order-independent by
    // construction, and verified as a RESOURCE with a both-ways injected
    // calibration in `ffi_ownership_test.dart`.
    ..zd_pull_tee_drop(_teeHandle);
  _receivePort.close();
  calloc
    ..free(_queryableHandle)
    ..free(_handlerHandle);
}