Skip to content

Commit

Permalink
Make SynchronizedFileSink.close unavailable from async (#195)
Browse files Browse the repository at this point in the history
Motivation

syncClose will block whatever thread it's on indefinitely. That makes it
unsafe to call in async contexts.

Modifications

Add a new close() method that's async.
Make the existing method unavailable from async.
Add some tests.

Results

Easier to close these from async contexts
  • Loading branch information
Lukasa authored Mar 3, 2023
1 parent d75ed70 commit cc1e527
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 3 deletions.
33 changes: 30 additions & 3 deletions Sources/NIOExtras/WritePCAPHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions Tests/LinuxMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class LinuxMainRunner {
testCase(ServerResponseTests.allTests),
testCase(ServerStateMachineTests.allTests),
testCase(SocksClientHandlerTests.allTests),
testCase(SynchronizedFileSinkTests.allTests),
testCase(WritePCAPHandlerTest.allTests),
])
}
Expand Down
35 changes: 35 additions & 0 deletions Tests/NIOExtrasTests/SynchronizedFileSinkTests+XCTest.swift
Original file line number Diff line number Diff line change
@@ -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),
]
}
}

125 changes: 125 additions & 0 deletions Tests/NIOExtrasTests/SynchronizedFileSinkTests.swift
Original file line number Diff line number Diff line change
@@ -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<T>(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<T>(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)
}
}

0 comments on commit cc1e527

Please sign in to comment.