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