Search Docs
Loading...
Skip to content

Cancel Operations

Stop an export, a load or a save that your app no longer needs, or give it a deadline, by cancelling the Swift task that runs it.

8 mins
estimated time
GitHub

Exports, loads, downloads and saves can keep the engine busy long after you start them. When a user closes a screen or picks another file, your app no longer needs that work. You cancel a call by cancelling the Swift task that awaits it. The engine then ends the call, stops its work and frees what it holds.

This guide covers how to cancel a call, how to start the next call and which calls you can cancel. It also shows how to give a call a deadline, how to stop a video export from its progress and what the engine keeps after a cancel.

Cancelling a Call#

Start the call in a Task and keep the task. Call cancel() on the task to stop the call. Here we start a PNG export of a page that holds an image block, and cancel the export at once.

let exportTask = Task {
try await engine.block.export(page, mimeType: .png)
}
exportTask.cancel()
do {
let blob = try await exportTask.value
print("The export ended before the cancel with \(blob.count) bytes")
} catch is CancellationError {
print("The export stopped")
}

The task throws after the cancel, so the code after the call does not run. A cancelled call throws CancellationError, and the loop over an export stream, such as a video or audio export, ends without an error. The example catches CancellationError and lets every other error through.

The task in the example has not started when cancel() runs. It still starts the export, which then ends at once. Check Task.isCancelled before a call when no work must start. A task that has already finished ignores the cancel, and value returns its result.

You can call cancel() from any thread, because the cancel reaches the engine on the main actor by itself. A SwiftUI .task modifier cancels its task when the view goes away, so the calls that the task awaits stop with it.

Starting the Next Call#

After the task throws, the engine is free for the next export or load. Start it after the catch or in a new task. Here we export the same page again, this time without a cancel.

let blob = try await engine.block.export(page, mimeType: .png)
print("The next export made a PNG file of \(blob.count) bytes")

Each image or PDF export runs on a worker engine of its own. The cancel releases the worker of the cancelled export, and the next export starts a new worker.

Calls That Can Be Cancelled#

Each of these calls stops its engine work when you cancel the task that awaits it.

  • Exports: engine.block.export(_:mimeType:options:onPreExport:uriResolver:onProgress:) for images and PDF, engine.block.export(_:to:mimeType:options:onPreExport:uriResolver:onProgress:) for a PDF file, engine.block.export(_:mimeType:options:onPreExport:uriResolver:onProgress:onData:) for PDF chunks, engine.block.exportWithColorMask(_:mimeType:maskColorR:maskColorG:maskColorB:options:onPreExport:uriResolver:), engine.block.exportVideo(_:mimeType:options:onPreExport:uriResolver:), engine.block.exportAudio(_:mimeType:options:), and their overloads that export several blocks
  • Scene and block loads: engine.scene.load(from:overrideEditorConfig:waitForResources:onProgress:) with a URL, engine.scene.load(from:overrideEditorConfig:waitForResources:) with a string and waitForResources, engine.scene.loadArchive(from:overrideEditorConfig:waitForResources:onProgress:), engine.scene.applyTemplate(from:) with a URL, engine.block.load(from:), engine.block.loadArchive(from:)
  • New scenes from media: engine.scene.create(fromImage:dpi:pixelScaleFactor:sceneLayout:), engine.scene.create(fromVideo:)
  • Downloads for a block or an asset: engine.block.createCaptionsFromURI(_:), engine.block.addImageFileURIToSourceSet(_:property:uri:), engine.block.addVideoFileURIToSourceSet(_:property:uri:), engine.block.forceLoadAVResource(_:), engine.block.forceLoadResources(_:), engine.asset.addLocalAssetSourceFromJSON(_:matcher:) with a URL, engine.asset.apply(sourceID:assetResult:), engine.asset.apply(sourceID:assetResult:placement:), engine.asset.defaultApplyAsset(assetResult:), engine.asset.defaultApplyAsset(assetResult:placement:)
  • Lookups: engine.editor.getMIMEType(url:), engine.editor.getFontMetrics(fontFileURI:), engine.block.getDominantColors(_:options:)
  • Color profiles: engine.scene.setCMYKProfile(from:) with a URL, engine.editor.loadCMYKProfile()
  • Saves: engine.scene.saveToArchive(options:), engine.block.saveToArchive(blocks:), and the saves to a string engine.scene.saveToString(allowedResourceSchemes:onDisallowedResourceScheme:), engine.scene.saveToString(options:) and engine.block.saveToString(blocks:allowedResourceSchemes:onDisallowedResourceScheme:) while they wait for onDisallowedResourceScheme

