home / skills / derklinke / codex-config / ios-assume-isolated

ios-assume-isolated skill

/skills/ios-assume-isolated

This skill helps optimize testing and performance by applying assumeIsolated patterns to synchronously access actor-isolated state in Swift.

npx playbooks add skill derklinke/codex-config --skill ios-assume-isolated

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
5.7 KB
---
name: assume-isolated
description: Use when needing synchronous actor access in tests, legacy delegate callbacks, or performance-critical code. Covers MainActor.assumeIsolated, @preconcurrency protocol conformances, crash behavior, Task vs assumeIsolated.
skill_type: discipline
version: 1.0.0
---

# assumeIsolated — Synchronous Actor Access

Synchronously access actor-isolated state when you **know** you're already on the correct isolation domain.

## When to Use

✅ **Use when:**

- Testing MainActor code synchronously (avoiding Task overhead)
- Legacy delegate callbacks documented to run on main thread
- Performance-critical code avoiding async hop overhead
- Protocol conformances where callbacks are guaranteed on specific actor

❌ **Don't use when:**

- Uncertain about current isolation (use `await` instead)
- Already in async context (you have isolation)
- Cross-actor calls needed (use async)
- Callback origin is unknown or untrusted

## API Reference

### MainActor.assumeIsolated

```swift
static func assumeIsolated<T>(
    _ operation: @MainActor () throws -> T,
    file: StaticString = #fileID,
    line: UInt = #line
) rethrows -> T where T: Sendable
```

**Behavior**: Executes synchronously. **Crashes** if not on MainActor's serial executor.

### Custom Actor assumeIsolated

```swift
func assumeIsolated<T>(
    _ operation: (isolated Self) throws -> T,
    file: StaticString = #fileID,
    line: UInt = #line
) rethrows -> T where T: Sendable
```

## Task vs assumeIsolated

| Aspect | `Task { @MainActor in }` | `MainActor.assumeIsolated` |
|--------|--------------------------|---------------------------|
| Timing | Deferred (next run loop) | Synchronous (inline) |
| Async support | Yes (can await) | No (sync only) |
| Context | From any context | Must be sync function |
| Failure mode | Runs anyway | **Crashes** if wrong isolation |
| Use case | Start async work | Verify + access isolated state |

## Patterns

### Pattern 1: Testing MainActor Code

```swift
@Test func viewModelUpdates() {
    MainActor.assumeIsolated {
        let vm = ViewModel()
        vm.update()
        #expect(vm.state == .updated)
    }
}
```

### Pattern 2: Legacy Delegate Callbacks

From WWDC 2024-10169 — When documentation guarantees main thread delivery:

```swift
@MainActor
class LocationDelegate: NSObject, CLLocationManagerDelegate {
    var location: CLLocation?

    // CLLocationManager created on main thread delivers callbacks on main thread
    nonisolated func locationManager(
        _ manager: CLLocationManager,
        didUpdateLocations locations: [CLLocation]
    ) {
        MainActor.assumeIsolated {
            self.location = locations.last
        }
    }
}
```

### Pattern 3: @preconcurrency Shorthand

`@preconcurrency` is equivalent shorthand — wraps in `assumeIsolated` automatically:

```swift
// ❌ Manual approach (verbose)
extension MyClass: SomeDelegate {
    nonisolated func callback() {
        MainActor.assumeIsolated {
            self.updateUI()
        }
    }
}

// ✅ Using @preconcurrency (equivalent, cleaner)
extension MyClass: @preconcurrency SomeDelegate {
    func callback() {
        self.updateUI()  // Compiler wraps in assumeIsolated
    }
}
```

**When protocol adds isolation**: `@preconcurrency` becomes unnecessary and compiler warns.

### Pattern 4: Thread Check Before assumeIsolated

When caller context is unknown (e.g., library code):

```swift
func getView() -> UIView {
    if Thread.isMainThread {
        return createHostingViewOnMain()
    } else {
        return DispatchQueue.main.sync {
            createHostingViewOnMain()
        }
    }
}

private func createHostingViewOnMain() -> UIView {
    MainActor.assumeIsolated {
        let hosting = UIHostingController(rootView: MyView())
        return hosting.view
    }
}
```

### Pattern 5: Custom Actor Access

```swift
actor DataStore {
    var cache: [String: Data] = [:]

    nonisolated func synchronousRead(key: String) -> Data? {
        // Only safe if called from DataStore's executor
        assumeIsolated { isolated in
            isolated.cache[key]
        }
    }
}
```

