1import Foundation
2
3/// Headless cache-budget check: `sequencer --cachetest <file.sq> [capGB]`.
4///
5/// Loads the real project into the headless context, jumps the playhead to
6/// several spots spread across the timeline, and lets demand builds +
7/// evictions run at each stop while continuously asserting the invariant
8/// that makes the cap a hard ceiling:
9///
10/// ledger + reserved ≤ cap (checked every second)
11/// bytes on disk ≤ cap + slack (checked every few seconds)
12///
13/// At each stop it also waits for the chunk under the playhead to become
14/// covered — proving the system optimizes around where the user is even when
15/// the whole project can't fit (the "Deltarune problem").
16func runCacheTest(path: String, capGB: Int) {
17 func spin(_ seconds: Double) {
18 RunLoop.main.run(until: Date().addingTimeInterval(seconds))
19 }
20
21 print("== Sequencer cachetest ==")
22 if capGB > 0 { UserDefaults.standard.set(capGB, forKey: "maxCacheGB") }
23 let pipeline = MediaPipeline.shared
24 let cap = pipeline.maxCacheBytes
25 print("cache: \(pipeline.cacheRoot.path)")
26 print(String(format: "cap: %.1f GB", Double(cap) / 1e9))
27
28 // Load the project (package or legacy flat file) into the headless context.
29 let url = URL(fileURLWithPath: path)
30 var jsonURL = url
31 var isDir: ObjCBool = false
32 if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {
33 jsonURL = url.appendingPathComponent("project.json")
34 }
35 guard let data = try? Data(contentsOf: jsonURL),
36 let doc = try? JSONDecoder().decode(SequencerDocument.self, from: data) else {
37 print("FAIL: cannot read project at \(path)")
38 return
39 }
40 let ctx = DocumentContext.headless
41 ctx.store.adopt(doc.project)
42 let project = ctx.store.project
43 let span = project.clips.map(\.end).max() ?? 0
44 print(String(format: "project: %d clips, %d media, %.0f s timeline",
45 project.clips.count, project.media.count, span))
46
47 // Wait for the launch reconcile so admission is open.
48 let bootDeadline = Date().addingTimeInterval(60)
49 while !pipeline.budgetReady && Date() < bootDeadline { spin(0.1) }
50 guard pipeline.budgetReady else { print("FAIL: ledger reconcile timed out"); return }
51
52 // Playhead stops: spread across the timeline, snapped to the midpoint of a
53 // real video clip so there is always something to cover.
54 var stops: [Double] = []
55 for frac in [0.05, 0.35, 0.65, 0.9] {
56 let t = span * frac
57 if let clip = project.clips
58 .filter({ $0.kind == .video })
59 .min(by: { abs(($0.start + $0.end) / 2 - t) < abs(($1.start + $1.end) / 2 - t) }) {
60 let mid = (clip.start + clip.end) / 2
61 if !stops.contains(where: { abs($0 - mid) < 60 }) { stops.append(mid) }
62 }
63 }
64
65 var failures = 0
66 var walkTick = 0
67 func checkInvariant(_ label: String) {
68 let held = pipeline.ledgerBytes <= cap
69 if !held {
70 failures += 1
71 print(String(format: "FAIL: ledger %.2f GB over cap (%@)",
72 Double(pipeline.ledgerBytes) / 1e9, label))
73 }
74 // The disk walk is the ground truth the ledger approximates. Slack
75 // covers unbudgeted small writes (filmstrips) between reconciles.
76 walkTick += 1
77 if walkTick % 5 == 0 {
78 let disk = MediaPipeline.directorySize(pipeline.cacheRoot)
79 if disk > cap + 500_000_000 {
80 failures += 1
81 print(String(format: "FAIL: %.2f GB on disk exceeds cap (%@)",
82 Double(disk) / 1e9, label))
83 }
84 }
85 }
86
87 for (i, t) in stops.enumerated() {
88 print(String(format: "\n-- stop %d: playhead %.0f s", i + 1, t))
89 ctx.playback.seek(to: t)
90 ctx.chunks.ensure(for: project)
91 ctx.chunks.updateDemand(force: true)
92 let clipsHere = project.clips.filter {
93 $0.kind == .video && $0.start <= t && t < $0.end
94 }
95 let deadline = Date().addingTimeInterval(150)
96 var covered = false
97 var announced = false
98 while Date() < deadline {
99 spin(1.0)
100 checkInvariant("stop \(i + 1)")
101 var missing: [String] = []
102 for clip in clipsHere {
103 guard let media = project.media(clip.mediaId) else { continue }
104 let src = clip.srcIn + (t - clip.start) * clip.speed
105 if !ctx.chunks.isCovered(media: media, sourceTime: src)
106 && !ctx.chunks.buildFailed(media: media, sourceTime: src) {
107 missing.append("\(media.cacheKey.prefix(8))#\(ChunkManager.chunkIndex(forSource: src))")
108 }
109 }
110 covered = missing.isEmpty
111 if covered { break }
112 if !announced {
113 announced = true
114 NSLog("[cachetest] stop %d waiting on: %@", i + 1,
115 missing.joined(separator: " "))
116 }
117 }
118 print(String(format: "coverage at playhead: %@ (ledger %.2f GB)",
119 covered ? "OK" : "FAIL (not covered in 150s)",
120 Double(pipeline.ledgerBytes) / 1e9))
121 if !covered { failures += 1 }
122 }
123
124 // Let any in-flight builds settle, then final ground-truth comparison.
125 spin(5)
126 let disk = MediaPipeline.directorySize(pipeline.cacheRoot)
127 print(String(format: "\nfinal: ledger %.2f GB, disk %.2f GB, cap %.1f GB",
128 Double(pipeline.ledgerBytes) / 1e9, Double(disk) / 1e9,
129 Double(cap) / 1e9))
130 if disk > cap + 500_000_000 { failures += 1; print("FAIL: final disk size over cap") }
131 print(failures == 0 ? "\n== cachetest PASS ==" : "\n== cachetest FAIL (\(failures)) ==")
132}