From 3fcaa76315e0d7d14538682be99170e83d3a140a Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 16 Oct 2026 02:15:36 -0400 Subject: [PATCH 1/3] TakeSession.finish: run the body without self, on a snapshot (#652, A2) finish() copies what it needs into FinishInput and calls the new nonisolated static finish(_:), then writes the updated metadata back. Same signature, same order of operations, same results for every caller. coverage, gaps, writeMetadata, collectDropouts and persistGaps became nonisolated statics taking the copies, standby and metadata; the instance methods the mid-Take code uses are now one-line wrappers over them, so tick, join, rejoin and stand-in are unchanged and nothing is duplicated. Assisted-by: Claude Code:claude-sonnet-5-5 Claude-Session: https://claude.ai/code/session_01YSHtEHRvQvHpyXAjjbcmVq --- .../Sources/Recording/TakeSession.swift | 59 ++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift b/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift index e65c01dd..dfcfdf7d 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift @@ -282,7 +282,9 @@ final class TakeSession { /// What each Copy holds so far, from its writer: the one source of Gaps and Dropouts (#441). A Copy that failed, /// retired or never joined stopped early, so it lacks everything past where it got to. - private var coverage: CopyCoverage { + private var coverage: CopyCoverage { Self.coverage(copies: copies, standby: standby) } + + nonisolated private static func coverage(copies: [CopyRecord], standby: Standby?) -> CopyCoverage { var all = copies.map { copy in CopyCoverage.Copy( // A Drive that went away may have lost what it wrote since its last commit (#595); a Device keeps it. @@ -303,9 +305,11 @@ final class TakeSession { /// Stretches written as silence in any Copy, as `Take.json` records them. With `takeEnd`, a Copy that stopped /// early is missing everything from where it stopped to there. - func gaps(takeEnd: Int? = nil) -> [TakeMetadata.Gap] { + func gaps(takeEnd: Int? = nil) -> [TakeMetadata.Gap] { Self.gaps(copies: copies, standby: standby, takeEnd: takeEnd) } + + nonisolated private static func gaps(copies: [CopyRecord], standby: Standby?, takeEnd: Int?) -> [TakeMetadata.Gap] { // A Drive only Take has no Device Copy to have Gaps in: the standby only stands in. - coverage.gaps(takeEnd: takeEnd).filter { standby == nil || $0.copy != .device } + coverage(copies: copies, standby: standby).gaps(takeEnd: takeEnd).filter { standby == nil || $0.copy != .device } } /// Looks for a Destination that has become available since the Take started. A Drive that appears @@ -360,13 +364,32 @@ final class TakeSession { /// Stops capturing, waits until every Stem is written and finalized and records the Gaps. /// A Copy that failed during the Take doesn't make this throw. func finish() -> Finished { + let finished = Self.finish(FinishInput( + capture: capture, copies: copies, standby: standby, metadata: metadata, levels: levels, stopTimeout: stopTimeout)) + metadata = finished.metadata + return finished + } + + /// What `finish` works on, copied out of the session so the join and the file writes need no main actor (#652). + struct FinishInput: Sendable { + let capture: Capture + let copies: [CopyRecord] + let standby: Standby? + var metadata: TakeMetadata + let levels: TakeLevels + let stopTimeout: Duration? + } + + nonisolated static func finish(_ input: FinishInput) -> Finished { + let capture = input.capture, copies = input.copies, standby = input.standby, levels = input.levels + var metadata = input.metadata capture.stopCapturing() // Every writer is told to stop first, then all are waited on against one deadline, so two stuck Copies // hold Stop for one timeout, not two (#652). The deadline is taken after every stop request, so each writer // still gets the full timeout from its own request (#320). for copy in copies { copy.writer.requestStop() } standby?.writer.requestStop() - let deadline = stopTimeout.map(TakeWriter.deadline) + let deadline = input.stopTimeout.map(TakeWriter.deadline) for copy in copies { copy.writer.waitStopped(until: deadline) } standby?.writer.waitStopped(until: deadline) // A standby is a Copy only if it stood in or its folder is kept: its empty folder isn't one, even if removing it fails. @@ -380,16 +403,16 @@ final class TakeSession { standbyFolder = nil } } - collectDropouts() + collectDropouts(&metadata, copies: copies, standby: standby) // Levels are kept per captured channel; Take.json lists only the recorded ones, by USB Channel number. for (channel, summary) in levels.summary.enumerated() { guard let index = metadata.usbChannels.firstIndex(where: { $0.usbChannel == channel + 1 }) else { continue } metadata.usbChannels[index].peakDbfs = summary.peakDbfs metadata.usbChannels[index].averageDbfs = summary.averageDbfs } - writeMetadata() + writeMetadata(metadata, copies: copies, standby: standby) // A Take whose Copies were never fed a first block is empty, not missing everything. - persistGaps(takeEnd: capture.awaitingFirstBlock ? 0 : capture.takeFrameCount) + persistGaps(&metadata, copies: copies, standby: standby, takeEnd: capture.awaitingFirstBlock ? 0 : capture.takeFrameCount) var folders: [DestinationKind: URL] = [:] folders[.device] = standbyFolder for copy in copies { folders[copy.kind] = copy.folder } @@ -470,7 +493,9 @@ final class TakeSession { metadata.stemCueMarkers.map { StemMarker(position: UInt32(clamping: $0.position), label: $0.name) } } - private func writeMetadata() { + private func writeMetadata() { Self.writeMetadata(metadata, copies: copies, standby: standby) } + + nonisolated private static func writeMetadata(_ metadata: TakeMetadata, copies: [CopyRecord], standby: Standby?) { // A Copy Stop gave up on (set only at Stop) may hang on any file operation: Repair writes its Take.json off the main thread (#652). for copy in copies where !copy.writer.wasAbandoned { try? metadata.write(to: copy.folder) } // A stand-in's Take.json lets a crash mid-Take find it (and Repair it) on the next launch. @@ -530,24 +555,30 @@ final class TakeSession { /// Moves the audio the writers had to drop into `Take.json` as Dropouts (one per Copy and stretch), with /// one Dropout Marker at each position where any Copy lost audio. - private func collectDropouts() { + private func collectDropouts() { Self.collectDropouts(&metadata, copies: copies, standby: standby) } + + nonisolated private static func collectDropouts(_ metadata: inout TakeMetadata, copies: [CopyRecord], standby: Standby?) { // Only audio every Copy lacks; a stretch another Copy has is a Gap (see `gaps(takeEnd:)`). - let found = coverage.dropouts + let found = coverage(copies: copies, standby: standby).dropouts guard found != metadata.dropouts else { return } let marked = Set(metadata.dropoutMarkers.map(\.position)) for position in Set(found.map(\.start)).subtracting(marked).sorted() { metadata.markers.append(.init(position: position, name: "Dropout", origin: .dropout)) } metadata.dropouts = found - writeMetadata() + writeMetadata(metadata, copies: copies, standby: standby) } /// Writes the Gaps found so far into `Take.json` in every Copy. - private func persistGaps(takeEnd: Int? = nil) { - let gaps = gaps(takeEnd: takeEnd) + private func persistGaps(takeEnd: Int? = nil) { Self.persistGaps(&metadata, copies: copies, standby: standby, takeEnd: takeEnd) } + + nonisolated private static func persistGaps( + _ metadata: inout TakeMetadata, copies: [CopyRecord], standby: Standby?, takeEnd: Int? + ) { + let gaps = gaps(copies: copies, standby: standby, takeEnd: takeEnd) guard gaps != metadata.gaps else { return } metadata.gaps = gaps - writeMetadata() + writeMetadata(metadata, copies: copies, standby: standby) } } From 1a7e6ba80c0735fd2dc3cb61db0c0e5ed767e66b Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 16 Oct 2026 02:17:28 -0400 Subject: [PATCH 2/3] TakeSession.FinishInput: say when the snapshot may be taken (#652, A2) Assisted-by: Claude Code:claude-sonnet-5-5 Claude-Session: https://claude.ai/code/session_01YSHtEHRvQvHpyXAjjbcmVq --- Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift b/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift index dfcfdf7d..91d9c626 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift @@ -371,6 +371,9 @@ final class TakeSession { } /// What `finish` works on, copied out of the session so the join and the file writes need no main actor (#652). + /// A snapshot: take it after the last `accumulate`, after `Recorder` has dropped the session and cancelled the + /// destination checks (a late `attachDrive` or stand-in would be missing from it), and change nothing on the session + /// until `finish()` writes the metadata back. struct FinishInput: Sendable { let capture: Capture let copies: [CopyRecord] From 9f4db712ec17363e9198002e86ca4f4e224ed404 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 16 Oct 2026 02:18:40 -0400 Subject: [PATCH 3/3] TakeSession.FinishInput: American spelling in the comment (#652, A2) Assisted-by: Claude Code:claude-sonnet-5-5 Claude-Session: https://claude.ai/code/session_01YSHtEHRvQvHpyXAjjbcmVq --- Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift b/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift index 91d9c626..0434270e 100644 --- a/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift +++ b/Packages/ShowRecorderKit/Sources/Recording/TakeSession.swift @@ -371,7 +371,7 @@ final class TakeSession { } /// What `finish` works on, copied out of the session so the join and the file writes need no main actor (#652). - /// A snapshot: take it after the last `accumulate`, after `Recorder` has dropped the session and cancelled the + /// A snapshot: take it after the last `accumulate`, after `Recorder` has dropped the session and canceled the /// destination checks (a late `attachDrive` or stand-in would be missing from it), and change nothing on the session /// until `finish()` writes the metadata back. struct FinishInput: Sendable {