Skip to content

Fix Swift 6 MainActor concurrency compatibility - #70

Merged
enebin merged 3 commits into
mainfrom
gh-69-update-code-base-for-6-2
Jun 10, 2026
Merged

enebin merged 3 commits into
mainfrom
gh-69-update-code-base-for-6-2

Conversation

@enebin

@enebin enebin commented Jun 5, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Support Swift 6 Default Actor Isolation MainActor builds across session, recorder, camera, album, processor, tuner, and representable layers.
  • Ensure Combine publisher delivery is marshalled to the main queue, including cached CurrentValueSubject values.
  • Preserve pre-start session configuration queueing and serialize shared mutable state used from background tasks.
  • Fix Cuckoo mock generation in worktrees and add main-thread publisher delivery tests.

Related Issues

Verification

  • xcodebuild build -quiet -project Demo/Aespa-iOS.xcodeproj -scheme Aespa-iOS -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /private/tmp/AespaDerivedData/Demo SWIFT_VERSION=6.0 SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor
  • xcodebuild test -project Tests/TestHostApp.xcodeproj -scheme Test -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -derivedDataPath /private/tmp/AespaDerivedData/Tests

Summary by CodeRabbit

  • New Features

    • Added a utility ensuring events are delivered reliably on the main thread.
  • Bug Fixes

    • Improved thread-safety and concurrency stability across core capture, album, and file handling.
  • Refactor

    • Broad concurrency and scheduling updates for better reliability and main-thread delivery semantics.
  • Chores

    • Updated version to 1.10.3 and adjusted ignore rules.
  • Tests

    • Added tests verifying main-thread event delivery behavior.

@coderabbitai

coderabbitai Bot commented Jun 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a637e965-7f0d-4466-a2cf-57f52a3ed80a

📥 Commits

Reviewing files that changed from the base of the PR and between fdfc2f0 and b29978c.

📒 Files selected for processing (1)
  • Sources/Aespa/Core/AespaCoreAlbumManager.swift
🚧 Files skipped from review as they are similar to previous changes (1)
  • Sources/Aespa/Core/AespaCoreAlbumManager.swift

📝 Walkthrough

Walkthrough

This PR enables Aespa adoption on MainActor-isolated Swift 6 apps by adding nonisolated/Sendable annotations throughout the runtime, replacing OperationQueue with DispatchQueue-based work scheduling, introducing lock-guarded state synchronization, and ensuring all Combine event delivery occurs on the main thread.

Changes

Swift 6 concurrency isolation and main-thread event delivery

Layer / File(s) Summary
Build scripts, version, and ignore patterns
.gitignore, Scripts/gen-mocks.sh, Scripts/version
Repository-root detection uses dynamic git resolution, mock generation supports cuckoo_generator, version bumps to 1.10.3, and cuckoonator is ignored.
Subject main-thread dispatch and device orientation provider
Sources/Aespa/Util/Extension/Subject+Extension.swift, Sources/Aespa/Util/Extension/UIKit+Extension.swift
A new sendOnMainThread method on Combine Subject dispatches values to the main thread via DispatchQueue, and DeviceOrientationProvider centralizes orientation reading with main-thread safety.
Test harness and subject extension tests
Tests/TestHostApp.xcodeproj/project.pbxproj, Tests/Tests/Util/SubjectExtensionTests.swift
Unit tests verify main-thread delivery on main and background threads, and Xcode project wiring includes the test file in the build.
Data models and public protocol contracts
Sources/Aespa/Data/Asset/PhotoAsset.swift, Sources/Aespa/Data/Asset/VideoAsset.swift, Sources/Aespa/Data/File/PhotoFile.swift, Sources/Aespa/Data/File/VideoFile.swift, Sources/Aespa/AespaOption.swift, Sources/Aespa/Loader/AespaLoading.swift, Sources/Aespa/Processor/AespaProcessing.swift
PhotoAsset, VideoAsset, PhotoFile, VideoFile, and all option/loader/processor types are marked nonisolated and Sendable.
Context protocol hierarchy with Sendable and handler aliases
Sources/Aespa/Core/Context/Context.swift
CommonContext, VideoContext, and PhotoContext become nonisolated Sendable protocols with updated ResultHandler and CompletionHandler typealiases marked @Sendable.
AVFoundation representable protocol isolation
Sources/Aespa/Core/Representable/AVCaptureConnection+AespaRepresentable.swift, Sources/Aespa/Core/Representable/AVCaptureDevice+AespaRepresentable.swift, Sources/Aespa/Core/Representable/AVCaptureFileOutput+AespaRepresentable.swift, Sources/Aespa/Core/Representable/AVCapturePhotoOutput+AespaRepresentable.swift, Sources/Aespa/Core/Representable/Photos+AespaRepresentable.swift, Sources/Aespa/Core/Representable/AespaCoreSession+AespaRepresentable.swift
All representable protocols for AVFoundation capture components are marked nonisolated for concurrency compliance.
Processor protocols, asset utilities, and tuning interfaces
Sources/Aespa/Processor/AespaProcessing.swift, Sources/Aespa/Tuner/AespaTuning.swift
Processor and tuning protocol families adopt nonisolated/Sendable annotations.
Aespa root and session instance lifecycle with lock-guarded core
Sources/Aespa/Aespa.swift
Aespa singleton uses NSRecursiveLock to guard shared core instance, ensuring atomic initialization-once semantics and preventing duplicate session creation on MainActor apps.
AespaSession concurrency annotations and main-queue publishers
Sources/Aespa/AespaSession.swift
AespaSession adopts nonisolated/Sendable, context extensions are nonisolated, completion handlers use ResultHandler alias, and Combine publishers receive(on: DispatchQueue.main) before delivery.
Core session dispatch-queue work scheduler
Sources/Aespa/Core/AespaCoreSession.swift
AespaCoreSession replaces OperationQueue with custom DispatchQueue work scheduling, lock-protected queue activation, and onStartFailure callbacks for async startup handling.
Album manager, camera, recorder, and event manager isolation
Sources/Aespa/Core/AespaCoreAlbumManager.swift, Sources/Aespa/Core/AespaCoreCamera.swift, Sources/Aespa/Core/AespaCoreRecorder.swift, Sources/Aespa/Core/AespaEventManager.swift
Core runtime components adopt nonisolated/Sendable, replace Combine subject flows with continuation-based capture, lock photo library state, and dispatch all inserted/removed asset events via sendOnMainThread.
Photo and video context state locking and main-thread publishes
Sources/Aespa/Core/Context/AespaPhotoContext.swift, Sources/Aespa/Core/Context/AespaVideoContext.swift
Photo and video contexts synchronize settings and recording state via recursive locks, publish initial/generated files via sendOnMainThread, and add nonisolated deprecated setter extensions.
Processor implementations using centralized orientation provider
Sources/Aespa/Processor/Capture/CapturePhotoProcessor.swift, Sources/Aespa/Processor/Record/StartRecordProcessor.swift, Sources/Aespa/Loader/Asset/AssetLoader.swift, Sources/Aespa/Processor/Asset/PhotoAssetAdditionProcessor.swift, Sources/Aespa/Processor/Asset/VideoAssetAdditionProcessor.swift, Sources/Aespa/Processor/File/FileWritingProcessor.swift, Sources/Aespa/Processor/Record/FinishRecordProcessor.swift
Processors switch auto-orientation sourcing to DeviceOrientationProvider and adopt nonisolated/Sendable annotations.
Utility types with concurrency annotations and locked logging state
Sources/Aespa/Util/Album/AlbumImporter.swift, Sources/Aespa/Util/Extension/AVFoundation+Extension.swift, Sources/Aespa/Util/File/FilePathProvider.swift, Sources/Aespa/Util/File/PhotoFileGenerator.swift, Sources/Aespa/Util/File/VideoFileGenerator.swift, Sources/Aespa/Data/Proxy/AssetCachingProxy.swift, Sources/Aespa/Util/Log/Logger.swift
Utility helpers are marked nonisolated/Sendable, Logger's enableLogging state is refactored to a computed property backed by a lock and nonisolated(unsafe) storage, and photo caching uses nonisolated(unsafe) binding during continuation resumption.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 From isolated threads, a unity flows,
DispatchQueues dance where main thread glows,
Locks guard the core, safe and sound,
Swift 6 concurrency's solid ground,
Your MainActor apps can thrive anew! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.20% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix Swift 6 MainActor concurrency compatibility' directly and clearly describes the main change: updating the codebase for Swift 6 MainActor compatibility.
Linked Issues check ✅ Passed The PR successfully addresses both linked issues: #69 (Swift 6 MainActor crashes) through actor isolation annotations, and #49 (background thread publishing) through sendOnMainThread implementation.
Out of Scope Changes check ✅ Passed All changes are directly related to Swift 6 MainActor compatibility. Minor additions include version bump (1.10.3) and cuckoo mock generation fix for worktrees, which support the overall objective.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gh-69-update-code-base-for-6-2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@enebin enebin self-assigned this Jun 5, 2026
@enebin enebin added the bug Something isn't working label Jun 5, 2026
@enebin

