Skip to content
C.W.K.
Stream
Lesson 04 of 05 · published

One Snapshot, Many Readers

~13 min · copy-on-write, undo, immutability, documentation, swift

Level 0Blank Canvas
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

One Primitive, Many Callers

Once tiles can be frozen, a snapshot of a whole layer stops being an expensive object and becomes a list of references. TileStore.frozenTiles() is that list: it freezes every live tile and returns them in key order, "a consistent picture of the store that any thread may read while painting goes on." Count its callers and you see how much of the app quietly stands on it:

  • Saves and exports. ArtworkArchive takes every layer's tiles, and every mask's paint, this way (the previous lesson).
  • Duplicate Layer. "The copy shares the original's tiles, and its mask's, frozen, until either side paints over one." Duplicating a layer copies no pixels at all.
  • Editing sessions. Transform, Adjustments, Liquify and Text each keep the layer as it was when the session began, so the preview always works from the original and the commit records one step.
  • Strokes that need the layer as the stroke found it. Clone strokes and combined dual-brush strokes paint coverage on stores of their own and then merge it over that original. Clone lays pixels sampled from the original at its offset; a dual brush lays its own color, shaped by the two coverages, over the original's pixels (or, erasing, takes that shape out of them).
  • Copy and Cut with Paste read the source layer through the same frozen picture.

Undo's Two Halves Are Both Shared

An undo step for pixels is .tiles(layerID, before, after). The before-state was frozen at each tile's first write (lesson two). The after-state comes from snapshot(of:) (first code block), which freezes the live buffers themselves: right after a stroke, the step's after-state is the live tile, the same object. Undo and redo call restore, which hands the snapshot's frozen buffers straight back to the store through setTile, without copying. So after an undo, the layer's live tile and the history's before-state are, again, one object.

This is why the history budget counts only before-states. While a step sits on the undo stack, its after-state is either the live tile or the next step's before-state, memory some other holder already pays for. The rule has a gap on the redo side. After an undo, the live layer holds the before-state again, and the undone step's after-state can be held by nothing but its redo entry. A tile the stroke created is the sharpest case: its before-state is nil, so the entry counts 64 bytes while it keeps a 32 KiB buffer alive. The budget undercounts redo until the next new step clears it. Track 6 turns the rest into the byte budget.

The Docstring That Missed the Memo

Now read the second code block, the comment on the History class as it stands today:

"History owns frozen tile copies; applying a step copies again, so the stacks never alias live layer tiles."

Every clause of that is false in the current code. History holds frozen shared buffers, not copies. Applying a step does not copy; restore shares. And the stacks alias live layer tiles on purpose, all the time. The sentence was true when it was written: before 6479f1f, snapshot and restore both called copy(). That commit rewrote the comment on byteCount a few dozen lines above to explain sharing, and left this one standing. Teach the code: sharing plus copy-on-write is the design, and the class comment is stale.

A stale comment like this one is worse than none, and the reason is specific. It does not describe a detail; it describes a safety property, in the exact place a future editor would look to learn whether it is safe to touch a history buffer. Someone who trusts it might decide that a buffer reached from the undo stack is private to history, and write into it. The comment would be the bug's alibi.

What Makes Sharing Safe

Two rules, both enforced at the tile, carry all of it:

  1. Immutability after freeze. A frozen buffer is never written. fill has a precondition, and the house rules require every other writer to go through writableTile.
  2. Copy on write. The store never writes a frozen buffer; it swaps in a copy first. Every holder therefore keeps seeing exactly the pixels it took.

A third rule makes the first two hold across threads: only the artwork's thread freezes or writes, and a buffer is frozen before it is handed to anyone else. After that, any thread may read it. The compiled sketch (third code block) runs the whole cast through one tile: a stroke, its undo step, a duplicate layer, an undo, and a fresh dab after it. Every holder keeps its own value, and the live store paid exactly two copies.

One honest trade-off is visible in the code. The frozen flag is sticky: nothing ever sets isFrozen back to false, even after every other holder has let go. The usual Swift copy-on-write pattern asks isKnownUniquelyReferenced and skips the copy when nobody else holds the buffer; Ariandel does not ask. The cost is an occasional copy nobody strictly needed, say on the first stroke after history trimmed the step that held the tile. The benefit is a rule with no reference counting to get wrong. A spare copy costs 32 KiB; a wrong pixel costs Dad a painting.

Sharing is safe exactly when nobody can write what they share. Freeze on hand-off, copy before writing, and every reader, from the undo stack to a background save, gets a picture that cannot move under it, for the price of a pointer.