## Common Mistakes

### Mistake 1: Silencing Compiler Errors

```swift
// ❌ DANGEROUS: Using assumeIsolated to silence warnings
func unknownContext() {
    MainActor.assumeIsolated {
        updateUI()  // Crashes if not actually on main actor!
    }
}

// ✅ When uncertain, use proper async
func unknownContext() async {
    await MainActor.run {
        updateUI()
    }
}
```

### Mistake 2: Assuming GCD Main Queue == MainActor

They're **usually** the same, but not guaranteed. Check documentation or use async.

### Mistake 3: Using in Async Context

```swift
// ❌ Unnecessary — you already have isolation
@MainActor
func updateState() async {
    MainActor.assumeIsolated {  // Pointless
        self.state = .ready
    }
}

// ✅ Direct access
@MainActor
func updateState() async {
    self.state = .ready
}
```

## When @preconcurrency Becomes Unnecessary

If the protocol later adds MainActor isolation:

```swift
// Library update:
@MainActor
protocol CaffeineThresholdDelegate: AnyObject {
    func caffeineLevel(at level: Double)
}

// Your code — @preconcurrency now warns:
// "@preconcurrency attribute on conformance has no effect"
extension Recaffeinater: CaffeineThresholdDelegate {
    func caffeineLevel(at level: Double) {
        // Direct access, no wrapper needed
    }
}
```

## Crash Behavior

Per Apple documentation:
> "If the current context is not running on the actor's serial executor... this method will crash with a fatal error."

**Trapping is intentional**: Better to crash than corrupt user data with a race condition.

## Resources

**WWDC**: 2024-10169

**Docs**: /swift/mainactor/assumeisolated, /swift/actor/assumeisolated

**Skills**: swift-concurrency-expert

Overview

This skill documents when and how to use assume-isolated patterns for synchronous actor access. It focuses on MainActor.assumeIsolated, custom actor assumeIsolated, and the @preconcurrency shorthand for cases where you already know you are on the correct executor. The guidance highlights tradeoffs, crash behavior, and practical patterns for tests, legacy callbacks, and performance-sensitive code.

How this skill works

assumeIsolated executes a synchronous closure on an actor’s executor and will crash if the current thread is not running on that actor’s serial executor. MainActor.assumeIsolated runs inline on the main actor and is useful when you must avoid async hops; custom actor assumeIsolated accepts an isolated Self parameter for the same pattern. @preconcurrency is a compiler shorthand that wraps nonisolated conformances in assumeIsolated automatically when appropriate.

When to use it

  • Synchronous unit tests for MainActor-bound code to avoid Task overhead and timing complexity.
  • Legacy delegate callbacks that documentation guarantees are delivered on the main thread.
  • Performance-critical hot paths where avoiding async transitions is required.
  • Protocol conformances where callers guarantee actor isolation and you need sync access.
  • Library internals where you have explicit thread checks before assuming isolation.

Best practices

  • Only use assumeIsolated when you are certain of current isolation; prefer await/Task.run otherwise.
  • Accept that assumeIsolated will crash on wrong executor—use it to fail fast rather than risk races.
  • Prefer @preconcurrency for cleaner nonisolated conformances instead of manually wrapping calls.
  • When caller origin is unknown, check Thread.isMainThread or dispatch sync to main before assumeIsolated.
  • Do not use assumeIsolated inside functions that are already isolated; it is redundant and pointless.

Example use cases

  • Write a synchronous test that constructs a ViewModel and asserts MainActor-updated state inside MainActor.assumeIsolated.
  • Update UI state from a legacy delegate nonisolated callback by wrapping the mutation in MainActor.assumeIsolated.
  • Implement a synchronous read method on an actor that is only ever called from the actor’s executor using assumeIsolated.
  • Replace manual wrapper code with @preconcurrency conformance for cleaner delegate implementations.
  • Guard library code that may be called from background threads by checking thread and dispatching to main, then using assumeIsolated for internal consistency.

FAQ

What happens if I call assumeIsolated from the wrong thread?

The runtime will trap with a fatal error; this is deliberate to avoid data races and silent corruption.

When should I prefer Task { @MainActor in } over MainActor.assumeIsolated?

Use Task when you are in an asynchronous context, need await support, or when deferring work to the next run loop is acceptable; use assumeIsolated only when synchronous inline access is required.