lazily 0.51.0

Lazy reactive signals with dependency tracking and cache invalidation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
//! Reactive queue: [`QueueCell`] + pluggable [`QueueStorage`] backend (#lzqueue).
//!
//! A `QueueCell<T>` is a FIFO collection composed of reactive cells — **not a
//! new cell kind** — that adds queue semantics (push to tail, pop from head) to
//! the reactive graph. It is specified as a **single-producer, single-consumer
//! (SPSC)** primitive; **MPSC** (multi-producer) is a *usage rule* on the same
//! primitive — multiple producers push inside a [`Context::batch`] boundary, and
//! the batch serializes the pushes into a deterministic order. There is no
//! separate `MPSCQueueCell` type (`lazily-spec/cell-model.md` § "QueueCell —
//! SPSC primitive with MPSC usage rule").
//!
//! ## Shell vs storage
//!
//! The reactive shell owns the reader-kinds (`head` / `len` / `is_empty` /
//! `is_full` as demand-driven Slots, `closed` as a Cell) and the invalidation
//! logic; it is storage-agnostic. The storage backend owns the actual FIFO data
//! structure and is pluggable via [`QueueStorage`], whose **minimal required
//! contract** is `try_push` / `try_pop` / `len` / `is_closed` / `close`
//! (`peek`/`capacity` are optional capabilities). The default [`VecDequeStorage`] is an
//! unbounded ring buffer; a bounded variant exposes reactive backpressure via
//! `is_full`. A distributed backend (`RaftQueueStorage`, future work per the
//! distributed-queue PRD) or an external-broker adapter (`KafkaStorage`, etc.)
//! plugs into the same reactive shell.
//!
//! ## Reader-kind invalidation
//!
//! Invalidation is scoped to **reader kind**, not to individual positions. A
//! push invalidates `len` / `is_empty` readers (and `head` when transitioning
//! from empty, and `is_full` when transitioning to capacity); a pop invalidates
//! `head` / `len` / `is_empty` readers (and `is_full` when transitioning off
//! capacity). The head reader observes the *current* head value — after a pop,
//! the head reader sees the next element (or `None`), not a stale value.
//!
//! Reader-kinds are **demand-driven derived Slots**, not eagerly-`Set` cells
//! (`relaycell-backpressure-analysis.md` §5 — a derived reader *is* a Slot). A
//! successful push/pop does not derive any reader value; it only invalidates the
//! Slots whose value **provably changed**, computed from the op plus the pre-op
//! `len()` (exact for any FIFO backend — no `peek` needed to decide):
//!
//! - `len` — every successful push/pop;
//! - `is_empty` — push-to-empty or pop-to-empty;
//! - `is_full` — push-to-capacity or pop-off-capacity (bounded backend only);
//! - `head` — every pop, or push-to-empty.
//!
//! Each reader's value is derived lazily from storage on first `Get` after
//! invalidation and memoized until the next relevant transition. This gives
//! reader-kind independence (a push to a non-empty queue never touches the `head`
//! Slot) without deriving anything eagerly: an **unsubscribed** `QueueCell`
//! collapses toward raw-storage cost per op (the reactive shell is charged only
//! along a path an effect actually observes — the merge cost law, §4.0), while a
//! subscribed reader stays glitch-free because the transitioning Slots are
//! invalidated together in one frontier walk ([`Context::clear_slots`]).
//!
//! ## Closure, bounded backpressure, ordering
//!
//! - **Closure** is an observable contract: pop on closed+non-empty drains;
//!   pop on closed+empty returns [`QueuePopError::Closed`] (distinct from
//!   [`QueuePopError::Empty`]); push on closed is an error; close is idempotent
//!   and terminal.
//! - **Bounded backpressure**: when the backend is bounded, `is_full` is a
//!   reactive read. A consumer's pop that transitions full → not-full
//!   invalidates `is_full` readers (true → false), enabling push-side effects to
//!   react to capacity recovery without polling.
//! - **Ordering**: SPSC gives total FIFO (pop order exactly matches push order).
//!   MPSC gives per-producer FIFO; inter-producer interleaving is deterministic
//!   within a `batch()` but the cross-batch order is batch-sequential.
//!
//! ```
//! use lazily::{Context, QueueCell};
//!
//! let ctx = Context::new();
//! let q: QueueCell<&'static str> = QueueCell::new(&ctx);
//!
//! // SPSC: total FIFO.
//! q.try_push(&ctx, "a").unwrap();
//! q.try_push(&ctx, "b").unwrap();
//! assert_eq!(q.head(&ctx), Some("a"));
//! assert_eq!(q.len(&ctx), 2);
//!
//! assert_eq!(q.try_pop(&ctx).unwrap(), "a");
//! assert_eq!(q.try_pop(&ctx).unwrap(), "b");
//! assert!(q.is_empty(&ctx));
//!
//! // MPSC: multiple producers push inside one batch → one invalidation pass.
//! ctx.batch(|ctx| {
//!     q.try_push(ctx, "p1-a").unwrap();
//!     q.try_push(ctx, "p2-a").unwrap();
//!     q.try_push(ctx, "p1-b").unwrap();
//! });
//! assert_eq!(q.len(&ctx), 3);
//! ```
//!
//! See `lazily-spec/cell-model.md` § "Reactive queues" for the full spec, and
//! `lazily-spec/docs/distributed-queue-prd.md` for the future consensus-backed
//! `RaftQueueStorage` backend.

use std::collections::{HashMap, VecDeque};
use std::hash::Hash;
use std::rc::Rc;

use crate::Context;
use crate::cell::Computed;
use crate::cell::Source;
use crate::context::ComputeOps;
use crate::topic_core::TopicCore;

// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------

/// Failure modes for [`QueueStorage::try_push`] / [`QueueCell::try_push`].
///
/// `Full` and `Closed` are the two observable rejection reasons distinguished by
/// the shell's contract (`lazily-spec/cell-model.md` § "Storage backend
/// contract"). Neither changes queue state, so neither invalidates any reader.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueuePushError {
    /// The backend is bounded and at capacity. The overflow policy (block /
    /// drop-oldest / drop-newest / reject) is a backend property; the reference
    /// [`VecDequeStorage`] rejects. Distinct from `Closed`.
    Full,
    /// The queue is closed; push is rejected regardless of capacity. Terminal —
    /// once closed, a queue cannot be reopened.
    Closed,
}

/// Failure modes for [`QueueStorage::try_pop`] / [`QueueCell::try_pop`].
///
/// `Empty` and `Closed` are distinct observable signals: `Empty` means "try
/// again later," `Closed` means "the producer is done and the queue is drained."
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueuePopError {
    /// The queue is open but contains no elements.
    Empty,
    /// The queue is closed and empty — the producer is done and all buffered
    /// elements have been consumed. Pop on a closed *non-empty* queue still
    /// drains (returns the next element); only closed+empty yields `Closed`.
    Closed,
}

// ---------------------------------------------------------------------------
// QueueStorage trait
// ---------------------------------------------------------------------------

/// Pluggable FIFO storage backend for a [`QueueCell`].
///
/// The shell / storage split (`lazily-spec/cell-model.md` § "Reactive shell vs
/// storage backend") keeps the reactive shell storage-agnostic: the shell owns
/// the reader-kind version cells and invalidation logic, the backend owns the
/// actual FIFO data structure. The default backend is [`VecDequeStorage`]
/// (unbounded `VecDeque`); future backends include `RaftQueueStorage` (embedded
/// consensus, per the distributed-queue PRD) and `KafkaStorage` /
/// `RedisStreamStorage` / `SqsStorage` (external-broker adapters).
///
/// # Conformance
///
/// **Minimal required contract** — a conforming backend MUST implement exactly:
/// [`try_push`](QueueStorage::try_push), [`try_pop`](QueueStorage::try_pop),
/// [`len`](QueueStorage::len), [`is_closed`](QueueStorage::is_closed), and
/// [`close`](QueueStorage::close). [`peek`](QueueStorage::peek) and
/// [`capacity`](QueueStorage::capacity) are **optional capabilities** with
/// default impls returning `None`; a raw channel that satisfies only the five
/// required methods is fully conforming (it simply has no `head`/`is_full`
/// reader). A conforming backend MUST also:
///
/// 1. **FIFO order** — `try_pop` returns elements in `try_push` order.
/// 2. **Cardinality compatibility** — its native producer/consumer shape is a
///    superset of the shell's required shape (SPSC shell = any backend; MPSC
///    usage requires a multi-writer backend).
/// 3. **Bounded contract (optional)** — a bounded backend overrides
///    [`capacity`](QueueStorage::capacity) to return `Some(n)` and `try_push`
///    returns [`Full`](QueuePushError::Full) at capacity. The overflow policy is
///    a backend property. An unbounded backend uses the default `None`.
/// 4. **Position identity** — invalidation is phrased over reader kind, not
///    storage indices. A ring-buffer backend whose slot index wraps MUST NOT
///    cause spurious invalidations; the shell layers its own demand-driven
///    reader-kind Slots above the storage.
//
// `is_empty` is deliberately NOT on this trait: emptiness is a shell-level
// reader kind, not a storage property (the shell derives `is_empty` from
// `len()`). See `lazily-spec/cell-model.md` § "Storage backend contract".
#[allow(clippy::len_without_is_empty)]
pub trait QueueStorage<T> {
    /// Append `value` to the tail. Returns [`QueuePushError::Full`] if bounded
    /// and at capacity, or [`QueuePushError::Closed`] if the queue is closed.
    /// On error the queue state is unchanged.
    fn try_push(&mut self, value: T) -> Result<(), QueuePushError>;

    /// Remove and return the head element. Returns [`QueuePopError::Empty`] if
    /// open and empty, or [`QueuePopError::Closed`] if closed and empty. Pop on
    /// a closed *non-empty* queue drains (returns the next element).
    fn try_pop(&mut self) -> Result<T, QueuePopError>;

    /// **Optional capability.** Peek the current head element without removing
    /// it, or `None` when empty. The shell reads this to materialize its `head`
    /// reader-kind Slot.
    ///
    /// This is **not** part of the required contract. The default returns `None`,
    /// so a backend that cannot peek (a raw channel, a consuming stream) is fully
    /// conforming — it simply has no meaningful `head` reader, exactly as an
    /// unbounded backend (`capacity() == None`) has no meaningful `is_full`. A
    /// backend that *can* cheaply inspect its head overrides this to expose a
    /// reactive `head`.
    ///
    /// *Footnote — `LookaheadShim`.* A caller that needs a `head` over a
    /// non-peekable backend MAY opt into a shell-level lookahead shim that
    /// prefetches (early-pops) one element into a one-slot buffer and reports it
    /// as the head. This is **SPSC-local only**: early-popping is incorrect for
    /// competing-consumer or consensus backends, where the popped element must
    /// not be committed to one consumer before assignment. The shim is not part
    /// of the core and is out of scope for the minimal contract.
    fn peek(&self) -> Option<&T> {
        None
    }

    /// Current number of buffered elements. **Required.**
    fn len(&self) -> usize;