enebin commented Jun 5, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 5, 2026 •

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (11)
Sources/Aespa/AespaSession.swift (1)

355-362: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Call completion on successful session start.

The success path never invokes onComplete, so callers depending on completion signaling won’t be notified when startup succeeds.

💡 Suggested fix
     func startSession(_ onComplete: `@escaping` CompletionHandler) {
         do {
             try coreSession.start()
             previewLayerSubject.sendOnMainThread(previewLayer)
+            onComplete(.success(()))
         } catch let error {
             onComplete(.failure(error))
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/AespaSession.swift` around lines 355 - 362, The startSession(_
onComplete: `@escaping` CompletionHandler) function currently only calls
onComplete on error; after successfully calling try coreSession.start() and
previewLayerSubject.sendOnMainThread(previewLayer) you must also invoke
onComplete with a success result (e.g. onComplete(.success(()))) so callers are
notified of successful startup; ensure you call the success completion on the
same thread convention as failures (typically main thread) to match existing
behavior.
Sources/Aespa/Core/AespaCoreRecorder.swift (2)

24-35: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Recorder still bypasses AespaCoreSession's serialized movie-output queue.

Lines 25-32 read core.movieFileOutput and call processor.process(output) directly from the caller's executor, even though AespaCoreSession now exposes a queued run<T: AespaMovieFileOutputProcessing> path for exactly this resource. That leaves startRecording(...) outside the new serialization model.

Suggested fix
 func run<T: AespaMovieFileOutputProcessing>(processor: T, _ onComplete: `@escaping` CompletionHandler) {
-    guard let output = core.movieFileOutput else {
-        onComplete(.failure(AespaError.session(reason: .cannotFindConnection)))
-        return
-    }
-
-    do {
-        try processor.process(output)
-        onComplete(.success(()))
-    } catch {
-        onComplete(.failure(error))
-    }
+    core.run(processor, onComplete)
 }

Also applies to: 39-50

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/AespaCoreRecorder.swift` around lines 24 - 35, The
recorder's run<T: AespaMovieFileOutputProcessing>(processor:_:) is reading
core.movieFileOutput and calling processor.process(output) directly, bypassing
AespaCoreSession's queued execution; replace that direct access with a call into
the session's serialized API (use the session's run<T:
AespaMovieFileOutputProcessing>(...) or equivalent method exposed by
AespaCoreSession) so the processor is executed on the core.movie-output queue,
and do the same fix for the other similar overload (the block covering lines
39-50) to ensure all movieFileOutput operations go through the session's queued
path.

52-66: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

stopRecording() can hang forever on synchronous stop failures.

Line 53 discards the run(processor:) result and then always awaits the subject. If the movie output is missing or FinishRecordProcessor fails before didFinishRecordingTo fires, no value is ever sent and this continuation never resumes.

Suggested fix
 func stopRecording() async throws -> URL {
-    run(processor: FinishRecordProcessor(), { _ in })
-    
     return try await withCheckedThrowingContinuation { continuation in
         fileIOResultSubsciption = fileIOResultSubject.sink { _ in
             // Do nothing on completion; we're only interested in values.
         } receiveValue: { result in
             switch result {
             case .success(let url):
                 continuation.resume(returning: url)
             case .failure(let error):
                 continuation.resume(throwing: error)
             }
         }
+
+        run(processor: FinishRecordProcessor()) { result in
+            if case .failure(let error) = result {
+                self.fileIOResultSubsciption?.cancel()
+                self.fileIOResultSubsciption = nil
+                continuation.resume(throwing: error)
+            }
+        }
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/AespaCoreRecorder.swift` around lines 52 - 66,
stopRecording() currently discards the synchronous result of run(processor:
FinishRecordProcessor()) and then awaits fileIOResultSubject forever when run
fails early; fix by subscribing to fileIOResultSubject (fileIOResultSubsciption)
before calling run, and pass a completion handler to run(processor:
FinishRecordProcessor(), ...) that immediately resumes the
withCheckedThrowingContinuation with an error on synchronous failure (or
forwards success), making sure to only call continuation.resume once (guard with
a Bool or use continuation.resume(returning:) / resume(throwing:) exactly once).
Ensure the completion handler or run's synchronous failure path and the
subject's receiveValue both resume the same continuation and that you
capture/clear fileIOResultSubsciption after resuming.
Sources/Aespa/Core/AespaCoreCamera.swift (1)

16-17: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Single shared result channel breaks overlapping captures.

This type now advertises cross-task safety, but Lines 41-47 store every in-flight capture in one shared subscription. A second capture(...) cancels the first sink, the first result is delivered to the wrong continuation, and the later result can double-resume the remaining continuation.

Also applies to: 33-52, 56-69

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/AespaCoreCamera.swift` around lines 16 - 17, The bug is
that a single shared PassthroughSubject (fileIOResultSubject) and a single
subscription (fileIOResultSubsciption) are reused across overlapping
capture(...) calls causing cancellations, mis-delivery, and double-resumes; fix
by making the result channel per-capture (create a new
PassthroughSubject<Result<AVCapturePhoto, Error>, Never> inside capture(...) or
maintain a dictionary keyed by a captureID/UUID mapping to individual
subjects/subscriptions), wire each capture to its own subscription so cancelling
one doesn’t cancel others, and ensure each continuation is resumed only once by
associating the continuation with that per-capture subject (update any code that
references fileIOResultSubject and fileIOResultSubsciption to use the
per-capture storage and keys).
Sources/Aespa/Core/AespaCoreAlbumManager.swift (1)

12-22: ⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

@unchecked Sendable is unsafe here without synchronizing the album state.

Lines 65-89 and Lines 130-168 read and write album, latestVideoFetchResult, and latestPhotoFetchResult from multiple async entry points plus the Photos change callback, but this class has no lock or actor boundary. Two concurrent fetch/add calls can race album initialization, and observer updates can interleave with loader reads.

Also applies to: 56-90, 129-168

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/AespaCoreAlbumManager.swift` around lines 12 - 22,
AespaCoreAlbumManager currently marks itself `@unchecked` Sendable while
reading/writing album, latestVideoFetchResult, and latestPhotoFetchResult from
multiple async entry points and the Photos change callback; remove the unchecked
Sendable marker and serialize access to those three properties (album,
latestVideoFetchResult, latestPhotoFetchResult) by introducing a private
synchronization primitive (e.g., a dedicated serial DispatchQueue or a
concurrent queue with barrier writes) and ensure every read/write in the
class—especially in the Photos change callback and in the fetch/add methods that
initialize or update the album—occurs on that queue (use sync for reads and
async/barrier for writes) so races are eliminated.
Sources/Aespa/Core/AespaCoreSession.swift (1)

24-38: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not report success from defer on throwable tuning paths.

Line 30 and Line 51 execute before the catch block when tune(...) throws, so the same completion is invoked once with .success and then again with .failure. Any async bridge on top of this will trap on the second resume.

Suggested fix
 func run<T: AespaSessionTuning>(_ tuner: T, _ onComplete: `@escaping` CompletionHandler) {
     enqueueWork {
         do {
             if tuner.needTransaction { self.beginConfiguration() }
             defer {
                 if tuner.needTransaction { self.commitConfiguration() }
-                onComplete(.success(()))
             }
             
             try tuner.tune(self)
+            onComplete(.success(()))
         } catch let error {
             Logger.log(error: error, message: "in \(tuner)")
             onComplete(.failure(error))
         }
     }
 }

 func run<T: AespaDeviceTuning>(_ tuner: T, _ onComplete: `@escaping` CompletionHandler) {
     enqueueWork {
         do {
             guard let device = self.videoDeviceInput?.device else {
                 throw AespaError.device(reason: .invalid)
             }
             
             if tuner.needLock { try device.lockForConfiguration() }
             defer {
                 if tuner.needLock { device.unlockForConfiguration() }
-                onComplete(.success(()))
             }
             
             try tuner.tune(device)
+            onComplete(.success(()))
         } catch let error {
             Logger.log(error: error, message: "in \(tuner)")
             onComplete(.failure(error))
         }
     }
 }

Also applies to: 41-59

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/AespaCoreSession.swift` around lines 24 - 38, The defer
currently calls commitConfiguration() and onComplete(.success(())) so when
tuner.tune(self) throws the defer still reports success before the catch reports
failure; fix by moving only commit/rollback configuration logic into the defer
and remove onComplete(.success(())) from it: keep beginConfiguration() before
try, have defer call commitConfiguration() (or rollback as appropriate) but call
onComplete(.success(())) explicitly after a successful try (i.e., immediately
after try tuner.tune(self) returns) and let the catch call
onComplete(.failure(error)); update the run<T: AespaSessionTuning> function
(referencing tuner.tune(self), beginConfiguration(), commitConfiguration(), and
onComplete) accordingly so completion is invoked exactly once.
Sources/Aespa/Core/Representable/Photos+AespaRepresentable.swift (1)

30-39: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don't rewrite the caller's PHFetchOptions in place.

fetchAlbum mutates the fetchOptions instance it was handed by overwriting its predicate. A reused options object will carry that hidden state into later fetches, and nonisolated makes accidental sharing across tasks easier. Build a local options object for the title filter instead of mutating the parameter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/Representable/Photos`+AespaRepresentable.swift around
lines 30 - 39, fetchAlbum currently mutates the caller's PHFetchOptions by
assigning fetchOptions.predicate; instead create a new local PHFetchOptions (or
copy the incoming one) and set the NSPredicate on that local instance, then pass
the local options into PHAssetCollection.fetchAssetCollections; do not modify
the parameter named fetchOptions, keep the function signature and return type
(fetchAlbum<Collection: AespaAssetCollectionRepresentable>) unchanged and only
replace the mutated options usage when calling
PHAssetCollection.fetchAssetCollections.
Sources/Aespa/Core/Representable/AespaCoreSession+AespaRepresentable.swift (2)

129-138: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove the attached video input, not a fresh wrapper.

removeMovieInput() creates a new AVCaptureDeviceInput and passes that to removeInput(_:). That won't match the instance currently registered on the session, so the video input stays attached and later reconfiguration can fail.

Suggested fix
 func removeMovieInput() {
-    guard
-        let videoDevice = AVCaptureDevice.default(for: AVMediaType.video),
-        let videoInput = try? AVCaptureDeviceInput(device: videoDevice)
-    else {
-        return
-    }
-
-    self.removeInput(videoInput)
+    if let videoDeviceInput {
+        self.removeInput(videoDeviceInput)
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/Representable/AespaCoreSession`+AespaRepresentable.swift
around lines 129 - 138, removeMovieInput() builds a new AVCaptureDeviceInput and
calls removeInput(_:) on that fresh wrapper, which won't match the
already-attached instance; instead locate the existing AVCaptureDeviceInput in
the session's inputs (e.g. iterate self.inputs or use first(where:)) whose
device has mediaType == .video or whose device == AVCaptureDevice.default(for:
.video) and pass that existing AVCaptureDeviceInput to removeInput(_:); update
removeMovieInput() to find and remove the matching AVCaptureDeviceInput instance
rather than creating a new one.

14-70: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Route AVCaptureSession start/stop and input/output mutations through the session workQueue; fix removeMovieInput() to remove the existing input instance.

  • AespaCoreSessionRepresentable and AespaCoreSession are nonisolated, exposing direct startRunning/add/remove APIs. Separately, AespaSession.startSession calls try coreSession.start() inside Task, and AespaCoreSession.start() directly calls session.startRunning() + adds inputs/outputs without going through enqueueWork/workQueue, so the blocking + ordering constraints aren’t actually enforced for session start.
  • removeMovieInput() constructs a brand-new AVCaptureDeviceInput and calls removeInput(_:) with it; it should remove the currently-attached video input instance from self.inputs (e.g., via videoDeviceInput / an inputs lookup), matching how removeAudioInput() behaves and avoiding identity-mismatch failures.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/Representable/AespaCoreSession`+AespaRepresentable.swift
around lines 14 - 70, The start/stop and input/output mutations must be routed
through the session workQueue/enqueueWork and removeMovieInput must remove the
attached input instance instead of creating a new one: update
AespaCoreSession.start() (and any direct calls to
session.startRunning()/stopRunning(), addMovieInput(), addAudioInput(),
addMovieFileOutput(), addCapturePhotoOutput()) to perform their session
mutations inside enqueueWork/on the workQueue rather than calling
AVCaptureSession APIs synchronously, and change removeMovieInput() to mirror
removeAudioInput() by using the existing videoDeviceInput (or lookup from
self.inputs) to call removeInput(_:) so you remove the actual attached
AVCaptureDeviceInput instance instead of instantiating a new one.
Sources/Aespa/Core/Context/AespaVideoContext.swift (1)

105-123: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear isRecording before post-stop work can fail.

After Line 108 succeeds, recording has already stopped, but isRecording is only reset after Lines 110-116 finish. Any failure in album import or file generation leaves the context stuck in the recording state even though the recorder is done.

Suggested fix
         Task(priority: .utility) {
             do {
                 let videoFilePath = try await recorder.stopRecording()
+                isRecording = false
                 
                 if option.asset.synchronizeWithLocalAlbum {
                     try await albumManager.addToAlbum(filePath: videoFilePath)
                     // delete
                 }
@@
-                isRecording = false
                 onCompelte(.success(videoFile))
             } catch let error {
                 Logger.log(error: error)
                 onCompelte(.failure(error))
             }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/Context/AespaVideoContext.swift` around lines 105 - 123,
The recorder has already stopped when recorder.stopRecording() returns but
isRecording is only set false after follow-up work; change stopRecording(_
onCompelte:) so that immediately after let videoFilePath = try await
recorder.stopRecording() you set isRecording = false (before awaiting
albumManager.addToAlbum or calling VideoFileGenerator.generate), then proceed
with album import and file generation as before and ensure the existing catch
still calls onCompelte(.failure(error)) and logs the error; keep references to
recorder.stopRecording(), isRecording, albumManager.addToAlbum(filePath:),
VideoFileGenerator.generate(with:), videoFileBufferSubject.sendOnMainThread(_:),
and onCompelte to locate the changes.
Sources/Aespa/Processor/File/FileWritingProcessor.swift (1)

14-17: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix the existence check before writing.

Line 16 passes deletingLastPathComponent().absoluteString into fileExists(atPath:), so this is checking a file:// URL string instead of a filesystem path. It also checks the parent directory while throwing .alreadyExist, which makes the condition itself inconsistent. Use path.path, and validate the exact thing you want to guard: the destination file for overwrite protection, or the parent directory for existence.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Processor/File/FileWritingProcessor.swift` around lines 14 -
17, In process(_ fileManager: FileManager) the current guard uses
path.deletingLastPathComponent().absoluteString which passes a file:// URL
string and checks the parent directory but throws AespaError.file(reason:
.alreadyExist); change the logic to check the actual filesystem path using
path.path and validate the intended target: if you want overwrite protection,
call fileManager.fileExists(atPath: path.path) and throw AespaError.file(reason:
.alreadyExist) when true; if instead you intend to validate the parent
directory, call fileManager.fileExists(atPath:
path.deletingLastPathComponent().path) and use a more appropriate error for a
missing parent directory.
🧹 Nitpick comments (3)
Sources/Aespa/Data/Asset/PhotoAsset.swift (1)

14-20: ⚡ Quick win

Prefer checked Sendable here if your target SDK already supports it.

PHAsset and UIImage are Sendable in current Apple SDK docs, so @unchecked Sendable buys nothing in that configuration and suppresses compiler checking for any future non-Sendable field added to PhotoAsset. If you no longer need an older SDK workaround, switch this to checked Sendable; otherwise, add a comment documenting the toolchain constraint. (developer.apple.com)

Suggested change
-nonisolated public struct PhotoAsset: `@unchecked` Sendable {
+nonisolated public struct PhotoAsset: Sendable {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Data/Asset/PhotoAsset.swift` around lines 14 - 20, Replace the
unchecked sendable conformance on PhotoAsset: if your minimum toolchain already
treats PHAsset and UIImage as Sendable, remove "`@unchecked`" and make PhotoAsset
conform to checked Sendable (i.e. keep "nonisolated public struct PhotoAsset:
Sendable") so the compiler enforces future safety for fields like asset: PHAsset
and uiimage: UIImage; if you must support an older toolchain that lacks Sendable
conformance for PHAsset/UIImage, keep "`@unchecked` Sendable" but add a clear
comment above PhotoAsset documenting the toolchain constraint and why unchecked
is required.
Sources/Aespa/Data/File/PhotoFile.swift (1)

16-22: ⚡ Quick win

Use checked Sendable here once the SDK baseline allows it.

UIImage is Sendable in current Apple SDK docs, so this wrapper can likely use checked Sendable instead of @unchecked Sendable. That keeps the public annotation explicit while preserving compiler enforcement if a future stored property isn't concurrency-safe. (developer.apple.com)

Suggested change
-nonisolated public struct PhotoFile: `@unchecked` Sendable {
+nonisolated public struct PhotoFile: Sendable {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Data/File/PhotoFile.swift` around lines 16 - 22, Change the
concurrency annotation on the PhotoFile wrapper from unchecked to checked
Sendable: update the declaration of nonisolated public struct PhotoFile:
`@unchecked` Sendable to use nonisolated public struct PhotoFile: Sendable,
keeping the stored properties (creationDate: Date and image: UIImage) as-is;
this will rely on UIImage being Sendable in the SDK baseline and enable compiler
enforcement for future non-thread-safe stored properties.
Tests/Tests/Util/SubjectExtensionTests.swift (1)

20-58: ⚡ Quick win

Add a CurrentValueSubject case to this suite.

These tests only cover PassthroughSubject, but the helper is also used with CurrentValueSubject call sites like Sources/Aespa/AespaSession.swift:23-37 and Sources/Aespa/Core/Context/AespaVideoContext.swift:18-32. A focused test there would protect the buffered/current-value path that motivated this PR.

🧪 Example test shape
+    func testSendOnMainThread_withCurrentValueSubject_deliversOnMainThread() {
+        let subject = CurrentValueSubject<Int, Never>(0)
+        let expectation = expectation(description: "CurrentValueSubject delivery on main thread")
+        var receivedValue: Int?
+        var deliveredOnMainThread = false
+
+        subject
+            .dropFirst()
+            .sink { value in
+                receivedValue = value
+                deliveredOnMainThread = Thread.isMainThread
+                expectation.fulfill()
+            }
+            .store(in: &cancellables)
+
+        DispatchQueue.global(qos: .utility).async {
+            subject.sendOnMainThread(1)
+        }
+
+        wait(for: [expectation], timeout: 2)
+        XCTAssertEqual(receivedValue, 1)
+        XCTAssertTrue(deliveredOnMainThread)
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Tests/Tests/Util/SubjectExtensionTests.swift` around lines 20 - 58, Add two
tests mirroring the PassthroughSubject cases but using CurrentValueSubject:
implement
testSendOnMainThread_whenCalledFromMainThread_withCurrentValue_deliversImmediately
(create CurrentValueSubject<Int, Never>(0), subscribe with sink storing
receivedValue and deliveredOnMainThread, call sendOnMainThread(1) and assert
value and main-thread delivery) and
testSendOnMainThread_whenCalledFromBackgroundThread_withCurrentValue_deliversOnMainThread
(subscribe, call sendOnMainThread(2) from DispatchQueue.global, await
expectation, then assert value and main-thread delivery). Use the same
cancellables storage and assertion patterns as the existing PassthroughSubject
tests and reference sendOnMainThread, CurrentValueSubject, and the existing test
method names to locate where to add them.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/Aespa/Aespa.swift`:
- Around line 25-39: Currently the code assigns the new AespaSession to the
shared core inside withCoreLock before async authorization/start completes,
which can leave a stale failed singleton; instead, instantiate AespaSession
locally (do not assign to core), perform async authorization/start and only
inside withCoreLock assign core = newCore after the start succeeds, and on
failure ensure you do not persist core and propagate the .failure via the
existing session(...) completion path; apply the same pattern for the similar
block referenced (lines 45-53) and use the same symbols AespaSession, core,
withCoreLock, shouldConfigureSession, and session(...) to locate and update the
logic.

In `@Sources/Aespa/Core/AespaCoreSession.swift`:
- Around line 94-106: The start() method can throw during
addMovieInput/addMovieFileOutput/addCapturePhotoOutput and currently never
activates the work queue or fails pending run(...) closures; change start() to
ensure activateWorkQueue() is always called (use defer or a do/catch that calls
activateWorkQueue() in both paths) and, on error, drain pendingWorkItems and
complete each queued closure with a failure (propagate the thrown Error) so
callers waiting on run(...) are resumed with an error; reference start(),
activateWorkQueue(), pendingWorkItems, run(...), addMovieInput(),
addMovieFileOutput(), and addCapturePhotoOutput() when implementing.

In `@Sources/Aespa/Core/Context/AespaPhotoContext.swift`:
- Around line 114-117: The code stores the caller-provided
AVCapturePhotoSettings instance directly into photoSetting (inside the case
.custom branch and the other assignment where photoSetting = setting), which
allows external mutation races; instead, make a defensive copy before storing by
creating a new AVCapturePhotoSettings from the provided one (e.g. via
AVCapturePhotoSettings(from:)) and assign that copy inside withSettingLock so
photoSetting always holds an independent instance; update both places that
assign photoSetting (the case .custom branch and the other assignment site) to
use the copied instance.

In `@Sources/Aespa/Util/Log/Logger.swift`:
- Around line 10-11: Logger.enableLogging is a mutable static flag accessed
concurrently (written in Aespa.session(with:onComplete:) and read in Logger.log
running on AespaCoreSession.workQueue), causing a potential data race; fix by
making the flag immutable after initial setup or syncrhonize access: either (a)
move logging enablement into per-session configuration passed into
AespaCoreSession and remove the static, (b) make enableLogging a let or an
once-set property that refuses subsequent writes, or (c) guard reads/writes with
a lock or atomic wrapper around Logger.enableLogging; update references in
Logger (enableLogging, log) and the writer in Aespa.session(with:onComplete:)
accordingly so all accesses are thread-safe.

---

Outside diff comments:
In `@Sources/Aespa/AespaSession.swift`:
- Around line 355-362: The startSession(_ onComplete: `@escaping`
CompletionHandler) function currently only calls onComplete on error; after
successfully calling try coreSession.start() and
previewLayerSubject.sendOnMainThread(previewLayer) you must also invoke
onComplete with a success result (e.g. onComplete(.success(()))) so callers are
notified of successful startup; ensure you call the success completion on the
same thread convention as failures (typically main thread) to match existing
behavior.

In `@Sources/Aespa/Core/AespaCoreAlbumManager.swift`:
- Around line 12-22: AespaCoreAlbumManager currently marks itself `@unchecked`
Sendable while reading/writing album, latestVideoFetchResult, and
latestPhotoFetchResult from multiple async entry points and the Photos change
callback; remove the unchecked Sendable marker and serialize access to those
three properties (album, latestVideoFetchResult, latestPhotoFetchResult) by
introducing a private synchronization primitive (e.g., a dedicated serial
DispatchQueue or a concurrent queue with barrier writes) and ensure every
read/write in the class—especially in the Photos change callback and in the
fetch/add methods that initialize or update the album—occurs on that queue (use
sync for reads and async/barrier for writes) so races are eliminated.

In `@Sources/Aespa/Core/AespaCoreCamera.swift`:
- Around line 16-17: The bug is that a single shared PassthroughSubject
(fileIOResultSubject) and a single subscription (fileIOResultSubsciption) are
reused across overlapping capture(...) calls causing cancellations,
mis-delivery, and double-resumes; fix by making the result channel per-capture
(create a new PassthroughSubject<Result<AVCapturePhoto, Error>, Never> inside
capture(...) or maintain a dictionary keyed by a captureID/UUID mapping to
individual subjects/subscriptions), wire each capture to its own subscription so
cancelling one doesn’t cancel others, and ensure each continuation is resumed
only once by associating the continuation with that per-capture subject (update
any code that references fileIOResultSubject and fileIOResultSubsciption to use
the per-capture storage and keys).

In `@Sources/Aespa/Core/AespaCoreRecorder.swift`:
- Around line 24-35: The recorder's run<T:
AespaMovieFileOutputProcessing>(processor:_:) is reading core.movieFileOutput
and calling processor.process(output) directly, bypassing AespaCoreSession's
queued execution; replace that direct access with a call into the session's
serialized API (use the session's run<T: AespaMovieFileOutputProcessing>(...) or
equivalent method exposed by AespaCoreSession) so the processor is executed on
the core.movie-output queue, and do the same fix for the other similar overload
(the block covering lines 39-50) to ensure all movieFileOutput operations go
through the session's queued path.
- Around line 52-66: stopRecording() currently discards the synchronous result
of run(processor: FinishRecordProcessor()) and then awaits fileIOResultSubject
forever when run fails early; fix by subscribing to fileIOResultSubject
(fileIOResultSubsciption) before calling run, and pass a completion handler to
run(processor: FinishRecordProcessor(), ...) that immediately resumes the
withCheckedThrowingContinuation with an error on synchronous failure (or
forwards success), making sure to only call continuation.resume once (guard with
a Bool or use continuation.resume(returning:) / resume(throwing:) exactly once).
Ensure the completion handler or run's synchronous failure path and the
subject's receiveValue both resume the same continuation and that you
capture/clear fileIOResultSubsciption after resuming.

In `@Sources/Aespa/Core/AespaCoreSession.swift`:
- Around line 24-38: The defer currently calls commitConfiguration() and
onComplete(.success(())) so when tuner.tune(self) throws the defer still reports
success before the catch reports failure; fix by moving only commit/rollback
configuration logic into the defer and remove onComplete(.success(())) from it:
keep beginConfiguration() before try, have defer call commitConfiguration() (or
rollback as appropriate) but call onComplete(.success(())) explicitly after a
successful try (i.e., immediately after try tuner.tune(self) returns) and let
the catch call onComplete(.failure(error)); update the run<T:
AespaSessionTuning> function (referencing tuner.tune(self),
beginConfiguration(), commitConfiguration(), and onComplete) accordingly so
completion is invoked exactly once.

In `@Sources/Aespa/Core/Context/AespaVideoContext.swift`:
- Around line 105-123: The recorder has already stopped when
recorder.stopRecording() returns but isRecording is only set false after
follow-up work; change stopRecording(_ onCompelte:) so that immediately after
let videoFilePath = try await recorder.stopRecording() you set isRecording =
false (before awaiting albumManager.addToAlbum or calling
VideoFileGenerator.generate), then proceed with album import and file generation
as before and ensure the existing catch still calls onCompelte(.failure(error))
and logs the error; keep references to recorder.stopRecording(), isRecording,
albumManager.addToAlbum(filePath:), VideoFileGenerator.generate(with:),
videoFileBufferSubject.sendOnMainThread(_:), and onCompelte to locate the
changes.

In `@Sources/Aespa/Core/Representable/AespaCoreSession`+AespaRepresentable.swift:
- Around line 129-138: removeMovieInput() builds a new AVCaptureDeviceInput and
calls removeInput(_:) on that fresh wrapper, which won't match the
already-attached instance; instead locate the existing AVCaptureDeviceInput in
the session's inputs (e.g. iterate self.inputs or use first(where:)) whose
device has mediaType == .video or whose device == AVCaptureDevice.default(for:
.video) and pass that existing AVCaptureDeviceInput to removeInput(_:); update
removeMovieInput() to find and remove the matching AVCaptureDeviceInput instance
rather than creating a new one.
- Around line 14-70: The start/stop and input/output mutations must be routed
through the session workQueue/enqueueWork and removeMovieInput must remove the
attached input instance instead of creating a new one: update
AespaCoreSession.start() (and any direct calls to
session.startRunning()/stopRunning(), addMovieInput(), addAudioInput(),
addMovieFileOutput(), addCapturePhotoOutput()) to perform their session
mutations inside enqueueWork/on the workQueue rather than calling
AVCaptureSession APIs synchronously, and change removeMovieInput() to mirror
removeAudioInput() by using the existing videoDeviceInput (or lookup from
self.inputs) to call removeInput(_:) so you remove the actual attached
AVCaptureDeviceInput instance instead of instantiating a new one.

In `@Sources/Aespa/Core/Representable/Photos`+AespaRepresentable.swift:
- Around line 30-39: fetchAlbum currently mutates the caller's PHFetchOptions by
assigning fetchOptions.predicate; instead create a new local PHFetchOptions (or
copy the incoming one) and set the NSPredicate on that local instance, then pass
the local options into PHAssetCollection.fetchAssetCollections; do not modify
the parameter named fetchOptions, keep the function signature and return type
(fetchAlbum<Collection: AespaAssetCollectionRepresentable>) unchanged and only
replace the mutated options usage when calling
PHAssetCollection.fetchAssetCollections.

In `@Sources/Aespa/Processor/File/FileWritingProcessor.swift`:
- Around line 14-17: In process(_ fileManager: FileManager) the current guard
uses path.deletingLastPathComponent().absoluteString which passes a file:// URL
string and checks the parent directory but throws AespaError.file(reason:
.alreadyExist); change the logic to check the actual filesystem path using
path.path and validate the intended target: if you want overwrite protection,
call fileManager.fileExists(atPath: path.path) and throw AespaError.file(reason:
.alreadyExist) when true; if instead you intend to validate the parent
directory, call fileManager.fileExists(atPath:
path.deletingLastPathComponent().path) and use a more appropriate error for a
missing parent directory.

---

Nitpick comments:
In `@Sources/Aespa/Data/Asset/PhotoAsset.swift`:
- Around line 14-20: Replace the unchecked sendable conformance on PhotoAsset:
if your minimum toolchain already treats PHAsset and UIImage as Sendable, remove
"`@unchecked`" and make PhotoAsset conform to checked Sendable (i.e. keep
"nonisolated public struct PhotoAsset: Sendable") so the compiler enforces
future safety for fields like asset: PHAsset and uiimage: UIImage; if you must
support an older toolchain that lacks Sendable conformance for PHAsset/UIImage,
keep "`@unchecked` Sendable" but add a clear comment above PhotoAsset documenting
the toolchain constraint and why unchecked is required.

In `@Sources/Aespa/Data/File/PhotoFile.swift`:
- Around line 16-22: Change the concurrency annotation on the PhotoFile wrapper
from unchecked to checked Sendable: update the declaration of nonisolated public
struct PhotoFile: `@unchecked` Sendable to use nonisolated public struct
PhotoFile: Sendable, keeping the stored properties (creationDate: Date and
image: UIImage) as-is; this will rely on UIImage being Sendable in the SDK
baseline and enable compiler enforcement for future non-thread-safe stored
properties.

In `@Tests/Tests/Util/SubjectExtensionTests.swift`:
- Around line 20-58: Add two tests mirroring the PassthroughSubject cases but
using CurrentValueSubject: implement
testSendOnMainThread_whenCalledFromMainThread_withCurrentValue_deliversImmediately
(create CurrentValueSubject<Int, Never>(0), subscribe with sink storing
receivedValue and deliveredOnMainThread, call sendOnMainThread(1) and assert
value and main-thread delivery) and
testSendOnMainThread_whenCalledFromBackgroundThread_withCurrentValue_deliversOnMainThread
(subscribe, call sendOnMainThread(2) from DispatchQueue.global, await
expectation, then assert value and main-thread delivery). Use the same
cancellables storage and assertion patterns as the existing PassthroughSubject
tests and reference sendOnMainThread, CurrentValueSubject, and the existing test
method names to locate where to add them.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 57fb9daa-9c6d-434c-8265-6cd2da329e1a

📥 Commits

Reviewing files that changed from the base of the PR and between dbf94a2 and 6e3639c.

📒 Files selected for processing (45)
  • .gitignore
  • Scripts/gen-mocks.sh
  • Scripts/version
  • Sources/Aespa/Aespa.swift
  • Sources/Aespa/AespaOption.swift
  • Sources/Aespa/AespaSession.swift
  • Sources/Aespa/Core/AespaCoreAlbumManager.swift
  • Sources/Aespa/Core/AespaCoreCamera.swift
  • Sources/Aespa/Core/AespaCoreRecorder.swift
  • Sources/Aespa/Core/AespaCoreSession.swift
  • Sources/Aespa/Core/AespaEventManager.swift
  • Sources/Aespa/Core/Context/AespaPhotoContext.swift
  • Sources/Aespa/Core/Context/AespaVideoContext.swift
  • Sources/Aespa/Core/Context/Context.swift
  • Sources/Aespa/Core/Representable/AVCaptureConnection+AespaRepresentable.swift
  • Sources/Aespa/Core/Representable/AVCaptureDevice+AespaRepresentable.swift
  • Sources/Aespa/Core/Representable/AVCaptureFileOutput+AespaRepresentable.swift
  • Sources/Aespa/Core/Representable/AVCapturePhotoOutput+AespaRepresentable.swift
  • Sources/Aespa/Core/Representable/AespaCoreSession+AespaRepresentable.swift
  • Sources/Aespa/Core/Representable/Photos+AespaRepresentable.swift
  • Sources/Aespa/Data/Asset/PhotoAsset.swift
  • Sources/Aespa/Data/Asset/Video�Asset.swift
  • Sources/Aespa/Data/File/PhotoFile.swift
  • Sources/Aespa/Data/File/VideoFile.swift
  • Sources/Aespa/Data/Proxy/AssetCachingProxy.swift
  • Sources/Aespa/Loader/AespaLoading.swift
  • Sources/Aespa/Loader/Asset/AssetLoader.swift
  • Sources/Aespa/Processor/AespaProcessing.swift
  • Sources/Aespa/Processor/Asset/PhotoAssetAdditionProcessor.swift
  • Sources/Aespa/Processor/Asset/VideoAssetAdditionProcessor.swift
  • Sources/Aespa/Processor/Capture/CapturePhotoProcessor.swift
  • Sources/Aespa/Processor/File/FileWritingProcessor.swift
  • Sources/Aespa/Processor/Record/FinishRecordProcessor.swift
  • Sources/Aespa/Processor/Record/StartRecordProcessor.swift
  • Sources/Aespa/Tuner/AespaTuning.swift
  • Sources/Aespa/Util/Album/AlbumImporter.swift
  • Sources/Aespa/Util/Extension/AVFoundation+Extension.swift
  • Sources/Aespa/Util/Extension/Subject+Extension.swift
  • Sources/Aespa/Util/Extension/UIKit+Extension.swift
  • Sources/Aespa/Util/File/FilePathProvider.swift
  • Sources/Aespa/Util/File/PhotoFileGenerator.swift
  • Sources/Aespa/Util/File/VideoFileGenerator.swift
  • Sources/Aespa/Util/Log/Logger.swift
  • Tests/TestHostApp.xcodeproj/project.pbxproj
  • Tests/Tests/Util/SubjectExtensionTests.swift

Comment thread Sources/Aespa/Aespa.swift Resolved
Comment thread Sources/Aespa/Util/Log/Logger.swift Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/Aespa/Core/Context/AespaVideoContext.swift (1)

105-125: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Typo in parameter name: onCompelte should be onComplete.

Line 105 has onCompelte instead of onComplete. This is a public API, so the typo will be visible to consumers.

✏️ Proposed fix
-    public func stopRecording(_ onCompelte: `@escaping` ResultHandler<VideoFile> = { _ in }) {
+    public func stopRecording(_ onComplete: `@escaping` ResultHandler<VideoFile> = { _ in }) {
         Task(priority: .utility) {
             do {
                 let videoFilePath = try await recorder.stopRecording()
                 isRecording = false
                 
                 if option.asset.synchronizeWithLocalAlbum {
                     try await albumManager.addToAlbum(filePath: videoFilePath)
                     // delete
                 }
                 
                 let videoFile = VideoFileGenerator.generate(with: videoFilePath, date: Date())
                 videoFileBufferSubject.sendOnMainThread(.success(videoFile))
                 
-                onCompelte(.success(videoFile))
+                onComplete(.success(videoFile))
             } catch let error {
                 Logger.log(error: error)
-                onCompelte(.failure(error))
+                onComplete(.failure(error))
             }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/Context/AespaVideoContext.swift` around lines 105 - 125,
Rename the misspelled public completion parameter in the stopRecording method
from `onCompelte` to `onComplete` and update all internal uses inside the method
(the calls `onCompelte(.success(videoFile))` and `onCompelte(.failure(error))`)
to the corrected name; ensure the method signature in
`AespaVideoContext.stopRecording(_:)` preserves the same default closure value
and behavior so external callers get the corrected API name, and update any
callers/tests that referenced `onCompelte` to use `onComplete`.
🧹 Nitpick comments (1)
Sources/Aespa/Core/AespaCoreCamera.swift (1)

51-71: 💤 Low value

Consider adding a comment explaining the nonisolated(unsafe) usage.

Line 67 uses nonisolated(unsafe) to work around AVCapturePhoto not conforming to Sendable. While this is a pragmatic workaround for Swift 6 compatibility, documenting why this is safe (the photo is consumed immediately and not shared across isolation domains) would help future maintainers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/Aespa/Core/AespaCoreCamera.swift` around lines 51 - 71, Add a short
inline comment above the nonisolated(unsafe) let uncheckedPhoto = photo in the
nonisolated extension AespaCoreCamera's
photoOutput(_:didFinishProcessingPhoto:error:) explaining why the unsafe
nonisolated cast is safe: note that AVCapturePhoto is not Sendable, but here the
photo is consumed immediately on the same thread, the associated continuation
removed via removeContinuation(for:) prevents cross-isolation sharing, and
continuation.resume(returning:) returns the photo synchronously so it won't
escape the actor boundary; keep the comment succinct and reference these symbols
(nonisolated extension AespaCoreCamera,
photoOutput(_:didFinishProcessingPhoto:error:), removeContinuation(for:),
continuation.resume(returning:)) for future maintainers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@Sources/Aespa/Core/Context/AespaVideoContext.swift`:
- Around line 105-125: Rename the misspelled public completion parameter in the
stopRecording method from `onCompelte` to `onComplete` and update all internal
uses inside the method (the calls `onCompelte(.success(videoFile))` and
`onCompelte(.failure(error))`) to the corrected name; ensure the method
signature in `AespaVideoContext.stopRecording(_:)` preserves the same default
closure value and behavior so external callers get the corrected API name, and
update any callers/tests that referenced `onCompelte` to use `onComplete`.

---

Nitpick comments:
In `@Sources/Aespa/Core/AespaCoreCamera.swift`:
- Around line 51-71: Add a short inline comment above the nonisolated(unsafe)
let uncheckedPhoto = photo in the nonisolated extension AespaCoreCamera's
photoOutput(_:didFinishProcessingPhoto:error:) explaining why the unsafe
nonisolated cast is safe: note that AVCapturePhoto is not Sendable, but here the
photo is consumed immediately on the same thread, the associated continuation
removed via removeContinuation(for:) prevents cross-isolation sharing, and
continuation.resume(returning:) returns the photo synchronously so it won't
escape the actor boundary; keep the comment succinct and reference these symbols
(nonisolated extension AespaCoreCamera,
photoOutput(_:didFinishProcessingPhoto:error:), removeContinuation(for:),
continuation.resume(returning:)) for future maintainers.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aeef1e73-1825-4ec6-a07b-d0b9634d65c8

📥 Commits

Reviewing files that changed from the base of the PR and between 6e3639c and fdfc2f0.

📒 Files selected for processing (11)
  • Sources/Aespa/Aespa.swift
  • Sources/Aespa/AespaSession.swift
  • Sources/Aespa/Core/AespaCoreAlbumManager.swift
  • Sources/Aespa/Core/AespaCoreCamera.swift
  • Sources/Aespa/Core/AespaCoreRecorder.swift
  • Sources/Aespa/Core/AespaCoreSession.swift
  • Sources/Aespa/Core/Context/AespaPhotoContext.swift
  • Sources/Aespa/Core/Context/AespaVideoContext.swift
  • Sources/Aespa/Core/Representable/AespaCoreSession+AespaRepresentable.swift
  • Sources/Aespa/Data/Asset/VideoAsset.swift
  • Sources/Aespa/Util/Log/Logger.swift
💤 Files with no reviewable changes (1)
  • Sources/Aespa/Data/Asset/VideoAsset.swift
🚧 Files skipped from review as they are similar to previous changes (5)
  • Sources/Aespa/Util/Log/Logger.swift
  • Sources/Aespa/Core/AespaCoreSession.swift
  • Sources/Aespa/Aespa.swift
  • Sources/Aespa/AespaSession.swift
  • Sources/Aespa/Core/Context/AespaPhotoContext.swift

@enebin
enebin merged commit 4865b20 into main Jun 10, 2026
1 check passed
@enebin
enebin deleted the gh-69-update-code-base-for-6-2 branch June 10, 2026 01:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update code base for 6.2 Xcode warning: Publishing changes from background threads is not allowed

1 participant