From ab34f8229b78710a6aba75de0de21cdfcc204a51 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 21:16:19 +0300 Subject: [PATCH] fix(base): an empty queue drains and clears without allocating Ludic frees nothing a safe program allocates, and every package's fact queue is drained once a frame: q_drain handed back its list and made a new one each time, most often of an empty queue. An empty drain now returns the queue's one shared empty list (never to be pushed to), and q_clear clears in place - the live list is never one a drain handed out. A queue that held facts still gives its list away, so what leaks is in proportion to what happened, not to the frame rate. queue_test holds it (an empty drain is the same list twice; a drained list is untouched by later pushes and clears). Co-Authored-By: Claude Opus 5.5 --- packages/ludic.base/queue.ludic | 10 ++++++++-- packages/ludic.base/tests/queue_test.ludic | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/ludic.base/queue.ludic b/packages/ludic.base/queue.ludic index ff3f2a2c..55e5205a 100644 --- a/packages/ludic.base/queue.ludic +++ b/packages/ludic.base/queue.ludic @@ -3,6 +3,7 @@ export property Queue { items: []T = null tag: QueueTag = null + none: []T = null # what draining an empty queue hands back, never pushed to } # what a queue says about itself, whatever it holds: its name and how many facts wait in it export property QueueTag { @@ -22,6 +23,7 @@ export function queue_new(name: string) -> Queue { q.items = new []T q.tag = new QueueTag q.tag.name = name + q.none = new []T return q } @@ -30,8 +32,11 @@ export function q_push(q: Queue, v: T) -> void { q.tag.pending = len(q.items) } -# the facts in the order they were pushed; the queue is empty afterwards +# the facts in the order they were pushed; the queue is empty afterwards. Most queues are empty +# most frames, and Ludic frees nothing: an empty drain hands back one shared empty list rather +# than a new one a frame. Read what a drain returns; never push to it. export function q_drain(q: Queue) -> []T { + if len(q.items) == 0 { return q.none } let out = q.items q.items = new []T q.tag.pending = 0 @@ -40,8 +45,9 @@ export function q_drain(q: Queue) -> []T { export function q_len(q: Queue) -> int { return len(q.items) } +# in place: the live list was never handed out (a drain gives its list away and starts another) export function q_clear(q: Queue) -> void { - q.items = new []T + List.clear(q.items) q.tag.pending = 0 } diff --git a/packages/ludic.base/tests/queue_test.ludic b/packages/ludic.base/tests/queue_test.ludic index 0d2c9a81..15631343 100644 --- a/packages/ludic.base/tests/queue_test.ludic +++ b/packages/ludic.base/tests/queue_test.ludic @@ -67,7 +67,27 @@ program QueueTest { expect_eq(len(core_undrained(tags)), 0) } + # an empty drain allocates nothing and hands back the same empty list; a drained list is the + # caller's, untouched by what is pushed or cleared after it + function empty_case() -> void { + let q: Queue = queue_new("quiet") + let a = q_drain(q) + let b = q_drain(q) + expect_eq(len(a), 0) + expect(a == b) + q_push(q, 5) + expect_eq(len(a), 0) + let got = q_drain(q) + q_push(q, 6) + q_clear(q) + expect_eq(len(got), 1) + expect_eq(got[0], 5) + expect_eq(q_len(q), 0) + expect_eq(len(q_drain(q)), 0) + } + test "a queue drains in the order it was pushed" () { fifo_case() } + test "an empty drain allocates nothing, and a drained list is the caller's" () { empty_case() } test "a queue holds records" () { record_case() } test "clear drops what is waiting" () { clear_case() } test "a queue's element type is told by the queue alone" () { infer_case() }