    /// **Optional capability.** Bounded capacity, or `None` for an unbounded
    /// backend (the default). When `Some(n)`, the shell exposes `is_full` as a
    /// reactive backpressure read; when `None`, `is_full` is trivially `false`.
    fn capacity(&self) -> Option<usize> {
        None
    }

    /// Whether the queue has been closed. Close is terminal — once true, it
    /// stays true.
    fn is_closed(&self) -> bool;

    /// Close the queue. Idempotent — closing an already-closed queue is a
    /// no-op. After close, [`try_push`](QueueStorage::try_push) returns
    /// [`Closed`](QueuePushError::Closed); [`try_pop`](QueueStorage::try_pop)
    /// continues to drain buffered elements and returns
    /// [`Closed`](QueuePopError::Closed) only once empty.
    fn close(&mut self);
}

// ---------------------------------------------------------------------------
// VecDequeStorage — the reference unbounded/bounded backend
// ---------------------------------------------------------------------------

/// The reference [`QueueStorage`] backend: a `VecDeque`-backed FIFO, optionally
/// bounded.
///
/// The unbounded form (the default) is what [`QueueCell::new`] uses; the bounded
/// form ([`VecDequeStorage::with_capacity`] / [`QueueCell::with_capacity`])
/// exposes reactive backpressure via the shell's `is_full` reader. The overflow
/// policy is **reject** — `try_push` at capacity returns
/// [`QueuePushError::Full`] (elements are never silently dropped); other
/// backends may choose block / drop-oldest / drop-newest.
///
/// Serializes as a JSON array (element order = FIFO order) for conformance
/// fixture purposes, matching `lazily-spec/cell-model.md` § "Wire and snapshot
/// shape".
#[derive(Debug, Clone, Default)]
pub struct VecDequeStorage<T> {
    elements: VecDeque<T>,
    capacity: Option<usize>,
    closed: bool,
}

impl<T> VecDequeStorage<T> {
    /// Create an unbounded storage (no capacity limit).
    pub fn unbounded() -> Self {
        Self {
            elements: VecDeque::new(),
            capacity: None,
            closed: false,
        }
    }

    /// Create a bounded storage that rejects pushes once it holds `capacity`
    /// elements.
    ///
    /// # Panics
    ///
    /// Panics if `capacity == 0` (a zero-capacity queue can never accept an
    /// element and has no useful semantics).
    pub fn with_capacity(capacity: usize) -> Self {
        assert!(capacity > 0, "VecDequeStorage capacity must be > 0");
        Self {
            elements: VecDeque::with_capacity(capacity),
            capacity: Some(capacity),
            closed: false,
        }
    }

    /// Snapshot the buffered elements in FIFO order (clone of each element).
    /// Non-reactive — for snapshot/serde and conformance-fixture verification.
    pub fn elements(&self) -> Vec<T>
    where
        T: Clone,
    {
        self.elements.iter().cloned().collect()
    }
}

impl<T> QueueStorage<T> for VecDequeStorage<T> {
    fn try_push(&mut self, value: T) -> Result<(), QueuePushError> {
        if self.closed {
            return Err(QueuePushError::Closed);
        }
        if let Some(cap) = self.capacity
            && self.elements.len() >= cap
        {
            return Err(QueuePushError::Full);
        }
        self.elements.push_back(value);
        Ok(())
    }

    fn try_pop(&mut self) -> Result<T, QueuePopError> {
        match self.elements.pop_front() {
            Some(v) => Ok(v),
            None => {
                if self.closed {
                    Err(QueuePopError::Closed)
                } else {
                    Err(QueuePopError::Empty)
                }
            }
        }
    }

    fn peek(&self) -> Option<&T> {
        self.elements.front()
    }

    fn len(&self) -> usize {
        self.elements.len()
    }

    fn capacity(&self) -> Option<usize> {
        self.capacity
    }

    fn is_closed(&self) -> bool {
        self.closed
    }

    fn close(&mut self) {
        self.closed = true;
    }
}

#[cfg(feature = "serde")]
impl<T> serde::Serialize for VecDequeStorage<T>
where
    T: serde::Serialize,
{
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        use serde::ser::SerializeSeq;
        let mut seq = serializer.serialize_seq(None)?;
        for e in &self.elements {
            seq.serialize_element(e)?;
        }
        seq.end()
    }
}

// ---------------------------------------------------------------------------
// QueueCell — the reactive shell
// ---------------------------------------------------------------------------

struct QueueCellInner<T, S> {
    // `Rc` so each reader-kind Slot's compute closure can capture the storage
    // independently of this struct (the struct also holds the Slot handles, so
    // a closure cannot borrow the struct itself without a cycle).
    storage: Rc<std::cell::RefCell<S>>,
    // Cached at construction: capacity is a contractually fixed backend property
    // (`QueueStorage` §Conformance). Caching it keeps the per-op invalidation
    // path off the storage borrow when deciding whether `is_full` transitioned.
    capacity: Option<usize>,
    // Reader-kind derived Slots (demand-driven). Each derives its value lazily
    // from `storage` on first `Get` after invalidation and memoizes it; the
    // shell invalidates only the Slots whose value provably changed on a given
    // op (see the module docs). `closed` stays a `Cell` because it changes only
    // via `close()`, never derived from a mutation transition.
    head: Computed<Option<T>>,
    len: Computed<usize>,
    is_empty: Computed<bool>,
    is_full: Computed<bool>,
    closed: Source<bool>,
}

