Skip to content

Commit 0e608b5

Browse files
bok-gqbit
andcommitted
Added failing test to reproduce deadlock
Co-Authored-By: Galen Quinn <gqbit@users.noreply.github.com>
1 parent c50af49 commit 0e608b5

1 file changed

Lines changed: 71 additions & 0 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Vexil open source project
4+
//
5+
// Copyright (c) 2026 Unsigned Apps and the open source contributors.
6+
// Licensed under the MIT license
7+
//
8+
// See LICENSE for license information
9+
//
10+
// SPDX-License-Identifier: MIT
11+
//
12+
//===----------------------------------------------------------------------===//
13+
14+
import Foundation
15+
import Testing
16+
@testable import Vexil
17+
18+
#if os(macOS)
19+
20+
struct AsyncCurrentValueTests {
21+
/// Regression test for a lock-order inversion deadlock between two threads:
22+
///
23+
/// Thread A (update): holds allocation.mutex → calls continuation.resume() inside didSet
24+
/// → resume() internally acquires the Swift task status lock
25+
///
26+
/// Thread B (cancel): acquires the Swift task status lock → fires onCancel: handler
27+
/// → onCancel: calls allocation.mutex.withLock → waits for mutex
28+
///
29+
/// Thread A holds mutex, wants task-lock.
30+
/// Thread B holds task-lock, wants mutex.
31+
/// → deadlock.
32+
///
33+
/// If the bug is present, the test will time out after 1 minute.
34+
@Test(.timeLimit(.minutes(1)))
35+
func `AsyncCurrentValue does not deadlock when cancellation races a concurrent update`() async {
36+
for _ in 0 ..< 100_000 {
37+
let currentValue = AsyncCurrentValue<FlagChange>(.all)
38+
39+
// This task will:
40+
// 1. Call next() once — returns the initial value immediately because
41+
// iterator.generation (0) < state.generation (1).
42+
// 2. Call next() again — suspends, storing its continuation in
43+
// pendingContinuations. This is the continuation that gets raced.
44+
let consumingTask = Task {
45+
var iterator = currentValue.stream.makeAsyncIterator()
46+
_ = await iterator.next(isolation: nil)
47+
_ = await iterator.next(isolation: nil)
48+
}
49+
50+
// Yield to give the consuming task time to advance past the first next()
51+
// and park its continuation inside pendingContinuations on the second call.
52+
await Task.yield()
53+
await Task.yield()
54+
await Task.yield()
55+
56+
// Fire the two racing operations:
57+
// updateTask — acquires allocation.mutex, sets wrappedValue, didSet calls
58+
// continuation.resume() while still inside withLock.
59+
// cancel — acquires the task status lock, fires onCancel:, which calls
60+
// allocation.mutex.withLock.
61+
let updateTask = Task.detached { currentValue.update { _ in } }
62+
consumingTask.cancel()
63+
64+
await updateTask.value
65+
await consumingTask.value
66+
}
67+
}
68+
69+
}
70+
71+
#endif

0 commit comments

Comments
 (0)