@@ -28,6 +28,24 @@ import AetherEngine
2828/// buffer and allocates nothing, so that run measured the HARNESS rather than his case. The default
2929/// arm is therefore the allocation-free one: what it measures is the engine. `--foundation-reader`
3030/// restores the allocating arm, which is now a control for the pool rather than the subject.
31+ /// AE#445 round 3: the ingest-side carry, as a POSITIVE control.
32+ ///
33+ /// The reporter's census named his growing block precisely: one `REALLOC`-tagged allocation on an
34+ /// exact x1.25 ladder whose content is every byte the session consumed. That factor is Foundation's,
35+ /// not libav's (`Data.__DataStorage._grow` adds `newLength >> 2` above 128 KB; `av_fast_realloc`
36+ /// adds a sixteenth, the AVIO dynamic buffer a half), so the block is a Swift `Data`, and the one
37+ /// `Data` shape that grows like that while its `count` stays small is a parse carry consumed from
38+ /// the front with `removeFirst`: that only advances the slice's lower bound, so the backing store
39+ /// keeps every byte below it and reallocs to fit the ever-rising upper bound. The engine paid for
40+ /// this lesson twice on its own readers (70430de, `ByteFIFO`) and re-bases with `subdata` in both.
41+ ///
42+ /// `--host-carry removeFirst` puts that shape back into the harness on purpose, so the tool that
43+ /// measures the engine at ratio 0.00 can also produce the reporter's ratio 1.00 on demand and name
44+ /// the cause. `--host-carry subdata` is the same carry re-based, which is the fix.
45+ enum HostCarryTrim : String {
46+ case none, removeFirst, subdata
47+ }
48+
3149final class PacedLiveSpoolIOReader : IOReader , @unchecked Sendable {
3250 private let path : String
3351 /// Foundation arm only. The POSIX arm reads through `fd` and never builds an object.
@@ -56,9 +74,19 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
5674 private( set) var maxLookbackBytes : Int64 = 0
5775 private( set) var seekCount : Int = 0
5876
77+ /// Host-side parse carry (see `HostCarryTrim`). Bounded in `count` by construction: everything
78+ /// but the partial trailing TS packet is consumed on every fill.
79+ private let carryTrim : HostCarryTrim
80+ private var carry = Data ( )
81+ private( set) var carryCount = 0
82+ /// The slice's lower bound, which is the whole tell: for a re-based carry it stays 0, for a
83+ /// `removeFirst` one it equals every byte ever consumed, and the backing store is that large.
84+ private( set) var carryStartIndex = 0
85+
5986 init ( path: String , rateKbps: Int , reportsSize: Bool , wraps: Bool ,
60- foundationRead: Bool = false ) throws {
87+ foundationRead: Bool = false , carryTrim : HostCarryTrim = . none ) throws {
6188 self . path = path
89+ self . carryTrim = carryTrim
6290 let attrs = try FileManager . default. attributesOfItem ( atPath: path)
6391 self . fileSize = ( attrs [ . size] as? NSNumber ) ? . int64Value ?? 0
6492 if foundationRead {
@@ -127,6 +155,7 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
127155 }
128156 position += Int64 ( got)
129157 bytesRead += Int64 ( got)
158+ feedCarryLocked ( buffer, count: got)
130159 lock. unlock ( )
131160 return Int32 ( got)
132161 }
@@ -136,6 +165,23 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
136165 }
137166 }
138167
168+ /// Push the delivered bytes through the carry and consume whole TS packets, which is what a
169+ /// PCR indexer on the ingest side does. Called under `lock`.
170+ private func feedCarryLocked( _ buffer: UnsafeMutablePointer < UInt8 > , count: Int ) {
171+ guard carryTrim != . none, count > 0 else { return }
172+ carry. append ( buffer, count: count)
173+ let consumable = ( carry. count / 188 ) * 188
174+ if consumable > 0 {
175+ switch carryTrim {
176+ case . removeFirst: carry. removeFirst ( consumable)
177+ case . subdata: carry = carry. subdata ( in: consumable..< carry. count)
178+ case . none: break
179+ }
180+ }
181+ carryCount = carry. count
182+ carryStartIndex = carry. startIndex
183+ }
184+
139185 /// One upstream burst. 32 KB is the size the engine's own file reader is measured in (#243) and
140186 /// is large enough that the read cadence is set by the source, not by the pacer's resolution.
141187 private static let releaseChunk : Int64 = 32 * 1024
@@ -165,7 +211,7 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
165211 func makeIndependentReader( ) -> IOReader ? {
166212 try ? PacedLiveSpoolIOReader ( path: path, rateKbps: Int ( rateBytesPerSecond * 8.0 / 1000.0 ) ,
167213 reportsSize: reportsSize, wraps: wraps,
168- foundationRead: handle != nil )
214+ foundationRead: handle != nil , carryTrim : carryTrim )
169215 }
170216
171217 var discImageProbeEnabled : Bool { false }
@@ -184,20 +230,23 @@ final class PacedLiveSpoolIOReader: IOReader, @unchecked Sendable {
184230/// graph. macOS has no jetsam, so the run cannot be killed here: the slope IS the finding.
185231func runCustomLiveSpool( path: String , seconds: Double , rateKbps: Int , dvrWindow: Double ? ,
186232 reportsSize: Bool , wraps: Bool , mallocCensus: Bool ,
187- foundationReader: Bool = false ) -> Int32 {
233+ foundationReader: Bool = false , carryTrim : HostCarryTrim = . none ) -> Int32 {
188234 EngineLog . handler = { print ( $0) }
189235 if mallocCensus {
190- AetherEngine . setLargeAllocationCensusEnabled ( true , triggerThresholdMB: 32 , triggerPollHz: 8 )
236+ // Uncapped captures: a steady mux-rate climb spends one capture per threshold climbed, so the
237+ // default twelve are gone long before a long run ends (AE#445 hit the cap 4.4 min early).
238+ AetherEngine . setLargeAllocationCensusEnabled ( true , triggerThresholdMB: 32 , triggerPollHz: 8 ,
239+ triggerCaptureCap: 0 )
191240 }
192241 print ( " aetherctl customio --live: \( path) (rate= \( rateKbps) kbit/s seconds= \( seconds) "
193242 + " dvrWindow= \( dvrWindow. map { String ( $0) } ?? " nil " ) size= \( reportsSize ? " reported " : " unknown " ) "
194243 + " wrap= \( wraps) census= \( mallocCensus) "
195- + " reader= \( foundationReader ? " foundation " : " posix " ) ) " )
244+ + " reader= \( foundationReader ? " foundation " : " posix " ) hostCarry= \( carryTrim . rawValue ) ) " )
196245 let box = UncheckedBox < Int32 ? > ( nil )
197246 Task { @MainActor in
198247 box. value = await customLiveSpoolRun ( path: path, seconds: seconds, rateKbps: rateKbps,
199248 dvrWindow: dvrWindow, reportsSize: reportsSize, wraps: wraps,
200- foundationReader: foundationReader)
249+ foundationReader: foundationReader, carryTrim : carryTrim )
201250 CFRunLoopStop ( CFRunLoopGetMain ( ) )
202251 }
203252 CFRunLoopRun ( )
@@ -206,12 +255,13 @@ func runCustomLiveSpool(path: String, seconds: Double, rateKbps: Int, dvrWindow:
206255
207256@MainActor
208257private func customLiveSpoolRun( path: String , seconds: Double , rateKbps: Int , dvrWindow: Double ? ,
209- reportsSize: Bool , wraps: Bool , foundationReader: Bool ) async -> Int32 {
258+ reportsSize: Bool , wraps: Bool , foundationReader: Bool ,
259+ carryTrim: HostCarryTrim ) async -> Int32 {
210260 let reader : PacedLiveSpoolIOReader
211261 do {
212262 reader = try PacedLiveSpoolIOReader ( path: path, rateKbps: rateKbps,
213263 reportsSize: reportsSize, wraps: wraps,
214- foundationRead: foundationReader)
264+ foundationRead: foundationReader, carryTrim : carryTrim )
215265 } catch {
216266 print ( " VERDICT: reader init failed: \( error. localizedDescription) " )
217267 return 1
@@ -255,8 +305,11 @@ private func customLiveSpoolRun(path: String, seconds: Double, rateKbps: Int, dv
255305 guard dt > 1 else { return " n/a " }
256306 return String ( format: " %.2f " , Double ( fp - f. footprint) / dt)
257307 } ?? " n/a "
258- print ( String ( format: " t=%.0fs state=%@ pos=%.2fs physFP=%dMB srcMB=%.1f growthMBps=%@ " ,
259- elapsed, " \( engine. state) " , engine. currentTime, fp, srcMB, slope) )
308+ let carryLine = carryTrim == . none ? " " : String (
309+ format: " carryCount=%dB carryStart=%.1fMB " ,
310+ reader. carryCount, Double ( reader. carryStartIndex) / 1_048_576.0 )
311+ print ( String ( format: " t=%.0fs state=%@ pos=%.2fs physFP=%dMB srcMB=%.1f growthMBps=%@%@ " ,
312+ elapsed, " \( engine. state) " , engine. currentTime, fp, srcMB, slope, carryLine) )
260313 if case . error( let msg) = engine. state {
261314 print ( " VERDICT: session errored: \( msg) " )
262315 engine. stop ( )
@@ -269,6 +322,16 @@ private func customLiveSpoolRun(path: String, seconds: Double, rateKbps: Int, dv
269322 print ( String ( format: " LOOKBACK: %d seeks, deepest reach-back %.1f MB behind the live edge "
270323 + " (%.0f s of source at this rate) " ,
271324 reader. seekCount, lookbackMB, lookbackMB / srcMBps) )
325+ if carryTrim != . none {
326+ // The verdict is the lower bound, not the count: a carry that starts at 0 owns exactly its
327+ // count, and one whose start tracks the consumed stream owns all of it.
328+ let startMB = Double ( reader. carryStartIndex) / 1_048_576.0
329+ let reading = reader. carryStartIndex > ( 1 << 20 )
330+ ? " riding a backing store that large "
331+ : " re-based, so the allocation is the count "
332+ print ( String ( format: " HOST CARRY (%@): count=%dB, slice lower bound %.1f MB: %@. " ,
333+ carryTrim. rawValue, reader. carryCount, startMB, reading) )
334+ }
272335 if let f = firstSample, let l = lastSample, l. t - f. t > 30 {
273336 let growth = Double ( l. footprint - f. footprint) / ( l. t - f. t)
274337 print ( String ( format: " VERDICT: physFP %d -> %d MB over %.0fs = %.2f MB/s "
0 commit comments