/// A reactive FIFO queue — SPSC primitive with an MPSC usage rule
/// (`#lzqueue`).
///
/// Cheap to [`Clone`] (an `Rc` to shared shell state), so the same queue can be
/// handed to producer and consumer closures. The reactive shell wraps a
/// pluggable [`QueueStorage`] backend (default [`VecDequeStorage`]); the shell
/// owns the reader-kind version cells (`head` / `len` / `is_empty` / `is_full`
/// / `closed`) and invalidates by reader kind — a push to a non-empty queue does
/// NOT invalidate the `head` reader, a pop does. See the module docs for the
/// full reader-kind independence contract.
pub struct QueueCell<T, S = VecDequeStorage<T>> {
    inner: Rc<QueueCellInner<T, S>>,
}

impl<T, S> Clone for QueueCell<T, S> {
    fn clone(&self) -> Self {
        Self {
            inner: Rc::clone(&self.inner),
        }
    }
}

impl<T> QueueCell<T, VecDequeStorage<T>>
where
    T: PartialEq + Clone + 'static,
{
    /// Create an unbounded queue (the default reference backend).
    pub fn new(ctx: &Context) -> Self {
        Self::with_storage(ctx, VecDequeStorage::unbounded())
    }

    /// Create a bounded queue with `capacity`. Exposes reactive backpressure via
    /// [`is_full`](Self::is_full): a pop that transitions full → not-full
    /// invalidates `is_full` readers.
    ///
    /// # Panics
    ///
    /// Panics if `capacity == 0`.
    pub fn with_capacity(ctx: &Context, capacity: usize) -> Self {
        Self::with_storage(ctx, VecDequeStorage::with_capacity(capacity))
    }
}

impl<T, S> QueueCell<T, S>
where
    T: PartialEq + Clone + 'static,
    S: QueueStorage<T> + 'static,
{
    /// Build a queue over an arbitrary [`QueueStorage`] backend. The shell wires
    /// its reader-kind Slots to derive lazily from the backend; no value is
    /// materialized until a reader is first observed.
    pub fn with_storage(ctx: &Context, storage: S) -> Self {
        let closed_val = storage.is_closed();
        let capacity = storage.capacity();
        let storage = Rc::new(std::cell::RefCell::new(storage));

        // Each reader-kind Slot memoizes a value derived from storage. The Slot
        // has no reactive dependencies (storage is out-of-graph), so once
        // computed it stays clean until the shell explicitly invalidates it on a
        // transition (see `invalidate_readers`).
        let head = {
            let storage = Rc::clone(&storage);
            ctx.computed(move |_ctx| storage.borrow().peek().cloned())
        };
        let len = {
            let storage = Rc::clone(&storage);
            ctx.computed(move |_ctx| storage.borrow().len())
        };
        let is_empty = {
            let storage = Rc::clone(&storage);
            ctx.computed(move |_ctx| storage.borrow().len() == 0)
        };
        let is_full = {
            let storage = Rc::clone(&storage);
            ctx.computed(move |_ctx| {
                let s = storage.borrow();
                match s.capacity() {
                    Some(cap) => s.len() >= cap,
                    None => false,
                }
            })
        };

        Self {
            inner: Rc::new(QueueCellInner {
                storage,
                capacity,
                head,
                len,
                is_empty,
                is_full,
                closed: ctx.source(closed_val),
            }),
        }
    }

    /// Invalidate exactly the reader-kind Slots whose derived value changed on a
    /// successful op that took the queue from `len_before` to `len_after`, in one
    /// atomic frontier walk so a subscribed observer never sees a partial state
    /// (e.g. `len` bumped but `is_full` not yet flipped). `head_changed` is
    /// passed by the caller because head depends on op *direction*, not just
    /// `len` (a pop always changes head; a push changes it only from empty).
    ///
    /// No reader value is derived here — invalidation only clears the changed
    /// Slots' caches (each re-derives lazily on its next `Get`). A reader-kind
    /// with no subscribers and no cache hits the `clear_slots` no-op fast path,
    /// so an unobserved op skips all derivation, effect scheduling, and flush.
    /// `closed` is never touched here: it changes only via [`close`](Self::close).
    fn invalidate_readers(
        &self,
        ctx: &Context,
        len_before: usize,
        len_after: usize,
        head_changed: bool,
    ) {
        let is_empty_changed = (len_before == 0) != (len_after == 0);
        let is_full_changed = self
            .inner
            .capacity
            .map(|c| (len_before >= c) != (len_after >= c))
            .unwrap_or(false);

        // Fixed-capacity buffer (no heap alloc on the hot path). `len` always
        // changes on a successful op, so it is always the first root.
        let mut roots = [self.inner.len.id; 4];
        let mut n = 1;
        if is_empty_changed {
            roots[n] = self.inner.is_empty.id;
            n += 1;
        }
        if is_full_changed {
            roots[n] = self.inner.is_full.id;
            n += 1;
        }
        if head_changed {
            roots[n] = self.inner.head.id;
            n += 1;
        }
        ctx.clear_slots(&roots[..n]);
    }

    /// Append `value` to the tail of the queue.
    ///
    /// Returns [`QueuePushError::Full`] if bounded and at capacity (reject
    /// policy — the default `VecDequeStorage` never silently drops), or
    /// [`QueuePushError::Closed`] if the queue is closed. On error the queue
    /// state is unchanged and no reader is invalidated.
    ///
    /// Invalidates `head` (only when transitioning from empty), `len`, and
    /// `is_empty` readers as appropriate; `is_full` when transitioning onto
    /// capacity. Does not touch `closed`.
    pub fn try_push(&self, ctx: &Context, value: T) -> Result<(), QueuePushError> {
        let (result, len_before) = {
            let mut s = self.inner.storage.borrow_mut();
            let len_before = s.len();
            (s.try_push(value), len_before)
        };
        if result.is_ok() {
            self.invalidate_readers(ctx, len_before, len_before + 1, len_before == 0);
        }
        result
    }

    /// Remove and return the head element.
    ///
    /// Returns [`QueuePopError::Empty`] if open and empty, or
    /// [`QueuePopError::Closed`] if closed and empty. Pop on a closed
    /// *non-empty* queue drains (returns the next element).
    ///
    /// Invalidates `head` (always — the head value changes), `len`, and
    /// `is_empty` (when transitioning to empty) readers as appropriate;
    /// `is_full` when transitioning off capacity.
    pub fn try_pop(&self, ctx: &Context) -> Result<T, QueuePopError> {
        let (result, len_before) = {
            let mut s = self.inner.storage.borrow_mut();
            let len_before = s.len();
            (s.try_pop(), len_before)
        };
        if result.is_ok() {
            // A successful pop always advances head and always decrements len.
            self.invalidate_readers(ctx, len_before, len_before - 1, true);
        }
        result
    }

    /// Close the queue. Idempotent — closing an already-closed queue is a
    /// no-op (no invalidation). Terminal — once closed, a queue cannot be
    /// reopened. After close, [`try_push`](Self::try_push) returns `Closed`;
    /// [`try_pop`](Self::try_pop) continues to drain and returns `Closed` only
    /// once empty.
    ///
    /// Invalidates the `closed` reader only on the false → true transition.
    pub fn close(&self, ctx: &Context) {
        let was_closed = self.inner.storage.borrow().is_closed();
        if was_closed {
            return;
        }
        self.inner.storage.borrow_mut().close();
        ctx.set(&self.inner.closed, true);
    }

    // -- Reactive reader-kind reads ----------------------------------------

    /// Reactive read of the current head value. `None` when the queue is empty.
    /// A reader is invalidated when the head value *changes* — every pop, and a
    /// push only when transitioning from empty.
    pub fn head<C: ComputeOps>(&self, ctx: &C) -> Option<T> {
        self.inner.head.get(ctx)
    }

    /// Handle to the `head` reader-kind Slot, for wiring derived computeds
    /// directly. Subscribe-to-head semantics: invalidated on head-value change.
    pub fn head_handle(&self) -> Computed<Option<T>> {
        self.inner.head
    }

    /// Reactive read of the number of buffered elements. Invalidated whenever
    /// the count changes (every successful push/pop).
    pub fn len<C: ComputeOps>(&self, ctx: &C) -> usize {
        self.inner.len.get(ctx)
    }

    /// Reactive emptiness check. Invalidated only on the empty ↔ non-empty
    /// transition.
    pub fn is_empty<C: ComputeOps>(&self, ctx: &C) -> bool {
        self.inner.is_empty.get(ctx)
    }

    /// Reactive fullness check (only meaningful when the backend is bounded).
    /// Invalidated on the full ↔ not-full transition — this is the backpressure
    /// signal: a producer observes `is_full` and backs off; a consumer's pop
    /// that transitions full → not-full invalidates the producer's `is_full`
    /// subscription and the producer resumes. For an unbounded backend this is
    /// always `false` and never invalidates.
    pub fn is_full<C: ComputeOps>(&self, ctx: &C) -> bool {
        self.inner.is_full.get(ctx)
    }

    /// Reactive read of the closed flag. Invalidated only on the open → closed
    /// transition.
    pub fn is_closed<C: ComputeOps>(&self, ctx: &C) -> bool {
        self.inner.closed.get(ctx)
    }

    /// Handles to the reader-kind nodes, for advanced wiring (e.g. effects that
    /// subscribe to multiple reader kinds).
    pub fn reader_handles(&self) -> QueueReaderHandles<T> {
        QueueReaderHandles {
            head: self.inner.head,
            len: self.inner.len,
            is_empty: self.inner.is_empty,
            is_full: self.inner.is_full,
            closed: self.inner.closed,
        }
    }

    // -- Non-reactive storage access ---------------------------------------

    /// The backend's capacity, or `None` if unbounded. Cached at construction
    /// (capacity is a fixed backend property), so this does not touch storage.
    pub fn capacity(&self) -> Option<usize> {
        self.inner.capacity
    }
}