Giving a Call a Deadline#

No engine call takes a deadline. Run the call and a Task.sleep(nanoseconds:) in a task group, take the result that comes first, and cancel the other task. Here we give the load of the scene file at sceneURL ten seconds.

let loadedInTime = try await withThrowingTaskGroup(of: Bool.self) { group in
group.addTask {
try await engine.scene.load(from: sceneURL)
return true
}
group.addTask {
try await Task.sleep(nanoseconds: 10 * NSEC_PER_SEC)
return false
}
let first = try await group.next() ?? false
group.cancelAll()
return first
}
print(loadedInTime ? "The scene loaded in time" : "The scene did not load within 10 seconds")

When the sleep ends first, cancelAll() cancels the load, and the current scene stays. When the load ends first, cancelAll() ends the sleep. A load that fails before the deadline throws its error from group.next(), and the task group throws it again. From iOS 16, Mac Catalyst 16 and macOS 13 on, Task.sleep(for: .seconds(10)) gives the same deadline.

Cancelling a Video Export From Its Progress#

engine.block.exportVideo(_:mimeType:options:onPreExport:uriResolver:) returns an AsyncThrowingStream of VideoExport events. Leave the loop over the stream to stop the export. A return or a break ends the iteration, and the export stops and releases its worker engine. Here we stop a video export that has rendered less than half of its frames after one minute.

@MainActor
private func exportVideoUnlessSlow(engine: Engine, page: DesignBlockID) async throws -> Blob? {
let startedAt = Date()
for try await event in try await engine.block.exportVideo(page, mimeType: .mp4) {
switch event {
case let .progress(renderedFrames, _, totalFrames):
if Date().timeIntervalSince(startedAt) > 60, renderedFrames < totalFrames / 2 {
return nil
}
case let .finished(video: video):
return video
}
}
return nil
}

A cancel of the task that runs the loop stops the export too. The loop then ends without an error, so the function returns nil. The first reports also count the start-up time of the export, so a projection of the total time from them would stop fast exports too. engine.block.exportAudio(_:mimeType:options:) returns a stream of AudioExport events, and leaving its loop stops it in the same way.

What a Cancel Stops and What It Keeps#

A cancel stops the work of the call, but the engine keeps what other blocks and calls still need:

  • A video export stops at once and releases its worker engine with its memory.
  • An image or PDF export releases its worker engine.
  • An audio export that mixes a page stops at once and drops its mix.
  • An audio export that copies the audio of one block without the mixer finishes the copy in the background and drops it.
  • A load stops its download, unless a block or another call needs the same file. The engine reads every video fill at each update, so the download of a video fill goes on.
  • load(from:) and loadArchive(from:) keep the current scene. With waitForResources the new scene replaces it before the wait for its resources, so a cancel during that wait keeps the new scene. load(from:) with a string and waitForResources loads the scene at once, so a cancel ends only the wait.
  • applyTemplate(from:), create(fromImage:dpi:pixelScaleFactor:sceneLayout:) and create(fromVideo:) keep the current scene.
  • loadCMYKProfile() ends only the wait, because the document keeps its profile loaded.
  • A cancelled setCMYKProfile(from:) gives back its claim, so an assignment that it replaced still takes effect.
  • An answer of onDisallowedResourceScheme after the cancel changes nothing.
  • apply(sourceID:assetResult:) stops the download that the default apply waits for, so it creates no block. An asset source with its own apply function keeps running in your code. A block that it creates stays, and the call does not return it.
  • getDominantColors(_:options:) waits while a resource of the block loads, so a cancel ends that wait.

Troubleshooting#

  • The task returns a result although you cancelled it: The call ended before the cancel. A task that has finished ignores cancel().
  • The call starts although you cancelled the task first: A cancelled task still starts its call, which then ends at once. Check Task.isCancelled before the call.
  • The download of a cancelled load goes on: Another block or another call needs the same file.
  • A video export goes on after you release the engine: The export runs on a worker engine of its own. Cancel the task that runs the loop over the stream, or leave the loop.

API Reference#

These are the methods this guide calls. The section Calls That Can Be Cancelled lists every method that stops when you cancel its task.

Method Category Purpose
engine.block.export(_:mimeType:options:onPreExport:uriResolver:onProgress:) Block Export a block as an image or a PDF
engine.block.exportVideo(_:mimeType:options:onPreExport:uriResolver:) Block Export a page as a video
engine.scene.load(from:overrideEditorConfig:waitForResources:onProgress:) Scene Load a scene or an archive from a URL

Next Steps#