commit 262badd3caa0e0906782e324cd98f02de2a276df
parent a55548c334572566dbf89c145f657909cae99366
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date: Sun, 20 Sep 2026 16:27:24 -0300
scan: move the snapshot to the package that uses it
Nothing in scan wrote a snapshot, read one, or mentioned one. The builder is its
only writer and the drawing loop its only reader, and both are in main, so the
seqlock now sits between them rather than in the package they happen to share.
TOP_ROWS and TOP_N were the same twenty written twice, free to drift apart; there
is one of them now. The two seqlock tests move with it, and `make test` gains the
root package, which had none at all despite holding the ranking and the roll-up.
Diffstat:
7 files changed, 120 insertions(+), 121 deletions(-)
diff --git a/Makefile b/Makefile
@@ -41,6 +41,7 @@ test: | $(BUILD)/test
$(ODIN) test flow -debug $(SAN) $(FLAGS) -out:$(BUILD)/test/flow$(EXE)
$(ODIN) test scan -debug $(SAN) $(FLAGS) -out:$(BUILD)/test/scan$(EXE)
$(ODIN) test walk -debug $(SAN) $(FLAGS) -out:$(BUILD)/test/walk$(EXE)
+ $(ODIN) test . -debug $(SAN) $(FLAGS) -out:$(BUILD)/test/main$(EXE)
check:
$(ODIN) check . $(FLAGS)
diff --git a/builder.odin b/builder.odin
@@ -23,7 +23,7 @@ draws the previous answer.
*/
Builder :: struct {
tree: ^scan.Tree,
- out: ^scan.Publisher,
+ out: ^Publisher,
scanning: ^b32, // cleared once nothing is filling the tree any more
started: time.Tick,
roll: Rollup,
@@ -58,10 +58,10 @@ builder_pass :: proc(b: ^Builder, complete: bool) {
}
rollup_advance(&b.roll, b.tree)
- top: [scan.TOP_ROWS]Sized
+ top: [TOP_N]Sized
count := rank(b.tree, b.roll.totals[:], top[:])
- s := scan.Snapshot {
+ s := Snapshot {
count = count,
nodes = b.tree.nodes_done,
bytes = b.tree.bytes_done,
@@ -75,5 +75,5 @@ builder_pass :: proc(b: ^Builder, complete: bool) {
bytes = top[i].bytes,
}
}
- scan.publish(b.out, s)
+ publish(b.out, s)
}
diff --git a/live.odin b/live.odin
@@ -25,7 +25,7 @@ answer from the last one.
FRAME :: 16 * time.Millisecond
run_live :: proc(f: ^Fill) -> int {
- out: scan.Publisher
+ out: Publisher
started := time.tick_now()
f.watched = true
@@ -54,14 +54,14 @@ run_live :: proc(f: ^Fill) -> int {
defer rollup_destroy(&b.roll)
frames, drawn, missed := 0, 0, 0
- last: scan.Snapshot
+ last: Snapshot
have := false
for {
frame_start := time.tick_now()
finished := bool(sync.atomic_load(&b.done))
- if s, ok := scan.current(&out); ok {
+ if s, ok := current(&out); ok {
last = s
have = true
drawn += 1
diff --git a/scan/snapshot.odin b/scan/snapshot.odin
@@ -1,70 +0,0 @@
-package scan
-
-import "core:sync"
-
-TOP_ROWS :: 20
-
-Row :: struct {
- node: u32,
- bytes: u64,
-}
-
-// What a UI draws: a fixed number of rows and a few counters, whatever the size of
-// the volume behind it.
-Snapshot :: struct {
- rows: [TOP_ROWS]Row,
- count: int,
- nodes: u64, // records folded so far
- bytes: u64,
- pending: int, // nodes waiting on an ancestor
- elapsed: f64, // milliseconds since the scan began
- complete: bool,
-}
-
-/*
-Hands a snapshot from the thread building it to the thread drawing it.
-
-A sequence number is odd only while a write is in flux. A reader copies, then checks
-the number is unchanged and even. A frame therefore never waits on a build: it gets
-the new answer, or keeps the one it had.
-*/
-Publisher :: struct {
- seq: u32,
- data: Snapshot,
-}
-
-publish :: proc(p: ^Publisher, s: Snapshot) {
- sync.atomic_add(&p.seq, 1)
- p.data = s
- sync.atomic_add(&p.seq, 1)
-}
-
-/*
-Attempts before giving up.
-
-Enough to cover one write, which copies a few hundred bytes and so takes tens of
-nanoseconds against under ten for an attempt. Beyond that the builder has been
-descheduled partway through and no amount of spinning will help, while a caller that
-keeps last frame's answer has lost nothing.
-*/
-@(private)
-ATTEMPTS :: 4
-
-// The latest complete snapshot. Fails while a write is in flux, which is the
-// caller's cue to keep the frame it already has.
-current :: proc(p: ^Publisher) -> (s: Snapshot, ok: bool) {
- for _ in 0 ..< ATTEMPTS {
- before := sync.atomic_load(&p.seq)
- if before & 1 != 0 {
- continue // a write is in progress
- }
- // Both loads are sequentially consistent, which is what stops the compiler
- // hoisting this copy above the first or sinking it below the second. Moving it
- // either way turns a correct seqlock into one that returns torn data silently.
- s = p.data
- if sync.atomic_load(&p.seq) == before {
- return s, true
- }
- }
- return {}, false
-}
diff --git a/scan/snapshot_test.odin b/scan/snapshot_test.odin
@@ -1,44 +0,0 @@
-package scan
-
-import "core:testing"
-
-@(test)
-test_snapshot_round_trips :: proc(t: ^testing.T) {
- p: Publisher
- _, ok := current(&p)
- testing.expect(t, ok, "an untouched publisher should still hand out its zero value")
-
- s := Snapshot {
- count = 2,
- nodes = 99,
- complete = true,
- }
- s.rows[0] = {
- node = 7,
- bytes = 4096,
- }
- publish(&p, s)
-
- got, got_ok := current(&p)
- testing.expect(t, got_ok)
- testing.expect_value(t, got.count, 2)
- testing.expect_value(t, got.nodes, u64(99))
- testing.expect_value(t, got.rows[0].bytes, u64(4096))
- testing.expect(t, got.complete)
-}
-
-@(test)
-test_snapshot_refuses_a_write_in_flux :: proc(t: ^testing.T) {
- // An odd sequence is what a half-written snapshot looks like. The reader must
- // decline rather than hand back something torn.
- p: Publisher
- publish(&p, Snapshot{count = 1})
- p.seq += 1 // as though a write had begun
-
- _, ok := current(&p)
- testing.expect(t, !ok, "a snapshot mid-write was handed out")
-
- p.seq += 1 // and finished
- _, ok2 := current(&p)
- testing.expect(t, ok2, "a finished write was not readable")
-}
diff --git a/snapshot.odin b/snapshot.odin
@@ -0,0 +1,68 @@
+package main
+
+import "core:sync"
+
+Row :: struct {
+ node: u32,
+ bytes: u64,
+}
+
+// What a UI draws: a fixed number of rows and a few counters, whatever the size of
+// the volume behind it.
+Snapshot :: struct {
+ rows: [TOP_N]Row,
+ count: int,
+ nodes: u64, // records folded so far
+ bytes: u64,
+ pending: int, // nodes waiting on an ancestor
+ elapsed: f64, // milliseconds since the scan began
+ complete: bool,
+}
+
+/*
+Hands a snapshot from the thread building it to the thread drawing it.
+
+A sequence number is odd only while a write is in flux. A reader copies, then checks
+the number is unchanged and even. A frame therefore never waits on a build: it gets
+the new answer, or keeps the one it had.
+*/
+Publisher :: struct {
+ seq: u32,
+ data: Snapshot,
+}
+
+publish :: proc(p: ^Publisher, s: Snapshot) {
+ sync.atomic_add(&p.seq, 1)
+ p.data = s
+ sync.atomic_add(&p.seq, 1)
+}
+
+/*
+Attempts before giving up.
+
+Enough to cover one write, which copies a few hundred bytes and so takes tens of
+nanoseconds against under ten for an attempt. Beyond that the builder has been
+descheduled partway through and no amount of spinning will help, while a caller that
+keeps last frame's answer has lost nothing.
+*/
+@(private)
+ATTEMPTS :: 4
+
+// The latest complete snapshot. Fails while a write is in flux, which is the
+// caller's cue to keep the frame it already has.
+current :: proc(p: ^Publisher) -> (s: Snapshot, ok: bool) {
+ for _ in 0 ..< ATTEMPTS {
+ before := sync.atomic_load(&p.seq)
+ if before & 1 != 0 {
+ continue // a write is in progress
+ }
+ // Both loads are sequentially consistent, which is what stops the compiler
+ // hoisting this copy above the first or sinking it below the second. Moving it
+ // either way turns a correct seqlock into one that returns torn data silently.
+ s = p.data
+ if sync.atomic_load(&p.seq) == before {
+ return s, true
+ }
+ }
+ return {}, false
+}
diff --git a/snapshot_test.odin b/snapshot_test.odin
@@ -0,0 +1,44 @@
+package main
+
+import "core:testing"
+
+@(test)
+test_snapshot_round_trips :: proc(t: ^testing.T) {
+ p: Publisher
+ _, ok := current(&p)
+ testing.expect(t, ok, "an untouched publisher should still hand out its zero value")
+
+ s := Snapshot {
+ count = 2,
+ nodes = 99,
+ complete = true,
+ }
+ s.rows[0] = {
+ node = 7,
+ bytes = 4096,
+ }
+ publish(&p, s)
+
+ got, got_ok := current(&p)
+ testing.expect(t, got_ok)
+ testing.expect_value(t, got.count, 2)
+ testing.expect_value(t, got.nodes, u64(99))
+ testing.expect_value(t, got.rows[0].bytes, u64(4096))
+ testing.expect(t, got.complete)
+}
+
+@(test)
+test_snapshot_refuses_a_write_in_flux :: proc(t: ^testing.T) {
+ // An odd sequence is what a half-written snapshot looks like. The reader must
+ // decline rather than hand back something torn.
+ p: Publisher
+ publish(&p, Snapshot{count = 1})
+ p.seq += 1 // as though a write had begun
+
+ _, ok := current(&p)
+ testing.expect(t, !ok, "a snapshot mid-write was handed out")
+
+ p.seq += 1 // and finished
+ _, ok2 := current(&p)
+ testing.expect(t, ok2, "a finished write was not readable")
+}