impl<T> QueueCell<T, VecDequeStorage<T>>
where
    T: PartialEq + Clone + 'static,
{
    /// Snapshot the buffered elements in FIFO order. Non-reactive — for
    /// debugging, snapshot/serde, and conformance-fixture verification. There
    /// is no reactive random-access `queue[N]` reader; per-position reactivity
    /// is the domain of `SourceMap`, not `QueueCell`.
    pub fn elements(&self) -> Vec<T> {
        self.inner.storage.borrow().elements()
    }
}

/// Handles to all five reader-kinds of a [`QueueCell`], for effects that need to
/// subscribe to several reader kinds at once. The four derived reader-kinds are
/// demand-driven [`Computed`]s; `closed` is a [`Source`] because it is a
/// direct input (set by [`close`](QueueCell::close)), not a derived value.
#[derive(Debug, Clone, Copy)]
pub struct QueueReaderHandles<T> {
    /// The head value (`None` when empty).
    pub head: Computed<Option<T>>,
    /// The element count.
    pub len: Computed<usize>,
    /// Whether the queue is empty.
    pub is_empty: Computed<bool>,
    /// Whether the queue is at capacity (bounded backpressure signal).
    pub is_full: Computed<bool>,
    /// Whether the queue has been closed.
    pub closed: Source<bool>,
}

// ---------------------------------------------------------------------------
// TopicCell — broadcast log with independent reactive subscriber cursors
// ---------------------------------------------------------------------------

/// Whether a [`TopicCell`] subscription survives disconnects and participates
/// in the retention frontier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TopicDurability {
    /// Cursor state persists while disconnected and holds back safe GC.
    Durable,
    /// Cursor state exists only for the connected session and never holds GC.
    Ephemeral,
}

