Skip to content

Commit 1c05ea6

Browse files
authored
Define commands as asynchronous and use Task for preview cancellation (#1049)
* Define commands/actions as asynchronous * Use `Task` for preview action cancellation * Move mutable logHandle variables in preview tests inside Tasks * Fix flaky test that sometimes hung due to blocking the async tasks * Fix memory leak where DiagnosticEngine retains itself * Fix PreviewServer retain cycle * Fix another flaky test that blocked the async tasks * Fix leak of NIO thread pools and event loops during tests
1 parent 4316b76 commit 1c05ea6

36 files changed

+460
-607
lines changed

Sources/SwiftDocC/Infrastructure/Diagnostics/DiagnosticEngine.swift

+3-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ public final class DiagnosticEngine {
2929
/// diagnostics with a severity up to and including `.information` will be printed.
3030
public var filterLevel: DiagnosticSeverity {
3131
didSet {
32-
self.filter = { $0.diagnostic.severity.rawValue <= self.filterLevel.rawValue }
32+
self.filter = { [filterLevel] in
33+
$0.diagnostic.severity.rawValue <= filterLevel.rawValue
34+
}
3335
}
3436
}
3537

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

+2
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
13311331
}
13321332

13331333
private func shouldContinueRegistration() throws {
1334+
try Task.checkCancellation()
13341335
guard isRegistrationEnabled.sync({ $0 }) else {
13351336
throw ContextError.registrationDisabled
13361337
}
@@ -1773,6 +1774,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
17731774
///
17741775
/// When given `false` the context will try to cancel as quick as possible
17751776
/// any ongoing bundle registrations.
1777+
@available(*, deprecated, message: "This deprecated API will be removed after 6.2 is released")
17761778
public func setRegistrationEnabled(_ value: Bool) {
17771779
isRegistrationEnabled.sync({ $0 = value })
17781780
}

Sources/SwiftDocC/Infrastructure/DocumentationConverter.swift

+4-35
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
5050
private var dataProvider: DocumentationWorkspaceDataProvider
5151

5252
/// An optional closure that sets up a context before the conversion begins.
53+
@available(*, deprecated, message: "This deprecated API will be removed after 6.2 is released")
5354
public var setupContext: ((inout DocumentationContext) -> Void)?
5455

5556
/// Conversion batches should be big enough to keep all cores busy but small enough not to keep
@@ -189,49 +190,17 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
189190
if let dataProvider = self.currentDataProvider {
190191
try workspace.unregisterProvider(dataProvider)
191192
}
192-
193-
// Do additional context setup.
194-
setupContext?(&context)
195193

196-
/*
197-
Asynchronously cancel registration if necessary.
198-
We spawn a timer that periodically checks `isCancelled` and if necessary
199-
disables registration in `DocumentationContext` as registration being
200-
the largest part of a documentation conversion.
201-
*/
202194
let context = self.context
203-
let isCancelled = self.isCancelled
204-
205-
// `true` if the `isCancelled` flag is set.
206-
func isConversionCancelled() -> Bool {
207-
return isCancelled?.sync({ $0 }) == true
208-
}
209-
210-
// Run a timer that synchronizes the cancelled state between the converter and the context directly.
211-
// We need a timer on a separate dispatch queue because `workspace.registerProvider()` blocks
212-
// the current thread until it loads all symbol graphs, markdown files, and builds the topic graph
213-
// so in order to be able to update the context cancellation flag we need to run on a different thread.
214-
var cancelTimerQueue: DispatchQueue? = DispatchQueue(label: "org.swift.docc.ConvertActionCancelTimer", qos: .unspecified, attributes: .concurrent)
215-
let cancelTimer = DispatchSource.makeTimerSource(queue: cancelTimerQueue)
216-
cancelTimer.schedule(deadline: .now(), repeating: .milliseconds(500), leeway: .milliseconds(50))
217-
cancelTimer.setEventHandler {
218-
if isConversionCancelled() {
219-
cancelTimer.cancel()
220-
context.setRegistrationEnabled(false)
221-
}
222-
}
223-
cancelTimer.resume()
224195

225196
// Start bundle registration
226197
try workspace.registerProvider(dataProvider, options: bundleDiscoveryOptions)
227198
self.currentDataProvider = dataProvider
228-
229-
// Bundle registration is finished - stop the timer and reset the context cancellation state.
230-
cancelTimer.cancel()
231-
cancelTimerQueue = nil
232-
context.setRegistrationEnabled(true)
233199

234200
// If cancelled, return early before we emit diagnostics.
201+
func isConversionCancelled() -> Bool {
202+
Task.isCancelled || isCancelled?.sync({ $0 }) == true
203+
}
235204
guard !isConversionCancelled() else { return ([], []) }
236205

237206
processingDurationMetric = benchmark(begin: Benchmark.Duration(id: "documentation-processing"))
+10-12
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -13,18 +13,16 @@ import SwiftDocC
1313

1414
/// An independent unit of work in the command-line workflow.
1515
///
16-
/// An `Action` represents a discrete documentation task; it takes options and inputs,
17-
/// performs its work, reports any problems it encounters, and outputs it generates.
18-
public protocol Action {
16+
/// An action represents a discrete documentation task; it takes options and inputs, performs its work, reports any problems it encounters, and outputs it generates.
17+
package protocol AsyncAction {
1918
/// Performs the action and returns an ``ActionResult``.
20-
mutating func perform(logHandle: LogHandle) throws -> ActionResult
19+
mutating func perform(logHandle: inout LogHandle) async throws -> ActionResult
2120
}
2221

23-
/// An action for which you can optionally customize the documentation context.
24-
public protocol RecreatingContext: Action {
25-
/// A closure that an action calls with the action's context for built documentation,
26-
/// before the action performs work.
27-
///
28-
/// Use this closure to set the action's context to a certain state before the action runs.
29-
var setupContext: ((inout DocumentationContext) -> Void)? { get set }
22+
package extension AsyncAction {
23+
mutating func perform(logHandle: LogHandle) async throws -> ActionResult {
24+
var logHandle = logHandle
25+
return try await perform(logHandle: &logHandle)
26+
}
3027
}
28+

Sources/SwiftDocCUtilities/Action/Actions/Action+MoveOutput.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import Foundation
1212
import SwiftDocC
1313

14-
extension Action {
14+
extension AsyncAction {
1515

1616
/// Creates a new unique directory, with an optional template, inside of specified container.
1717
/// - Parameters:

Sources/SwiftDocCUtilities/Action/Actions/Convert/ConvertAction.swift

+8-51
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import Foundation
1414
import SwiftDocC
1515

1616
/// An action that converts a source bundle into compiled documentation.
17-
public struct ConvertAction: Action, RecreatingContext {
17+
public struct ConvertAction: AsyncAction {
1818
enum Error: DescribedError {
1919
case doesNotContainBundle(url: URL)
2020
case cancelPending
@@ -59,12 +59,6 @@ public struct ConvertAction: Action, RecreatingContext {
5959
private var fileManager: FileManagerProtocol
6060
private let temporaryDirectory: URL
6161

62-
public var setupContext: ((inout DocumentationContext) -> Void)? {
63-
didSet {
64-
converter.setupContext = setupContext
65-
}
66-
}
67-
6862
var converter: DocumentationConverter
6963

7064
private var durationMetric: Benchmark.Duration?
@@ -239,52 +233,17 @@ public struct ConvertAction: Action, RecreatingContext {
239233
dataProvider: dataProvider,
240234
bundleDiscoveryOptions: bundleDiscoveryOptions,
241235
sourceRepository: sourceRepository,
242-
isCancelled: isCancelled,
243236
diagnosticEngine: self.diagnosticEngine,
244237
experimentalModifyCatalogWithGeneratedCuration: experimentalModifyCatalogWithGeneratedCuration
245238
)
246239
}
247-
248-
/// `true` if the convert action is cancelled.
249-
private let isCancelled = Synchronized<Bool>(false)
250240

251-
/// `true` if the convert action is currently running.
252-
let isPerforming = Synchronized<Bool>(false)
253-
254-
/// A block to execute when conversion has finished.
255-
/// It's used as a "future" for when the action is cancelled.
256-
var didPerformFuture: (()->Void)?
257-
258-
/// A block to execute when conversion has started.
259-
var willPerformFuture: (()->Void)?
260-
261-
/// Cancels the action.
262-
///
263-
/// The method blocks until the action has completed cancelling.
264-
mutating func cancel() throws {
265-
/// If the action is not running, there is nothing to cancel
266-
guard isPerforming.sync({ $0 }) == true else { return }
267-
268-
/// If the action is already cancelled throw `cancelPending`.
269-
if isCancelled.sync({ $0 }) == true {
270-
throw Error.cancelPending
271-
}
272-
273-
/// Set the cancelled flag.
274-
isCancelled.sync({ $0 = true })
275-
276-
/// Wait for the `perform(logHandle:)` method to call `didPerformFuture()`
277-
let waitGroup = DispatchGroup()
278-
waitGroup.enter()
279-
didPerformFuture = {
280-
waitGroup.leave()
281-
}
282-
waitGroup.wait()
283-
}
241+
/// A block of extra work that tests perform to affect the time it takes to convert documentation
242+
var _extraTestWork: (() async -> Void)?
284243

285244
/// Converts each eligible file from the source documentation bundle,
286245
/// saves the results in the given output alongside the template files.
287-
mutating public func perform(logHandle: LogHandle) throws -> ActionResult {
246+
public mutating func perform(logHandle: inout LogHandle) async throws -> ActionResult {
288247
// Add the default diagnostic console writer now that we know what log handle it should write to.
289248
if !diagnosticEngine.hasConsumer(matching: { $0 is DiagnosticConsoleWriter }) {
290249
diagnosticEngine.add(
@@ -302,15 +261,13 @@ public struct ConvertAction: Action, RecreatingContext {
302261
var postConversionProblems: [Problem] = []
303262
let totalTimeMetric = benchmark(begin: Benchmark.Duration(id: "convert-total-time"))
304263

305-
// While running this method keep the `isPerforming` flag up.
306-
isPerforming.sync({ $0 = true })
307-
willPerformFuture?()
308264
defer {
309-
didPerformFuture?()
310-
isPerforming.sync({ $0 = false })
311265
diagnosticEngine.flush()
312266
}
313267

268+
// Run any extra work that the test may have injected
269+
await _extraTestWork?()
270+
314271
let temporaryFolder = try createTempFolder(with: htmlTemplateDirectory)
315272

316273
defer {
@@ -451,7 +408,7 @@ public struct ConvertAction: Action, RecreatingContext {
451408
benchmark(end: totalTimeMetric)
452409

453410
if !didEncounterError {
454-
let coverageResults = try coverageAction.perform(logHandle: logHandle)
411+
let coverageResults = try await coverageAction.perform(logHandle: &logHandle)
455412
postConversionProblems.append(contentsOf: coverageResults.problems)
456413
}
457414

Sources/SwiftDocCUtilities/Action/Actions/CoverageAction.swift

+6-6
Original file line numberDiff line numberDiff line change
@@ -12,24 +12,24 @@ import Foundation
1212
import SwiftDocC
1313

1414
/// An action that creates documentation coverage info for a documentation bundle.
15-
public struct CoverageAction: Action {
16-
internal init(
15+
public struct CoverageAction: AsyncAction {
16+
init(
1717
documentationCoverageOptions: DocumentationCoverageOptions,
1818
workingDirectory: URL,
19-
fileManager: FileManagerProtocol) {
19+
fileManager: FileManagerProtocol
20+
) {
2021
self.documentationCoverageOptions = documentationCoverageOptions
2122
self.workingDirectory = workingDirectory
2223
self.fileManager = fileManager
2324
}
2425

2526
public let documentationCoverageOptions: DocumentationCoverageOptions
26-
internal let workingDirectory: URL
27+
let workingDirectory: URL
2728
private let fileManager: FileManagerProtocol
2829

29-
public mutating func perform(logHandle: LogHandle) throws -> ActionResult {
30+
public mutating func perform(logHandle: inout LogHandle) async throws -> ActionResult {
3031
switch documentationCoverageOptions.level {
3132
case .brief, .detailed:
32-
var logHandle = logHandle
3333
print(" --- Experimental coverage output enabled. ---", to: &logHandle)
3434

3535
let summaryString = try CoverageDataEntry.generateSummary(

Sources/SwiftDocCUtilities/Action/Actions/EmitGeneratedCurationAction.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Foundation
1212
import SwiftDocC
1313

1414
/// An action that emits documentation extension files that reflect the auto-generated curation.
15-
struct EmitGeneratedCurationAction: Action {
15+
struct EmitGeneratedCurationAction: AsyncAction {
1616
let catalogURL: URL?
1717
let additionalSymbolGraphDirectory: URL?
1818
let outputURL: URL
@@ -41,7 +41,7 @@ struct EmitGeneratedCurationAction: Action {
4141
self.fileManager = fileManager
4242
}
4343

44-
mutating func perform(logHandle: LogHandle) throws -> ActionResult {
44+
mutating func perform(logHandle: inout LogHandle) async throws -> ActionResult {
4545
let workspace = DocumentationWorkspace()
4646
let context = try DocumentationContext(dataProvider: workspace)
4747

Sources/SwiftDocCUtilities/Action/Actions/IndexAction.swift

+3-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import Foundation
1212
import SwiftDocC
1313

1414
/// An action that creates an index of a documentation bundle.
15-
public struct IndexAction: Action {
15+
public struct IndexAction: AsyncAction {
1616
let rootURL: URL
1717
let outputURL: URL
1818
let bundleIdentifier: String
@@ -22,8 +22,7 @@ public struct IndexAction: Action {
2222
private var dataProvider: LocalFileSystemDataProvider!
2323

2424
/// Initializes the action with the given validated options, creates or uses the given action workspace & context.
25-
public init(documentationBundleURL: URL, outputURL: URL, bundleIdentifier: String, diagnosticEngine: DiagnosticEngine = .init()) throws
26-
{
25+
public init(documentationBundleURL: URL, outputURL: URL, bundleIdentifier: String, diagnosticEngine: DiagnosticEngine = .init()) throws {
2726
// Initialize the action context.
2827
self.rootURL = documentationBundleURL
2928
self.outputURL = outputURL
@@ -35,7 +34,7 @@ public struct IndexAction: Action {
3534

3635
/// Converts each eligible file from the source documentation bundle,
3736
/// saves the results in the given output alongside the template files.
38-
mutating public func perform(logHandle: LogHandle) throws -> ActionResult {
37+
mutating public func perform(logHandle: inout LogHandle) async throws -> ActionResult {
3938
let problems = try buildIndex()
4039
diagnosticEngine.emit(problems)
4140

Sources/SwiftDocCUtilities/Action/Actions/Init/InitAction.swift

+3-4
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import Foundation
1212
import SwiftDocC
1313

1414
/// An action that generates a documentation catalog from a template seed.
15-
public struct InitAction: Action {
16-
15+
public struct InitAction: AsyncAction {
16+
1717
enum Error: DescribedError {
1818
case catalogAlreadyExists
1919
var errorDescription: String {
@@ -72,8 +72,7 @@ public struct InitAction: Action {
7272
/// Generates a documentation catalog from a catalog template.
7373
///
7474
/// - Parameter logHandle: The file handle that the convert and preview actions will print debug messages to.
75-
public mutating func perform(logHandle: SwiftDocC.LogHandle) throws -> ActionResult {
76-
75+
public mutating func perform(logHandle: inout LogHandle) async throws -> ActionResult {
7776
let diagnosticEngine: DiagnosticEngine = DiagnosticEngine(treatWarningsAsErrors: false)
7877
diagnosticEngine.filterLevel = .warning
7978
diagnosticEngine.add(DiagnosticConsoleWriter(formattingOptions: []))

Sources/SwiftDocCUtilities/Action/Actions/Merge/MergeAction.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import SwiftDocC
1313
import Markdown
1414

1515
/// An action that merges a list of documentation archives into a combined archive.
16-
struct MergeAction: Action {
16+
struct MergeAction: AsyncAction {
1717
var archives: [URL]
1818
var landingPageInfo: LandingPageInfo
1919
var outputURL: URL
@@ -33,7 +33,7 @@ struct MergeAction: Action {
3333
}
3434
}
3535

36-
mutating func perform(logHandle: LogHandle) throws -> ActionResult {
36+
mutating func perform(logHandle: inout LogHandle) async throws -> ActionResult {
3737
guard let firstArchive = archives.first else {
3838
// A validation warning should have already been raised in `Docc/Merge/InputAndOutputOptions/validate()`.
3939
return ActionResult(didEncounterError: true, outputs: [])

0 commit comments

Comments
 (0)