Skip to content

Commit 90d64a8

Browse files
authored
Fix occasional flakiness in preview action cancellation test (#1070)
* Fix data race in `LogHandle.LogStorage` (used in tests to verify log output) * Fix start/stop data race in DirectoryMonitor rdar://137751315 * Fix possible data race to `PreviewAction.logHandle` * Fix possible data race in loading symbol graphs for different platforms * Remove unnecessary restart of the directory monitor * Use shorter temporary unix domain socket path in tests * Add extra test assertion if we ever need to debug more flakiness in this test * Reenable `testCancelsConversion` * Add back reimplementation of `testWatchRecoversAfterConversionErrors`
1 parent 000acac commit 90d64a8

File tree

6 files changed

+228
-54
lines changed

6 files changed

+228
-54
lines changed

Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ struct SymbolGraphLoader {
6161

6262
let loadGraphAtURL: (URL) -> Void = { [dataLoader, bundle] symbolGraphURL in
6363
// Bail out in case a symbol graph has already errored
64-
guard loadError == nil else { return }
64+
guard loadingLock.sync({ loadError == nil }) else { return }
6565

6666
do {
6767
// Load and decode a single symbol graph file

Sources/SwiftDocC/Utility/LogHandle.swift

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,10 @@ public enum LogHandle: TextOutputStream {
3434

3535
/// A by-reference string storage.
3636
public class LogStorage {
37-
var text = ""
37+
var _text = Synchronized("")
38+
var text: String {
39+
_text.sync { $0 }
40+
}
3841
}
3942

4043
/// Writes the given string to the log handle.
@@ -51,7 +54,7 @@ public enum LogHandle: TextOutputStream {
5154
case .file(let fileHandle):
5255
fileHandle.write(Data(string.utf8))
5356
case .memory(let storage):
54-
storage.text.append(string)
57+
storage._text.sync { $0.append(string) }
5558
}
5659
}
5760
}

Sources/SwiftDocC/Utility/Synchronization.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,16 +97,16 @@ public class Synchronized<Value> {
9797
/// lock.sync { myVar = 1 }
9898
/// let result = lock.sync { return index["key"] }
9999
/// ```
100-
typealias Lock = Synchronized<Void>
100+
package typealias Lock = Synchronized<Void>
101101

102-
public extension Lock {
102+
extension Lock {
103103
/// Creates a new lock.
104-
convenience init() {
104+
package convenience init() {
105105
self.init(())
106106
}
107107

108108
@discardableResult
109-
func sync<Result>(_ block: () throws -> Result) rethrows -> Result {
109+
package func sync<Result>(_ block: () throws -> Result) rethrows -> Result {
110110
#if os(macOS) || os(iOS)
111111
os_unfair_lock_lock(lock)
112112
defer { os_unfair_lock_unlock(lock) }

Sources/SwiftDocCUtilities/Action/Actions/PreviewAction.swift

Lines changed: 27 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ public final class PreviewAction: AsyncAction {
3838
static var allowConcurrentPreviews = false
3939

4040
private let printHTMLTemplatePath: Bool
41-
42-
var logHandle = LogHandle.standardOutput
4341

4442
let port: Int
4543

@@ -89,17 +87,17 @@ public final class PreviewAction: AsyncAction {
8987
///
9088
/// - Parameter logHandle: The file handle that the convert and preview actions will print debug messages to.
9189
public func perform(logHandle: inout LogHandle) async throws -> ActionResult {
92-
self.logHandle = logHandle
90+
self.logHandle.sync { $0 = logHandle }
9391

9492
if let rootURL = convertAction.rootURL {
95-
print("Input: \(rootURL.path)", to: &self.logHandle)
93+
print("Input: \(rootURL.path)")
9694
}
9795
// TODO: This never did output human readable string; rdar://74324255
9896
// print("Input: \(convertAction.documentationCoverageOptions)", to: &self.logHandle)
9997

10098
// In case a developer is using a custom template log its path.
10199
if printHTMLTemplatePath, let htmlTemplateDirectory = convertAction.htmlTemplateDirectory {
102-
print("Template: \(htmlTemplateDirectory.path)", to: &self.logHandle)
100+
print("Template: \(htmlTemplateDirectory.path)")
103101
}
104102

105103
let previewResult = try await preview()
@@ -124,15 +122,16 @@ public final class PreviewAction: AsyncAction {
124122
let previewResult: ActionResult
125123
// Preview the output and monitor the source bundle for changes.
126124
do {
127-
print(String(repeating: "=", count: 40), to: &logHandle)
125+
print(String(repeating: "=", count: 40))
128126
if let previewURL = URL(string: "http://localhost:\(port)") {
129-
print("Starting Local Preview Server", to: &logHandle)
127+
print("Starting Local Preview Server")
130128
printPreviewAddresses(base: previewURL)
131-
print(String(repeating: "=", count: 40), to: &logHandle)
129+
print(String(repeating: "=", count: 40))
132130
}
133131

134132
let to: PreviewServer.Bind = bindServerToSocketPath.map { .socket(path: $0) } ?? .localhost(port: port)
135-
servers[serverIdentifier] = try PreviewServer(contentURL: convertAction.targetDirectory, bindTo: to, logHandle: &logHandle)
133+
var logHandleCopy = logHandle.sync { $0 }
134+
servers[serverIdentifier] = try PreviewServer(contentURL: convertAction.targetDirectory, bindTo: to, logHandle: &logHandleCopy)
136135

137136
// When the user stops docc - stop the preview server first before exiting.
138137
trapSignals()
@@ -159,7 +158,8 @@ public final class PreviewAction: AsyncAction {
159158

160159
func convert() async throws -> ActionResult {
161160
convertAction = try createConvertAction()
162-
let (result, context) = try await convertAction.perform(logHandle: &logHandle)
161+
var logHandleCopy = logHandle.sync { $0 }
162+
let (result, context) = try await convertAction.perform(logHandle: &logHandleCopy)
163163

164164
previewPaths = try context.previewPaths()
165165
return result
@@ -168,15 +168,23 @@ public final class PreviewAction: AsyncAction {
168168
private func printPreviewAddresses(base: URL) {
169169
// If the preview paths are empty, just print the base.
170170
let firstPath = previewPaths.first ?? ""
171-
print("\t Address: \(base.appendingPathComponent(firstPath).absoluteString)", to: &logHandle)
171+
print("\t Address: \(base.appendingPathComponent(firstPath).absoluteString)")
172172

173173
let spacing = String(repeating: " ", count: "Address:".count)
174174
for previewPath in previewPaths.dropFirst() {
175-
print("\t \(spacing) \(base.appendingPathComponent(previewPath).absoluteString)", to: &logHandle)
175+
print("\t \(spacing) \(base.appendingPathComponent(previewPath).absoluteString)")
176176
}
177177
}
178178

179-
var monitoredConvertTask: Task<Void, Never>?
179+
private var logHandle: Synchronized<LogHandle> = .init(.none)
180+
181+
fileprivate func print(_ string: String, terminator: String = "\n") {
182+
logHandle.sync { logHandle in
183+
Swift.print(string, terminator: terminator, to: &logHandle)
184+
}
185+
}
186+
187+
fileprivate var monitoredConvertTask: Task<Void, Never>?
180188
}
181189

182190
// Monitoring a source folder: Asynchronous output reading and file system events are supported only on macOS.
@@ -192,38 +200,27 @@ extension PreviewAction {
192200
}
193201

194202
monitor = try DirectoryMonitor(root: rootURL) { _, _ in
195-
print("Source bundle was modified, converting... ", terminator: "", to: &self.logHandle)
203+
self.print("Source bundle was modified, converting... ", terminator: "")
196204
self.monitoredConvertTask?.cancel()
197205
self.monitoredConvertTask = Task {
198-
defer {
199-
// Reload the directory contents and start to monitor for changes.
200-
do {
201-
try monitor.restart()
202-
} catch {
203-
// The file watching system API has thrown, stop watching.
204-
print("Watching for changes has failed. To continue preview with watching restart docc.", to: &self.logHandle)
205-
print(error.localizedDescription, to: &self.logHandle)
206-
}
207-
}
208-
209206
do {
210207
let result = try await self.convert()
211208
if result.didEncounterError {
212209
throw ErrorsEncountered()
213210
}
214-
print("Done.", to: &self.logHandle)
211+
self.print("Done.")
215212
} catch DocumentationContext.ContextError.registrationDisabled {
216213
// The context cancelled loading the bundles and threw to yield execution early.
217-
print("\nConversion cancelled...", to: &self.logHandle)
214+
self.print("\nConversion cancelled...")
218215
} catch is CancellationError {
219-
print("\nConversion cancelled...", to: &self.logHandle)
216+
self.print("\nConversion cancelled...")
220217
} catch {
221-
print("\n\(error.localizedDescription)\nCompilation failed", to: &self.logHandle)
218+
self.print("\n\(error.localizedDescription)\nCompilation failed")
222219
}
223220
}
224221
}
225222
try monitor.start()
226-
print("Monitoring \(rootURL.path) for changes...", to: &self.logHandle)
223+
self.print("Monitoring \(rootURL.path) for changes...")
227224
}
228225
}
229226
#endif // !os(Linux) && !os(Android)

Sources/SwiftDocCUtilities/Utility/DirectoryMonitor.swift

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -207,18 +207,22 @@ class DirectoryMonitor {
207207
)
208208
}
209209

210+
private var startStopLock = Lock()
211+
210212
/// Start monitoring the root directory and its contents.
211213
func start() throws {
212-
let watchedFiles = try watchedURLChecksum()
213-
lastChecksum = watchedFiles.checksum
214-
215-
do {
216-
watchedDirectories = try allDirectories(in: root)
217-
.map { return try watchedDirectory(at: $0.directory, files: $0.files, on: monitorQueue) }
218-
} catch Error.openFileHandleFailed(_, let errorCode) where errorCode == Error.tooManyOpenFilesErrorCode {
219-
// In case we've reached the file descriptor limit throw a detailed user error
220-
// with recovery instructions.
221-
throw Error.reachedOpenFileLimit(watchedFiles.count)
214+
try startStopLock.sync {
215+
let watchedFiles = try watchedURLChecksum()
216+
lastChecksum = watchedFiles.checksum
217+
218+
do {
219+
watchedDirectories = try allDirectories(in: root)
220+
.map { return try watchedDirectory(at: $0.directory, files: $0.files, on: monitorQueue) }
221+
} catch Error.openFileHandleFailed(_, let errorCode) where errorCode == Error.tooManyOpenFilesErrorCode {
222+
// In case we've reached the file descriptor limit throw a detailed user error
223+
// with recovery instructions.
224+
throw Error.reachedOpenFileLimit(watchedFiles.count)
225+
}
222226
}
223227
}
224228

@@ -229,12 +233,14 @@ class DirectoryMonitor {
229233

230234
/// Stop monitoring.
231235
func stop() {
232-
for directory in watchedDirectories {
233-
for source in directory.sources {
234-
source.cancel()
236+
startStopLock.sync {
237+
for directory in watchedDirectories {
238+
for source in directory.sources {
239+
source.cancel()
240+
}
235241
}
242+
watchedDirectories = []
236243
}
237-
watchedDirectories = []
238244
}
239245

240246
deinit {

0 commit comments

Comments
 (0)