/// Public, serializable-in-spirit state for one topic subscription.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopicSubscriptionSnapshot {
    /// Absolute offset of the next element to read.
    pub cursor: u64,
    /// Durable subscriptions survive disconnect; ephemeral ones are removed.
    pub durability: TopicDurability,
    /// Offline durable subscriptions retain data but are not scheduled.
    pub connected: bool,
}

/// Durable state required to recreate a [`TopicCell`] without moving cursors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopicSnapshot<T, I: Eq + Hash = String> {
    /// Absolute offset of `elements[0]`.
    pub base_offset: u64,
    /// Retained append log, oldest first.
    pub elements: Vec<T>,
    /// Stable subscriber identity to persisted subscription state.
    pub subscriptions: HashMap<I, TopicSubscriptionSnapshot>,
}

/// Result of subscribing a stable identity.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TopicSubscribeOutcome {
    /// A new cursor was created at the current tail.
    Created,
    /// An offline durable cursor was reconnected without moving it.
    Reconnected,
    /// The identity was already connected; no state changed.
    AlreadyConnected,
}

struct TopicCellInner<T, I> {
    core: Rc<std::cell::RefCell<TopicCore<T, I>>>,
    // A distinct demand-driven reader per stable subscriber is the essential
    // invalidation boundary: publish fans out to connected readers; advance,
    // disconnect, and reconnect touch only the named reader.
    readers: std::cell::RefCell<HashMap<I, Computed<Vec<T>>>>,
}

/// A broadcast topic: every subscriber receives every published element using
/// an independent, non-destructive cursor (`#lztopiccell`).
///
/// Elements are retained until every durable cursor has passed them. Ephemeral
/// subscriptions start at the current tail, disappear on disconnect, and never
/// hold the GC frontier. Each subscription owns a demand-driven reactive read
/// stream, so advancing one subscriber never invalidates another subscriber.
pub struct TopicCell<T, I = String> {
    inner: Rc<TopicCellInner<T, I>>,
}

impl<T, I> Clone for TopicCell<T, I> {
    fn clone(&self) -> Self {
        Self {
            inner: Rc::clone(&self.inner),
        }
    }
}

impl<T, I> TopicCell<T, I>
where
    T: PartialEq + Clone + 'static,
    I: Eq + Hash + Clone + 'static,
{
    /// Create an empty topic at absolute offset zero.
    pub fn new(_ctx: &Context) -> Self {
        Self {
            inner: Rc::new(TopicCellInner {
                core: Rc::new(std::cell::RefCell::new(TopicCore::new())),
                readers: std::cell::RefCell::new(HashMap::new()),
            }),
        }
    }

    /// Restore an atomic topic snapshot. Durable cursors are preserved exactly;
    /// connected ephemeral records are accepted for live-state fixture replay.
    ///
    /// # Panics
    ///
    /// Panics if any cursor lies outside `base_offset..=end_offset`.
    pub fn from_snapshot(ctx: &Context, snapshot: TopicSnapshot<T, I>) -> Self {
        let topic = Self {
            inner: Rc::new(TopicCellInner {
                core: Rc::new(std::cell::RefCell::new(TopicCore::from_snapshot(snapshot))),
                readers: std::cell::RefCell::new(HashMap::new()),
            }),
        };
        let ids = topic.inner.core.borrow().subscription_ids();
        for id in ids {
            topic.ensure_reader(ctx, id);
        }
        topic
    }

    fn ensure_reader(&self, ctx: &Context, id: I) -> Computed<Vec<T>> {
        if let Some(handle) = self.inner.readers.borrow().get(&id) {
            return *handle;
        }
        let core = Rc::clone(&self.inner.core);
        let reader_id = id.clone();
        let handle = ctx.computed(move |_ctx| core.borrow().read_suffix(&reader_id));
        self.inner.readers.borrow_mut().insert(id, handle);
        handle
    }

    /// Create a cursor at the current tail, or reconnect an existing durable
    /// identity without moving its cursor. Once an identity exists its stored
    /// durability wins over the caller's argument.
    pub fn subscribe(
        &self,
        ctx: &Context,
        id: I,
        durability: TopicDurability,
    ) -> TopicSubscribeOutcome {
        let (outcome, invalidate) = self
            .inner
            .core
            .borrow_mut()
            .subscribe(id.clone(), durability);
        let reader = self.ensure_reader(ctx, id);
        if invalidate {
            ctx.clear_slots(&[reader.id]);
        }
        outcome
    }

    /// Reconnect a durable identity, preserving its cursor. Unknown identities
    /// are created as durable subscriptions at the current tail.
    pub fn reconnect(&self, ctx: &Context, id: I) -> TopicSubscribeOutcome {
        self.subscribe(ctx, id, TopicDurability::Durable)
    }

    /// Disconnect a subscriber. Durable records remain offline at the same
    /// cursor; ephemeral records and their retention-neutral session state are
    /// removed. Returns whether state changed.
    pub fn disconnect(&self, ctx: &Context, id: &I) -> bool {
        let Some(remove_reader) = self.inner.core.borrow_mut().disconnect(id) else {
            return false;
        };
        let reader = if remove_reader {
            self.inner.readers.borrow_mut().remove(id)
        } else {
            self.inner.readers.borrow().get(id).copied()
        };
        if let Some(reader) = reader {
            ctx.clear_slots(&[reader.id]);
        }
        true
    }

    /// Append exactly one element, leaving every cursor unchanged. Returns its
    /// absolute offset and invalidates every connected subscriber independently.
    pub fn publish(&self, ctx: &Context, value: T) -> u64 {
        let (offset, connected) = self.inner.core.borrow_mut().publish(value);
        let readers = self.inner.readers.borrow();
        let roots: Vec<_> = connected
            .iter()
            .filter_map(|id| readers.get(id).map(|reader| reader.id))
            .collect();
        ctx.clear_slots(&roots);
        offset
    }

    /// Reactive suffix read for one connected subscriber. Unknown and offline
    /// subscribers observe an empty stream.
    pub fn read_stream(&self, ctx: &Context, id: &I) -> Vec<T> {
        self.inner
            .readers
            .borrow()
            .get(id)
            .copied()
            .map(|reader| ctx.get(&reader))
            .unwrap_or_default()
    }

    /// Reactive read of the element at a subscriber's cursor.
    pub fn read(&self, ctx: &Context, id: &I) -> Option<T> {
        self.read_stream(ctx, id).into_iter().next()
    }

    /// Advance only the named connected cursor by one, returning the element it
    /// passed. At the tail, for an offline subscriber, or for an unknown id this
    /// is a no-op returning `None`.
    pub fn advance(&self, ctx: &Context, id: &I) -> Option<T> {
        let value = self.inner.core.borrow_mut().advance(id)?;
        if let Some(reader) = self.inner.readers.borrow().get(id) {
            ctx.clear_slots(&[reader.id]);
        }
        Some(value)
    }

    /// Remove the prefix below the minimum durable cursor, or all retained
    /// elements when no durable subscription exists. Subscriber cursors remain
    /// absolute, so safe GC invalidates no reader. Returns the removed count.
    pub fn gc(&self) -> usize {
        self.inner.core.borrow_mut().gc()
    }

    /// Absolute offset of the first retained element.
    pub fn base_offset(&self) -> u64 {
        self.inner.core.borrow().base_offset()
    }

    /// Absolute offset immediately after the retained append log.
    pub fn end_offset(&self) -> u64 {
        self.inner.core.borrow().end_offset()
    }

    /// Non-reactive retained-log snapshot, oldest first.
    pub fn elements(&self) -> Vec<T> {
        self.inner.core.borrow().elements()
    }

    /// Snapshot one subscription record.
    pub fn subscription(&self, id: &I) -> Option<TopicSubscriptionSnapshot> {
        self.inner.core.borrow().subscription(id)
    }

    /// Handle to the named subscriber's reactive unread suffix.
    pub fn reader_handle(&self, id: &I) -> Option<Computed<Vec<T>>> {
        self.inner.readers.borrow().get(id).copied()
    }

    /// Atomic durable/live-state snapshot suitable for restart and conformance.
    pub fn snapshot(&self) -> TopicSnapshot<T, I> {
        self.inner.core.borrow().snapshot()
    }
}

