A single request passes through three asynchronous stages—downloading, parsing, and writing to disk—yet its identifier appears only intermittently in the logs. Replace the identifier with a global variable, and parallel tests start mixing up values. When diagnosing this on a cloud Mac, do not start by adjusting the thread count. What needs verifying is which tasks receive the context and where its propagation stops.
This article uses a standalone Swift package to test TaskLocal, without connecting to application services or relying on a simulator. It checks task semantics, not thread affinity: switching execution threads within the same task should not cause task-local values to disappear.
Define the propagation contract first
Define the request identifier as an optional string with a default value of nil. When there is no request context, leaving the default in place makes missing propagation easier to spot than automatically generating a plausible-looking identifier.
The contract has five rules: the value is readable within its scope; a nested scope can temporarily override it; leaving that scope restores the outer value; ordinary child tasks and task-group children inherit the context present when they are created; and detached tasks do not inherit it by default. Throwing an error must also restore the outer state.
TaskLocal is neither thread-local storage nor a safe wrapper around a global variable. It propagates task context, not the last value written on a particular thread.
Values should also be as small and immutable as possible. This article passes only strings, not mutable dictionaries, session objects, or database connections. Putting a reference-type object in TaskLocal does not automatically make that object concurrency-safe.
Create a standalone test package
Run the following commands in a normal working directory on the cloud Mac. Record the toolchain version first so that later failure reports can distinguish code changes from compiler changes.
mkdir TraceScopeLab
cd TraceScopeLab
swift --version
swift package init --type library --name TraceScopeLab
mkdir -p Tests/TraceScopeLabTests
Create ContextTests.swift in the test directory and add the complete code below. You can keep the default generated test file; the two are independent. The example uses the testing framework supplied with the toolchain and requires no additional dependencies.
import XCTest
private enum TraceContext {
@TaskLocal static var requestID: String?
}
private enum ProbeError: Error {
case expected
}
final class ContextTests: XCTestCase {
func testNestedScopeRestoresAfterThrow() {
XCTAssertNil(TraceContext.requestID)
TraceContext.$requestID.withValue("outer") {
XCTAssertEqual(TraceContext.requestID, "outer")
XCTAssertThrowsError(
try TraceContext.$requestID.withValue("inner") {
XCTAssertEqual(TraceContext.requestID, "inner")
throw ProbeError.expected
}
)
XCTAssertEqual(TraceContext.requestID, "outer")
}
XCTAssertNil(TraceContext.requestID)
}
func testTaskBoundaries() async {
let observed = await TraceContext.$requestID
.withValue("request-a") {
let child = Task {
TraceContext.requestID
}
let detached = Task.detached {
TraceContext.requestID
}
let inherited = await child.value
let absent = await detached.value
let groupValue = await withTaskGroup(
of: String?.self,
returning: String?.self
) { group in
group.addTask {
TraceContext.requestID
}
var result: String?
for await value in group {
result = value
}
return result
}
return (inherited, absent, groupValue)
}
XCTAssertEqual(observed.0, "request-a")
XCTAssertNil(observed.1)
XCTAssertEqual(observed.2, "request-a")
XCTAssertNil(TraceContext.requestID)
}
}
Here, results are retrieved with await before being passed to assertions, rather than placing asynchronous reads inside synchronous assertion expressions. Every task created is awaited to completion, so no work remains running in the background after the test ends.
Choose context explicitly for detached tasks
Task {} inherits task-local values, but it creates an unstructured task whose lifetime is not automatically tied to the scope that created it. Inheriting context and automatically waiting for completion are separate concerns.
A previously created task may therefore retain the original identifier after the parent scope exits. This is not a restoration failure. Prefer structured concurrency for work within a request. When an unstructured task is genuinely necessary, retain its handle and explicitly wait for it or cancel it.
For independent background work, having no request context by default makes the boundary clearer. If tracing does require correlation, first capture the fields that are allowed to propagate, then bind them inside the detached task:
let capturedID = TraceContext.requestID
let worker = Task.detached {
TraceContext.$requestID.withValue(capturedID) {
TraceContext.requestID
}
}
let observedID = await worker.value
Place this code in an asynchronous test method in the test file above. Capture happens on the creating side; binding happens in the new task. Do not wait until you are inside the detached task to read the old context. Unrelated work, such as scheduled cleanup, should keep nil so that an entire batch of logs is not incorrectly attributed to a single user request.
Add an isolation check for interleaved execution
Passing a single-request test is not enough. Add an asynchronous test method that creates two tasks concurrently, binding request-a in one and request-b in the other. Inside each binding scope, call await Task.yield(), then return the current identifier after execution resumes. Finally, assert each result individually.
Do not require the two tasks to finish in a fixed order. Task.yield() only offers a hint to yield execution; it does not guarantee an interleaving order. The acceptance criterion should be “each task has the correct value, regardless of which finishes first,” not “the first log entry must come from A.”
After both asynchronous scopes have ended, also check that the default value is still nil. Do not collect results in a shared array or coordinate execution order with a global counter, as the test itself could introduce a race.
Turn failures into actionable reports
Run the targeted tests, then run them again with parallel execution enabled. The second run does not replace explicit isolation assertions, but it can help expose shared-state contamination between tests.
swift test --filter ContextTests
swift test --parallel --filter ContextTests
If an ordinary child task reads nil, first check whether it was actually created inside withValue. If a detached task reads an identifier, look for an explicit binding or an additional wrapper layer. If leaving a nested scope does not restore the value, confirm that the read actually accesses TaskLocal rather than a global property with the same name.
A failure report only needs the toolchain version, test name, expected value, and actual value. There is no need to copy real request identifiers from production logs into test fixtures.
Finally, keep tracing separate from authorization: context can help connect the steps of an operation, but it does not prove that the caller is allowed to perform it. Propagate small values across explicit task boundaries, and validate permissions as explicit inputs. That is what keeps the conclusions of parallel tests reliable over time.
Frequently asked questions
Does Task.detached inherit TaskLocal values?
No. Read the required identifier in the creating task, then explicitly establish a new withValue scope inside the detached task.
Can TaskLocal be the sole basis for authorization?
No. It is useful for tracing context, not proof of identity. Pass and validate authorization information explicitly for sensitive operations.
Hand off your next build to a dedicated Cloud Mac
MacFTP provides dedicated Apple Silicon physical machines running native macOS, with daily, weekly, monthly, and quarterly rental options. Check the configuration, then connect your existing toolchain to a node. Availability is confirmed in real time in the console.