From c74099f5f8150d0e4ef353a18677a4a0e35a99d7 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 22:55:43 +0300 Subject: [PATCH] fix(base): a queue drain allocates nothing - two lists, reused The rest of the leak Physics found: a drain that carried facts gave its list away and made a new one, every queue every frame something happened. The queue keeps two lists and hands one out while the other fills; a drained list is good until the next drain of that queue (nothing in the game or the packages keeps one past it). With ab34f82's empty drain and in-place clear, a queue allocates nothing in steady state. queue_test holds the reuse; every package (403) and lab/unit (90) pass. Co-Authored-By: Claude Opus 5.5 --- packages/ludic.base/queue.ludic | 13 +++++++++---- packages/ludic.base/tests/queue_test.ludic | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/ludic.base/queue.ludic b/packages/ludic.base/queue.ludic index 55e5205a..521a85e2 100644 --- a/packages/ludic.base/queue.ludic +++ b/packages/ludic.base/queue.ludic @@ -4,6 +4,7 @@ export property Queue { items: []T = null tag: QueueTag = null none: []T = null # what draining an empty queue hands back, never pushed to + spare: []T = null # the list the last drain handed out: cleared and reused by the next } # what a queue says about itself, whatever it holds: its name and how many facts wait in it export property QueueTag { @@ -24,6 +25,7 @@ export function queue_new(name: string) -> Queue { q.tag = new QueueTag q.tag.name = name q.none = new []T + q.spare = new []T return q } @@ -32,13 +34,16 @@ 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. 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. +# the facts in the order they were pushed; the queue is empty afterwards. Ludic frees nothing, so +# a drain allocates nothing: the queue keeps two lists and hands one out while the other fills, and +# an empty drain hands back one shared empty list. What a drain returns is good until the NEXT +# drain of the same queue - read it, never push to it, never keep it. export function q_drain(q: Queue) -> []T { if len(q.items) == 0 { return q.none } let out = q.items - q.items = new []T + List.clear(q.spare) + q.items = q.spare + q.spare = out q.tag.pending = 0 return out } diff --git a/packages/ludic.base/tests/queue_test.ludic b/packages/ludic.base/tests/queue_test.ludic index 15631343..cbf46091 100644 --- a/packages/ludic.base/tests/queue_test.ludic +++ b/packages/ludic.base/tests/queue_test.ludic @@ -87,6 +87,23 @@ program QueueTest { } test "a queue drains in the order it was pushed" () { fifo_case() } + # two lists, reused: a drained list is the caller's until the next drain of that queue + function reuse_case() -> void { + let q: Queue = queue_new("reused") + q_push(q, 1) + let a = q_drain(q) + q_push(q, 2) + expect_eq(a[0], 1) + let b = q_drain(q) + expect_eq(b[0], 2) + q_push(q, 3) + let c = q_drain(q) + expect(c == a) + expect_eq(len(c), 1) + expect_eq(c[0], 3) + } + + test "a drain reuses the queue's two lists" () { reuse_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() }