// `WorkQueueCell` remains separate future work: N consumers compete for
// exclusive handoff, which requires an authority to serialize assignment.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn spsc_fifo_basic() {
        let ctx = Context::new();
        let q: QueueCell<i32> = QueueCell::new(&ctx);
        assert!(q.is_empty(&ctx));
        assert_eq!(q.head(&ctx), None);

        q.try_push(&ctx, 1).unwrap();
        q.try_push(&ctx, 2).unwrap();
        q.try_push(&ctx, 3).unwrap();

        assert_eq!(q.len(&ctx), 3);
        assert_eq!(q.head(&ctx), Some(1));
        assert_eq!(q.elements(), vec![1, 2, 3]);

        assert_eq!(q.try_pop(&ctx).unwrap(), 1);
        assert_eq!(q.try_pop(&ctx).unwrap(), 2);
        assert_eq!(q.try_pop(&ctx).unwrap(), 3);
        assert_eq!(q.try_pop(&ctx), Err(QueuePopError::Empty));
    }

    #[test]
    fn bounded_rejects_at_capacity() {
        let ctx = Context::new();
        let q = QueueCell::<i32>::with_capacity(&ctx, 2);
        assert_eq!(q.capacity(), Some(2));
        assert!(!q.is_full(&ctx));

        q.try_push(&ctx, 1).unwrap();
        q.try_push(&ctx, 2).unwrap();
        assert!(q.is_full(&ctx));
        assert_eq!(q.try_push(&ctx, 3), Err(QueuePushError::Full));

        // pop frees a slot → is_full flips → reactive backpressure signal
        assert_eq!(q.try_pop(&ctx).unwrap(), 1);
        assert!(!q.is_full(&ctx));
        q.try_push(&ctx, 3).unwrap();
        assert!(q.is_full(&ctx));
    }

    #[test]
    fn closure_lifecycle() {
        let ctx = Context::new();
        let q: QueueCell<&str> = QueueCell::new(&ctx);
        q.try_push(&ctx, "a").unwrap();
        q.try_push(&ctx, "b").unwrap();

        q.close(&ctx);
        assert!(q.is_closed(&ctx));

        // push on closed is an error
        assert_eq!(q.try_push(&ctx, "c"), Err(QueuePushError::Closed));

        // pop on closed+non-empty drains
        assert_eq!(q.try_pop(&ctx).unwrap(), "a");
        assert_eq!(q.try_pop(&ctx).unwrap(), "b");

        // pop on closed+empty returns Closed (distinct from Empty)
        assert_eq!(q.try_pop(&ctx), Err(QueuePopError::Closed));

        // idempotent close — no-op, no invalidation
        q.close(&ctx);
        assert!(q.is_closed(&ctx));
    }

    #[test]
    fn reader_kind_independence_head_not_invalidated_on_push_to_nonempty() {
        let ctx = Context::new();
        let q: QueueCell<i32> = QueueCell::new(&ctx);

        let head_reader = ctx.computed({
            let q = q.clone();
            move |ctx| q.head(ctx)
        });
        assert_eq!(ctx.get(&head_reader), None);

        q.try_push(&ctx, 1).unwrap();
        // push to empty changes head → invalidated
        assert_eq!(ctx.get(&head_reader), Some(1));

        q.try_push(&ctx, 2).unwrap();
        q.try_push(&ctx, 3).unwrap();
        // head reader still cached (head unchanged) — reader-kind independence
        assert!(
            ctx.is_set(&head_reader),
            "push to non-empty must not invalidate head reader"
        );

        q.try_pop(&ctx).unwrap();
        // pop changes head → invalidated
        assert_eq!(ctx.get(&head_reader), Some(2));
    }

    #[test]
    fn mpsc_via_batch_is_one_invalidation_pass() {
        let ctx = Context::new();
        let q: QueueCell<i32> = QueueCell::new(&ctx);

        let len_reader = ctx.computed({
            let q = q.clone();
            move |ctx| q.len(ctx)
        });
        assert_eq!(ctx.get(&len_reader), 0);

        ctx.batch(|ctx| {
            q.try_push(ctx, 10).unwrap();
            q.try_push(ctx, 20).unwrap();
            q.try_push(ctx, 30).unwrap();
        });
        // After the batch the len reader is invalidated exactly once and sees 3.
        assert!(
            !ctx.is_set(&len_reader),
            "batch should have invalidated the len reader once"
        );
        assert_eq!(ctx.get(&len_reader), 3);
        assert_eq!(q.elements(), vec![10, 20, 30]);
    }

    #[test]
    fn clone_shares_state() {
        let ctx = Context::new();
        let q: QueueCell<i32> = QueueCell::new(&ctx);
        let producer = q.clone();
        producer.try_push(&ctx, 42).unwrap();
        assert_eq!(q.try_pop(&ctx).unwrap(), 42);
    }

    /// A raw-channel-style backend that implements ONLY the minimal required
    /// contract — `try_push` / `try_pop` / `len` / `is_closed` / `close` — and
    /// deliberately does not override `peek` or `capacity`. This is the shape of
    /// a Go channel / consuming mpsc: no lookahead, no bound advertised. It
    /// proves the minimal contract (0c): such a backend is fully conforming and
    /// simply has no meaningful `head`/`is_full` reader.
    #[derive(Default)]
    struct MinimalFifoStorage<T> {
        elements: VecDeque<T>,
        closed: bool,
    }

    impl<T> QueueStorage<T> for MinimalFifoStorage<T> {
        fn try_push(&mut self, value: T) -> Result<(), QueuePushError> {
            if self.closed {
                return Err(QueuePushError::Closed);
            }
            self.elements.push_back(value);
            Ok(())
        }

        fn try_pop(&mut self) -> Result<T, QueuePopError> {
            match self.elements.pop_front() {
                Some(v) => Ok(v),
                None if self.closed => Err(QueuePopError::Closed),
                None => Err(QueuePopError::Empty),
            }
        }

        fn len(&self) -> usize {
            self.elements.len()
        }

        fn is_closed(&self) -> bool {
            self.closed
        }

        fn close(&mut self) {
            self.closed = true;
        }
        // NB: no `peek`, no `capacity` — the trait defaults apply.
    }

    #[test]
    fn raw_channel_backend_conforms_to_minimal_contract() {
        let ctx = Context::new();
        let q: QueueCell<i32, MinimalFifoStorage<i32>> =
            QueueCell::with_storage(&ctx, MinimalFifoStorage::default());

        // FIFO + len + is_empty derive from the required methods alone.
        assert!(q.is_empty(&ctx));
        q.try_push(&ctx, 1).unwrap();
        q.try_push(&ctx, 2).unwrap();
        assert_eq!(q.len(&ctx), 2);
        assert!(!q.is_empty(&ctx));

        // No peek capability → no meaningful head reader (trivially None), just
        // as an unbounded backend has no meaningful is_full (trivially false).
        assert_eq!(q.head(&ctx), None);
        assert!(!q.is_full(&ctx));
        assert_eq!(q.capacity(), None);

        // FIFO drain order is preserved by try_pop alone.
        assert_eq!(q.try_pop(&ctx).unwrap(), 1);
        assert_eq!(q.try_pop(&ctx).unwrap(), 2);
        assert!(q.is_empty(&ctx));

        // Closure lifecycle: Closed distinct from Empty, push-after-close errors.
        q.close(&ctx);
        assert!(q.is_closed(&ctx));
        assert_eq!(q.try_push(&ctx, 3), Err(QueuePushError::Closed));
        assert_eq!(q.try_pop(&ctx), Err(QueuePopError::Closed));
    }

    /// A subscribed reader over the minimal backend still reacts correctly: the
    /// demand-driven `len` Slot invalidates on each op even without `peek`.
    #[test]
    fn raw_channel_backend_reader_kinds_stay_reactive() {
        let ctx = Context::new();
        let q: QueueCell<i32, MinimalFifoStorage<i32>> =
            QueueCell::with_storage(&ctx, MinimalFifoStorage::default());

        let len_reader = ctx.computed({
            let q = q.clone();
            move |ctx| q.len(ctx)
        });
        assert_eq!(ctx.get(&len_reader), 0);

        q.try_push(&ctx, 10).unwrap();
        assert!(!ctx.is_set(&len_reader), "push must invalidate len reader");
        assert_eq!(ctx.get(&len_reader), 1);

        q.try_pop(&ctx).unwrap();
        assert!(!ctx.is_set(&len_reader), "pop must invalidate len reader");
        assert_eq!(ctx.get(&len_reader), 0);
    }
}