Skip to content

Commit b65809b

Browse files
fix(os): avoid recursive signal cancellation lock
1 parent fd4a84a commit b65809b

2 files changed

Lines changed: 55 additions & 11 deletions

File tree

‎Sources/ContainerizationOS/AsyncSignalHandler.swift‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,19 +43,22 @@ public final class AsyncSignalHandler: Sendable {
4343
/// Cancel every AsyncStream of signals, as well as the underlying
4444
/// DispatchSignalSource's for each registered signal.
4545
public func cancel() {
46-
self.state.withLock {
47-
if $0.conts.isEmpty {
48-
return
49-
}
50-
51-
for cont in $0.conts {
52-
cont.finish()
53-
}
54-
for source in $0.sources {
55-
source.cancel()
56-
}
46+
let resources = self.state.withLock {
47+
let continuations = $0.conts
48+
let sources = $0.sources
5749
$0.conts.removeAll()
5850
$0.sources.removeAll()
51+
return (continuations, sources)
52+
}
53+
54+
// Finishing a continuation invokes its onTermination callback, which
55+
// calls cancel() again. Release the state lock before doing so to keep
56+
// cancellation idempotent without recursively acquiring the mutex.
57+
for continuation in resources.0 {
58+
continuation.finish()
59+
}
60+
for source in resources.1 {
61+
source.cancel()
5962
}
6063
}
6164

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the Containerization project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import Testing
18+
19+
@testable import ContainerizationOS
20+
21+
@Suite("Async signal handler tests")
22+
struct AsyncSignalHandlerTests {
23+
@Test
24+
func cancelFinishesStreamsWithoutRecursivelyLocking() async {
25+
// Stream cancellation exercises the recursive callback without installing
26+
// a process-wide signal disposition in the shared test runner.
27+
let handler = AsyncSignalHandler.create(notify: [])
28+
let firstStream = handler.signals
29+
let secondStream = handler.signals
30+
31+
handler.cancel()
32+
33+
var firstIterator = firstStream.makeAsyncIterator()
34+
var secondIterator = secondStream.makeAsyncIterator()
35+
#expect(await firstIterator.next() == nil)
36+
#expect(await secondIterator.next() == nil)
37+
38+
// Cancellation remains safe after all resources have been drained.
39+
handler.cancel()
40+
}
41+
}

0 commit comments

Comments
 (0)