Code

Undo's after-state shares the live tile; restore shares back (AriandelEngine/Tiles.swift, verbatim)·swift
    /// Current state of the given tiles, the after-half of an undo step. The
    /// snapshot shares the live buffers (frozen); the store copies on its next write.
    public func snapshot(of keys: some Sequence<TileKey>) -> TileSnapshot {
        var snapshot: TileSnapshot = [:]
        for key in keys {
            snapshot.updateValue(tiles[key]?.freeze(), forKey: key)
        }
        return snapshot
    }

    /// Writes a snapshot back (undo or redo). The store shares the snapshot's
    /// frozen buffers, so the snapshot never changes.
    public func restore(_ snapshot: TileSnapshot) {
        for (key, tile) in snapshot {
            setTile(tile?.freeze(), at: key)
        }
    }
The stale class comment, as it stands in AriandelEngine/History.swift (verbatim)·swift
/// Undo and redo stacks, bounded by bytes and by count.
///
/// History owns frozen tile copies; applying a step copies again, so the
/// stacks never alias live layer tiles.
public final class History {
A minimal sketch: one tile shared by history, a duplicate and the live store (compiled and run with swiftc in Swift 6 mode)·swift
// A minimal sketch of one frozen buffer with many readers: undo history,
// a duplicate layer and the live store all hold the same object, and the
// store's copy-on-write keeps each of them from seeing another's writes.
final class Buffer {
    var value: Int
    private(set) var isFrozen = false
    init(_ value: Int) { self.value = value }
    @discardableResult func freeze() -> Buffer { isFrozen = true; return self }
}

final class Store {
    private(set) var tiles: [Int: Buffer] = [:]
    private(set) var copies = 0

    func writableTile(at key: Int) -> Buffer {
        if let tile = tiles[key] {
            guard tile.isFrozen else { return tile }
            copies += 1
            let copy = Buffer(tile.value)
            tiles[key] = copy
            return copy
        }
        let tile = Buffer(0)
        tiles[key] = tile
        return tile
    }

    func setTile(_ tile: Buffer?, at key: Int) { tiles[key] = tile }  // takes it as it is: frozen stays shared
    func snapshot(_ key: Int) -> Buffer? { tiles[key]?.freeze() }
    func restore(_ tile: Buffer?, at key: Int) { setTile(tile?.freeze(), at: key) }  // shares, never copies
}

let layer = Store()
layer.writableTile(at: 0).value = 10                 // the canvas before the stroke
let before = layer.snapshot(0)                       // history's before-state (frozen, shared)
layer.writableTile(at: 0).value = 20                 // the stroke: copies once, writes the copy
let after = layer.snapshot(0)                        // history's after-state IS the live tile

let duplicate = Store()                              // Duplicate Layer: share every tile, copy none
duplicate.setTile(layer.snapshot(0), at: 0)
print("duplicate shares the live tile:", duplicate.tiles[0]! === layer.tiles[0]!)

layer.restore(before, at: 0)                         // undo: the store now holds history's buffer
print("after undo, live tile is history's before-state:", layer.tiles[0]! === before!)

layer.writableTile(at: 0).value = 99                 // paint after the undo
print("history before =", before!.value, " history after =", after!.value,
      " duplicate =", duplicate.tiles[0]!.value, " live =", layer.tiles[0]!.value)
print("copies made by the live store:", layer.copies)

// Output (swiftc -swift-version 6):
// duplicate shares the live tile: true
// after undo, live tile is history's before-state: true
// history before = 10  history after = 20  duplicate = 20  live = 99
// copies made by the live store: 2

External links

Exercise

Change restore in the sketch so that it copies, as the stale comment describes. Undo and redo a few times in a row without painting in between, and compare the store's copy count with the sharing version. Then put restore back, make writableTile ignore the freeze (replace everything from its isFrozen guard down to return copy with a plain return tile), rerun, and write down what every holder prints.
Hint
Copying on restore pays on every undo and every redo; sharing pays only when you paint afterwards, and then once per tile. That is why the old design was correct, just expensive. With the freeze ignored, every holder prints 99 and the store reports zero copies: the stroke wrote straight into the buffer history was keeping as its before-state, and everything after followed. Zero copies and a corrupted history are the same bug seen from two sides. (Deleting only the guard line does the opposite: the store copies on every write to an existing tile, which is wasteful but still correct, and the sketch prints the same values with two copies.)

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.