Managing Project Save and Close with Concurrent Mutations in Palmier Pro

Palmier Pro uses the ProjectPackageCoordinator to serialize access to the .palmier package, ensuring that mutations pause during saves and close operations wait until all background work completes.

Palmier Pro stores a project's media assets and timeline in a .palmier package. Because the UI runs on the main actor while file-system work and media processing happen off the main thread, the app requires a robust coordination layer to prevent data races. The ProjectPackageCoordinator class, defined in Sources/PalmierPro/Project/ProjectPackageCoordinator.swift, guarantees that no mutation runs while a save or close is in progress.

The Concurrency Challenge in Palmier Pro

Concurrent mutations pose a significant risk when users edit timelines while auto-save operations run in the background. If a mutation modifies the timeline while the save operation is serializing the project to disk, the resulting .palmier package could contain inconsistent state. Additionally, closing a project while mutations are active can lead to resource leaks or corrupted persistence.

The coordinator solves this by tracking three pieces of state:

  • savesInProgress: An integer counting active save operations.
  • activeMutations: An integer counting currently running mutations.
  • pendingMutations: A queue holding mutations that arrived while a save was ongoing.

Inside the ProjectPackageCoordinator

ProjectPackageCoordinator is annotated with @MainActor, ensuring all state changes happen on the UI thread while the heavy work inside mutations runs on background executors. This design prevents race conditions when the view-model in Sources/PalmierPro/Editor/ViewModel/EditorViewModel.swift forwards user actions to the coordinator.

Tracking Save and Mutation State

When a save begins, the coordinator calls saveStarted() to increment savesInProgress. Each mutation wraps its execution in beginMutation() and endMutation() calls that update activeMutations. If saveFinished(success:) reports failure, the coordinator cancels all pending mutations rather than applying them to a corrupted project.

Queuing Mutations During Active Saves

The performMutation(_:) method checks savesInProgress before executing work. If a save is active, the mutation stores its continuation in pendingMutations and suspends. Once saveFinished(success:) completes successfully, resumeIdleWaitersIfNeeded() resumes these queued mutations.

Implementing Safe Mutations

Editor commands use the coordinator to bracket mutation work. The following pattern from EditorViewModel.swift demonstrates how to add a clip while respecting save boundaries:

func addClip(_ clip: Clip) async throws {
    try projectPackageCoordinator.beginMutation()
    defer { projectPackageCoordinator.endMutation() }

    let result = try await projectPackageCoordinator.performMutation {
        timeline.add(clip)
        return true
    }

    if result { onProjectCheckpointRequired?() }
}

The beginMutation() call blocks if a save is in progress, while defer ensures endMutation() runs even if the mutation throws. The closure passed to performMutation executes on a background queue, keeping the main actor responsive.

Graceful Project Shutdown

Closing a project requires a two-phase handshake. First, beginClosing() sets isClosing to true, preventing new mutations from starting. Then waitUntilIdle() suspends the caller until both savesInProgress and activeMutations reach zero.

func closeProject() async {
    await projectPackageCoordinator.beginClosing()
    await projectPackageCoordinator.waitUntilIdle()

    videoEngine?.shutdown()
    projectPackageCoordinator.cancelClosing()
}

The waitUntilIdle() method uses a CheckedContinuation stored in idleWaiters. When the project becomes idle, resumeIdleWaitersIfNeeded() resumes all waiters, allowing resource teardown to proceed safely.

Error Handling and Cancellation Safety

The coordinator defends against unbalanced state updates. Mismatched calls to saveFinished or endMutation trigger assertionFailure, catching programming errors during development. For cancellation, performMutation(_:) wraps its continuation with withTaskCancellationHandler. If the caller cancels the task, cancelMutation(id:) removes the pending mutation from the queue and invokes its cancellation closure.

Failed saves also trigger cleanup. When saveFinished(success: false) executes, the coordinator discards all pendingMutations rather than applying them to the project state:

func saveProject() async {
    projectPackageCoordinator.saveStarted()
    defer { projectPackageCoordinator.saveFinished(success: true) }

    do {
        try await fileIO.writeProject(to: projectURL)
    } catch {
        projectPackageCoordinator.saveFinished(success: false)
        throw error
    }
}

Summary

  • ProjectPackageCoordinator in Sources/PalmierPro/Project/ProjectPackageCoordinator.swift centralizes concurrency control for the .palmier package format.
  • @MainActor isolation ensures thread-safe state updates while mutations run on background queues.
  • Three state trackers—savesInProgress, activeMutations, and pendingMutations—coordinate active work and queue deferred operations.
  • Closing sequence uses beginClosing() and waitUntilIdle() to guarantee resources are released only after all saves and mutations complete.
  • Cancellation handlers prevent memory leaks when async mutations are cancelled mid-flight.

Frequently Asked Questions

How does Palmier Pro prevent mutations from corrupting a save operation?

ProjectPackageCoordinator increments savesInProgress when saveStarted() is called. If performMutation(_:) detects a non-zero count, it stores the mutation in pendingMutations instead of executing it immediately. After saveFinished(success:) completes, pending mutations resume, ensuring no overlap between save I/O and state changes.

What happens to pending mutations if a project save fails?

When saveFinished(success: false) is invoked, the coordinator cancels all mutations held in pendingMutations by invoking their cancellation handlers. This prevents partial edits from being applied to a project that failed to persist, maintaining state consistency.

Why is ProjectPackageCoordinator marked with @MainActor?

The @MainActor annotation ensures that counter increments, continuation storage, and idle-waiter management happen on the main thread. While the coordinator schedules actual mutation work on background executors, the synchronization primitives themselves remain thread-safe by virtue of Swift's actor isolation, preventing data races in the concurrency state machine.

How does the close operation know when the project is truly idle?

The waitUntilIdle() method creates a CheckedContinuation and appends it to idleWaiters. Whenever endMutation() or saveFinished() reduces activeMutations or savesInProgress to zero, resumeIdleWaitersIfNeeded() resumes all stored continuations. This suspends the close routine until both counters reach zero, guaranteeing no background work is active during resource teardown.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →