DEV Community

Emad Beyrami
Emad Beyrami

Posted on

The Silent iOS 17 Crash Hiding in `@Dependency` and `withTaskGroup`

A subtle Swift Concurrency pitfall for developers using Point-Free's swift-dependencies.

I recently tracked down a crash that only affected users running iOS 17.

The stack trace pointed deep into Swift's concurrency runtime:

swift_task_reportIllegalTaskLocalBindingWithinWithTaskGroup
Enter fullscreen mode Exit fullscreen mode

None of my code interacted with task-local values directly. The only suspicious line was reading a @Dependency property from Point-Free's swift-dependencies library inside a withThrowingTaskGroup.

The exact same code worked flawlessly on iOS 18.

After digging into Swift's task-local behavior, the cause turned out to be surprisingly subtle.


TL;DR

Reading a @Dependency property inside a withTaskGroup or withThrowingTaskGroup body including inside an addTask closure's capture list can crash on iOS 17 with a fatal swift_task_reportIllegalTaskLocalBindingWithinWithTaskGroup trap.

The same code runs without issue on iOS 18+, making it easy to miss during development.

The fix is simple:

let client = self.client

try await withThrowingTaskGroup(of: ...) { group in
    group.addTask {
        try await client.doWork()
    }
}
Enter fullscreen mode Exit fullscreen mode

Resolve every @Dependency before entering the task group.


Background: How swift-dependencies Works

Point-Free's swift-dependencies provides dependency injection built on top of Swift's task-local values.

A dependency might look like this:

@Observable
final class MyViewModel {

    @ObservationIgnored
    @Dependency(\.myClient)
    private var myClient
}
Enter fullscreen mode Exit fullscreen mode

Although myClient looks like a stored property, accessing it isn't just a simple field lookup. The property wrapper resolves the dependency from the current task-local dependency context.

This design is what makes scoped dependency overrides possible:

withDependencies {
    $0.myClient = .mock
} operation: {
    // Uses the mocked dependency
}
Enter fullscreen mode Exit fullscreen mode

It's an elegant approach that integrates naturally with Swift Concurrency but it also means dependency access participates in the task-local system.


The Problem: Task-Local Values and Task Groups

Swift's structured concurrency places an important restriction on task-local values.

Child tasks inherit the task-local values that exist when they're created. Allowing new task-local bindings while a task group is actively spawning children would make inheritance ambiguous, so the runtime explicitly forbids it.

On iOS 17, violating this rule results in an immediate runtime trap:

Fatal error:
Attempted to write to a task local value from a task group.
Enter fullscreen mode Exit fullscreen mode

Internally, this comes from:

swift_task_reportIllegalTaskLocalBindingWithinWithTaskGroup
Enter fullscreen mode Exit fullscreen mode

How @Dependency Triggers It

The surprising part is that you're not explicitly writing to a task-local value.

You're simply reading a property:

self.connectionClient
Enter fullscreen mode Exit fullscreen mode

However, swift-dependencies ultimately resolves dependencies through Swift's task-local infrastructure. On iOS 17, accessing a @Dependency from inside a task group body can trigger the runtime's task-local binding restriction, causing an immediate fatal error.

The same code does not crash on iOS 18, making this especially difficult to discover if your development devices and simulators are already running the latest OS.


A Concrete Example

Consider a service that performs multiple checks concurrently:

@Observable
final class MyService {

    @ObservationIgnored
    @Dependency(\.connectionClient)
    private var connectionClient

    func checkConnections() async throws {

        try await withThrowingTaskGroup(of: Bool.self) { group in

            // ❌ Crashes on iOS 17
            group.addTask { [connectionClient] in
                try await connectionClient.isAvailable()
            }

            // ❌ Also crashes
            let connected = try await connectionClient.currentState()

            for try await result in group {
                ...
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Both accesses occur inside the task group body.

Even the capture list is evaluated while constructing the closure, so the dependency is still being resolved from within the task group before the child task begins executing.


Why the Capture List Doesn't Help

A common instinct is to write:

group.addTask { [connectionClient] in
    ...
}
Enter fullscreen mode Exit fullscreen mode

It looks like the dependency is copied before the task starts.

Unfortunately, that's not what happens.

Capture lists are evaluated when the closure is created, and the closure is created inside the task group body. Reading the dependency still occurs within the task group, triggering the same runtime restriction on iOS 17.


The Fix

Resolve every dependency before entering the task group.

func checkConnections() async throws {

    // ✅ Resolve outside the task group
    let connectionClient = self.connectionClient

    try await withThrowingTaskGroup(of: Bool.self) { group in

        group.addTask {
            try await connectionClient.isAvailable()
        }

        for try await result in group {
            ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The local constant contains the already-resolved dependency, so no task-local access occurs inside the task group.

The behavior is identical across iOS 17 and iOS 18.


Why This Bug Slips Into Production

1. It only affects iOS 17

Most developers build and test on the latest OS.

If your development devices, CI, and QA all run iOS 18, you'll never encounter this crash.

Meanwhile, users still running iOS 17 experience a deterministic runtime failure.


2. There are no compiler warnings

Swift provides no static analysis for this pattern.

The code compiles cleanly with zero warnings.

The failure is entirely runtime-enforced.


3. The stack trace isn't obvious

Instead of pointing directly at your code, the crash appears inside Swift's concurrency runtime:

Thread 1: EXC_BREAKPOINT (SIGTRAP)

swift_task_reportIllegalTaskLocalBindingWithinWithTaskGroup
swift_task_localValuePush
...
swift-dependencies
...
Your @Dependency getter
Enter fullscreen mode Exit fullscreen mode

Unless you already know how swift-dependencies works internally, it's difficult to connect the runtime trap back to a seemingly innocent property access.


4. @Dependency Looks Like a Stored Property

This declaration feels no different than any other property:

@Dependency(\.connectionClient)
private var connectionClient
Enter fullscreen mode Exit fullscreen mode

Nothing in the syntax suggests that accessing it interacts with Swift's task-local machinery.

It's natural to treat it like a stored property including capturing it inside a task group until iOS 17 reminds you otherwise.


Finding Affected Code

A quick way to identify potentially affected code is to search for files that contain both @Dependency and task groups:

grep -rl "withTaskGroup\|withThrowingTaskGroup" --include="*.swift" . | \
xargs grep -l "@Dependency"
Enter fullscreen mode Exit fullscreen mode

Then inspect each occurrence.

Safe

let client = self.client

withTaskGroup { group in
    group.addTask {
        try await client.doWork()
    }
}
Enter fullscreen mode Exit fullscreen mode

Unsafe

withTaskGroup { group in

    group.addTask { [client] in
        ...
    }

    let state = self.client
}
Enter fullscreen mode Exit fullscreen mode

If a @Dependency is accessed anywhere inside the task group body, move it outside.


Reproducing the Crash

To verify the issue yourself:

  1. Download an iOS 17 simulator runtime from Xcode → Settings → Platforms.
  2. Create an iOS 17 simulator.
  3. Run the affected code path.
  4. The crash is immediate and deterministic.

Testing on iOS 18 alone won't reproduce it.


Summary

Affected OS iOS 17.x
iOS 18+ No crash observed
Compiler warning None
Crash swift_task_reportIllegalTaskLocalBindingWithinWithTaskGroup
Root cause Accessing a @Dependency inside a task group interacts with task-local state in a way the iOS 17 runtime rejects
Fix Resolve every @Dependency before entering the task group

Final Takeaway

The lesson isn't just about swift-dependenciesit's about property wrappers built on top of Swift Concurrency.

A property may look like a simple stored value while performing considerably more work under the hood.

Whenever you combine swift-dependencies with withTaskGroup or withThrowingTaskGroup, make it a habit to resolve your dependencies before entering the task group:

let connectionClient = self.connectionClient
let analytics = self.analytics
let logger = self.logger

try await withThrowingTaskGroup(of: Void.self) { group in

    group.addTask {
        try await connectionClient.doWork()
    }

    group.addTask {
        await analytics.track()
    }

    group.addTask {
        logger.log("Finished")
    }
}
Enter fullscreen mode Exit fullscreen mode

It's a tiny change, but it avoids a crash that's easy to miss during development and difficult to diagnose once it reaches production.


Have you run into similar Swift Concurrency edge cases? I'd love to hear about them in the comments.

Top comments (0)