diff --git a/Sources/NIOExtras/WritePCAPHandler.swift b/Sources/NIOExtras/WritePCAPHandler.swift index 23a3e9af..195a27e8 100644 --- a/Sources/NIOExtras/WritePCAPHandler.swift +++ b/Sources/NIOExtras/WritePCAPHandler.swift @@ -599,7 +599,7 @@ extension NIOWritePCAPHandler { /// A synchronised file sink that uses a `DispatchQueue` to do all the necessary write synchronously. /// /// A `SynchronizedFileSink` is thread-safe so can be used from any thread/`EventLoop`. After use, you - /// _must_ call `syncClose` on the `SynchronizedFileSink` to shut it and all the associated resources down. Failing + /// _must_ call `syncClose` or `close` on the `SynchronizedFileSink` to shut it and all the associated resources down. Failing /// to do so triggers undefined behaviour. public final class SynchronizedFileSink { private let fileHandle: NIOFileHandle @@ -682,17 +682,44 @@ extension NIOWritePCAPHandler { self.workQueue = DispatchQueue(label: "io.swiftnio.extras.WritePCAPHandler.SynchronizedFileSink.workQueue") self.errorHandler = errorHandler } - + + #if swift(>=5.7) + /// Synchronously close this `SynchronizedFileSink` and any associated resources. + /// + /// After use, it is mandatory to close a `SynchronizedFileSink` exactly once. `syncClose` may be called from + /// any thread but not from an `EventLoop` as it will block, and may not be called from an async context. + @available(*, noasync, message: "syncClose() can block indefinitely, prefer close()", renamed: "close()") + public func syncClose() throws { + try self._syncClose() + } + #else /// Synchronously close this `SynchronizedFileSink` and any associated resources. /// /// After use, it is mandatory to close a `SynchronizedFileSink` exactly once. `syncClose` may be called from - /// any thread but not from an `EventLoop` as it will block. + /// any thread but not from an `EventLoop` as it will block, and may not be called from an async context. public func syncClose() throws { + try self._syncClose() + } + #endif + + private func _syncClose() throws { self.writesGroup.wait() try self.workQueue.sync { try self.fileHandle.close() } } + + /// Close this `SynchronizedFileSink` and any associated resources. + /// + /// After use, it is mandatory to close a `SynchronizedFileSink` exactly once. + @available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) + public func close() async throws { + try await withCheckedThrowingContinuation { continuation in + self.workQueue.async { + continuation.resume(with: Result { try self.fileHandle.close() }) + } + } + } public func write(buffer: ByteBuffer) { self.workQueue.async(group: self.writesGroup) { diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 95e3a5c6..f99d1c76 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -59,6 +59,7 @@ class LinuxMainRunner { testCase(ServerResponseTests.allTests), testCase(ServerStateMachineTests.allTests), testCase(SocksClientHandlerTests.allTests), + testCase(SynchronizedFileSinkTests.allTests), testCase(WritePCAPHandlerTest.allTests), ]) } diff --git a/Tests/NIOExtrasTests/SynchronizedFileSinkTests+XCTest.swift b/Tests/NIOExtrasTests/SynchronizedFileSinkTests+XCTest.swift new file mode 100644 index 00000000..3ed6e8fb --- /dev/null +++ b/Tests/NIOExtrasTests/SynchronizedFileSinkTests+XCTest.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2018-2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +// +// SynchronizedFileSinkTests+XCTest.swift +// +import XCTest + +/// +/// NOTE: This file was generated by generate_linux_tests.rb +/// +/// Do NOT edit this file directly as it will be regenerated automatically when needed. +/// + +extension SynchronizedFileSinkTests { + + @available(*, deprecated, message: "not actually deprecated. Just deprecated to allow deprecated tests (which test deprecated functionality) without warnings") + static var allTests : [(String, (SynchronizedFileSinkTests) -> () throws -> Void)] { + return [ + ("testSimpleFileSink", testSimpleFileSink), + ("testSimpleFileSinkAsyncShutdown", testSimpleFileSinkAsyncShutdown), + ] + } +} + diff --git a/Tests/NIOExtrasTests/SynchronizedFileSinkTests.swift b/Tests/NIOExtrasTests/SynchronizedFileSinkTests.swift new file mode 100644 index 00000000..d05210cb --- /dev/null +++ b/Tests/NIOExtrasTests/SynchronizedFileSinkTests.swift @@ -0,0 +1,125 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftNIO project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftNIO project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import XCTest + +import NIOCore +import NIOEmbedded +@testable import NIOExtras + +final class SynchronizedFileSinkTests: XCTestCase { + func testSimpleFileSink() throws { + try withTemporaryFile { file, path in + let sink = try NIOWritePCAPHandler.SynchronizedFileSink.fileSinkWritingToFile(path: path, errorHandler: { XCTFail("Caught error \($0)") }) + + sink.write(buffer: ByteBuffer(string: "Hello, ")) + sink.write(buffer: ByteBuffer(string: "world!")) + try sink.syncClose() + + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + XCTAssertEqual(data, Data(NIOWritePCAPHandler.pcapFileHeader.readableBytesView) + Data("Hello, world!".utf8)) + } + } + + func testSimpleFileSinkAsyncShutdown() throws { + guard #available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) else { return } + XCTAsyncTest { + try await withTemporaryFile { file, path in + let sink = try NIOWritePCAPHandler.SynchronizedFileSink.fileSinkWritingToFile(path: path, errorHandler: { XCTFail("Caught error \($0)") }) + + sink.write(buffer: ByteBuffer(string: "Hello, ")) + sink.write(buffer: ByteBuffer(string: "world!")) + try await sink.close() + + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + XCTAssertEqual(data, Data(NIOWritePCAPHandler.pcapFileHeader.readableBytesView) + Data("Hello, world!".utf8)) + } + } + } +} + +fileprivate func withTemporaryFile(content: String? = nil, _ body: (NIOCore.NIOFileHandle, String) throws -> T) throws -> T { + let temporaryFilePath = "\(temporaryDirectory)/nio_extras_\(UUID())" + FileManager.default.createFile(atPath: temporaryFilePath, contents: content?.data(using: .utf8)) + defer { + XCTAssertNoThrow(try FileManager.default.removeItem(atPath: temporaryFilePath)) + } + + let fileHandle = try NIOFileHandle(path: temporaryFilePath, mode: [.read, .write]) + defer { + XCTAssertNoThrow(try fileHandle.close()) + } + + return try body(fileHandle, temporaryFilePath) +} + +fileprivate func withTemporaryFile(content: String? = nil, _ body: (NIOCore.NIOFileHandle, String) async throws -> T) async throws -> T { + let temporaryFilePath = "\(temporaryDirectory)/nio_extras_\(UUID())" + FileManager.default.createFile(atPath: temporaryFilePath, contents: content?.data(using: .utf8)) + defer { + XCTAssertNoThrow(try FileManager.default.removeItem(atPath: temporaryFilePath)) + } + + let fileHandle = try NIOFileHandle(path: temporaryFilePath, mode: [.read, .write]) + defer { + XCTAssertNoThrow(try fileHandle.close()) + } + + return try await body(fileHandle, temporaryFilePath) +} + +fileprivate var temporaryDirectory: String { +#if os(Linux) + return "/tmp" +#else + if #available(macOS 10.12, iOS 10, tvOS 10, watchOS 3, *) { + return FileManager.default.temporaryDirectory.path + } else { + return "/tmp" + } +#endif // os +} + +extension XCTestCase { + @available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) + /// Cross-platform XCTest support for async-await tests. + /// + /// Currently the Linux implementation of XCTest doesn't have async-await support. + /// Until it does, we make use of this shim which uses a detached `Task` along with + /// `XCTest.wait(for:timeout:)` to wrap the operation. + /// + /// - NOTE: Support for Linux is tracked by https://bugs.swift.org/browse/SR-14403. + /// - NOTE: Implementation currently in progress: https://github.com/apple/swift-corelibs-xctest/pull/326 + func XCTAsyncTest( + expectationDescription: String = "Async operation", + timeout: TimeInterval = 30, + file: StaticString = #filePath, + line: UInt = #line, + function: StaticString = #function, + operation: @escaping @Sendable () async throws -> Void + ) { + let expectation = self.expectation(description: expectationDescription) + Task { + do { + try await operation() + } catch { + XCTFail("Error thrown while executing \(function): \(error)", file: file, line: line) + Thread.callStackSymbols.forEach { print($0) } + } + expectation.fulfill() + } + self.wait(for: [expectation], timeout: timeout) + } +}