Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 13 additions & 15 deletions Packages/ShowRecorderKit/Sources/Recording/Recorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1531,11 +1531,8 @@ public final class Recorder {
guard !isRecording, !isStarting, !isFinishing, !isMoving, let access = driveFolder() else { return [] }
isMoving = true
defer {
isMoving = false
foundTakesToMove = false
let waiting = moveWaiters
moveWaiters = []
for waiter in waiting { waiter.resume() }
endMoving()
}
// A Show detail rename already running finishes first; one asked for from now on waits for this move.
while editsInProgress > 0 {
Expand Down Expand Up @@ -1683,26 +1680,19 @@ public final class Recorder {
if let refusal = releaseRefusal(show: show, take: take) { return .kept([refusal]) }
guard let access = driveFolder() else { return .kept(["The Drive isn't connected."]) }
isMoving = true
defer {
isMoving = false
let waiting = moveWaiters
moveWaiters = []
for waiter in waiting { waiter.resume() }
}
// The order is load-bearing: the access ends first, then `endMoving` lets the edits that waited go on.
defer { endMoving() }
defer { access.end() }
// A Show detail edit already running finishes first, as for a move.
while editsInProgress > 0 {
await withCheckedContinuation { editWaiters.append($0) }
}
if let refusal = releaseRefusal(show: show, take: take) {
access.end()
return .kept([refusal])
}
if let refusal = releaseRefusal(show: show, take: take) { return .kept([refusal]) }
let name = TakeMetadata.name(take: take)
let deviceTake = deviceFolder.appending(path: show).appending(path: name)
let driveTake = access.folder.appending(path: show).appending(path: name)
let shouldStop = moveStop.latch()
let outcome = await runOffPool { DeviceTakeRelease.run(deviceTake: deviceTake, driveTake: driveTake, shouldStop: shouldStop) }
access.end()
guard outcome == .released else { return outcome }
// It is no longer kept on this device: off the moves list, and the last Take reads as moved if it is that one.
lastMoveRun.moves.removeAll { $0.show == show && $0.take == take }
Expand Down Expand Up @@ -1779,6 +1769,14 @@ public final class Recorder {
/// Edits waiting for a move to end (#630).
@ObservationIgnored private var moveWaiters: [CheckedContinuation<Void, Never>] = []

/// Ends a move or a delete from the Device: `isMoving` is false and every edit that waited for it gets going (#630).
private func endMoving() {
isMoving = false
let waiting = moveWaiters
moveWaiters = []
for waiter in waiting { waiter.resume() }
}

/// Returns once no move is running (#630). On the main actor, what the caller does next runs before any move can
/// start.
func waitWhileMoving() async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,37 @@ struct RecorderReleaseTests {
#expect(recorder.moveReport.entries.isEmpty, "no longer listed as kept on the iPhone")
}

final class Ended: @unchecked Sendable {
private let lock = NSLock()
private var value = 0
func bump() { lock.withLock { value += 1 } }
var count: Int { lock.withLock { value } }
}

@Test("Each delete lets the Drive go exactly once, whether it deletes or keeps the Take, and leaves no move running (#890)")
func driveLetGoOnce() async throws {
defer { try? FileManager.default.removeItem(at: root) }
try makeKeptTake()
let ended = Ended()
let drive = drive
let recorder = Recorder(
deviceFolder: device, driveFolder: { DriveAccess(folder: drive, stop: { ended.bump() }) },
now: { RecordingTakeTests.showDay }, destinationCheckInterval: nil)

// The Drive lacks a Stem: kept.
let hidden = driveTake.appending(path: DeviceTakeReleaseTests.channel(2).stemFile)
let aside = root.appending(path: "aside.wav")
try FileManager.default.moveItem(at: hidden, to: aside)
guard case .kept = await recorder.releaseFromDevice(show: "2026-10-06 Show", take: 1) else { Issue.record("not kept"); return }
#expect(ended.count == 1, "kept")
#expect(!recorder.isMoving)

try FileManager.default.moveItem(at: aside, to: hidden)
#expect(await recorder.releaseFromDevice(show: "2026-10-06 Show", take: 1) == .released)
#expect(ended.count == 2, "released")
#expect(!recorder.isMoving)
}

@Test("Not during a Take, and not during a save: the Take stays")
func neverWhileABusyTake() async throws {
defer { try? FileManager.default.removeItem(at: root) }
Expand Down
Loading