diff --git a/Sources/ServiceLauncher/Lifecycle.swift b/Sources/ServiceLauncher/Lifecycle.swift index 6a9bd1a..7caf2aa 100644 --- a/Sources/ServiceLauncher/Lifecycle.swift +++ b/Sources/ServiceLauncher/Lifecycle.swift @@ -21,10 +21,126 @@ import Backtrace import Dispatch import Logging +public struct TopLevelLifecycle { + private let lifecycle = Lifecycle(label: "\(TopLevelLifecycle.self)") + + /// Starts the provided `LifecycleItem` array and waits (blocking) until a shutdown `Signal` is captured or `Lifecycle.shutdown` is called on another thread. + /// Startup is performed in the order of items provided. + /// + /// - parameters: + /// - configuration: Defines lifecycle `Configuration` + public func startAndWait(configuration: Configuration = .init()) throws { + let waitSemaphore = DispatchSemaphore(value: 0) + var startError: Error? + + self.start(configuration: configuration) { error in + startError = error + waitSemaphore.signal() + } + waitSemaphore.wait() + try startError.map { throw $0 } + self.wait() + } + + /// Starts the provided `LifecycleItem` array. + /// Startup is performed in the order of items provided. + /// + /// - parameters: + /// - configuration: Defines lifecycle `Configuration` + /// - callback: The handler which is called after the start operation completes. The parameter will be `nil` on success and contain the `Error` otherwise. + public func start(configuration: Configuration = .init(), callback: @escaping (Error?) -> Void) { + if configuration.installBacktrace { + self.lifecycle.logger.info("installing backtrace") + Backtrace.install() + } + configuration.shutdownSignal?.forEach { signal in + self.lifecycle.logger.info("setting up shutdown hook on \(signal)") + let signalSource = TopLevelLifecycle.trap(signal: signal, handler: { signal in + self.lifecycle.logger.info("intercepted signal: \(signal)") + self.shutdown() + }) + self.lifecycle.shutdownGroup.notify(queue: DispatchQueue.global()) { + signalSource.cancel() + } + } + self.lifecycle.start(on: configuration.callbackQueue) { error in + callback(error) + } + } + + /// Shuts down the `LifecycleItem` array provided in `start` or `startAndWait`. + /// Shutdown is performed in reverse order of items provided./ + /// + /// - parameters: + /// - callback: The handler which is called after the start operation completes. The parameter will be `nil` on success and contain the `Error` otherwise. + public func shutdown(callback: @escaping (Error?) -> Void = { _ in }) { + self.lifecycle.shutdown(callback: callback) + } + + /// Waits (blocking) until shutdown signal is captured or `Lifecycle.shutdown` is invoked on another thread. + public func wait() { + self.lifecycle.wait() + } + + /// Adds a `LifecycleItem` to a `LifecycleItems` collection. + /// + /// - parameters: + /// - items: one or more `LifecycleItem`. + func register(_ items: [LifecycleItem]) { + self.lifecycle.register(items) + } + + /// Adds a `LifecycleItem` to a `LifecycleItems` collection. + /// + /// - parameters: + /// - items: one or more `LifecycleItem`. + internal func register(_ items: LifecycleItem...) { + self.lifecycle.register(items) + } + + /// Add a `LifecycleItem` to a `LifecycleItems` collection. + /// + /// - parameters: + /// - label: label of the item, useful for debugging. + /// - start: closure to perform the startup. + /// - shutdown: closure to perform the shutdown. + func register(label: String, start: Lifecycle.Handler, shutdown: Lifecycle.Handler) { + self.lifecycle.register(label: label, start: start, shutdown: shutdown) + } + + /// Adds a `LifecycleItem` to a `LifecycleItems` collection. + /// + /// - parameters: + /// - label: label of the item, useful for debugging. + /// - shutdown: closure to perform the shutdown. + func registerShutdown(label: String, _ handler: Lifecycle.Handler) { + self.lifecycle.registerShutdown(label: label, handler) + } + + /// Adds a `LifecycleItem` to a `LifecycleItems` collection. + /// + /// - parameters: + /// - label: label of the item, useful for debugging. + /// - start: closure to perform the shutdown. + /// - shutdown: closure to perform the shutdown. + func register(label: String, start: @escaping () throws -> Void, shutdown: @escaping () throws -> Void) { + self.lifecycle.register(label: label, start: start, shutdown: shutdown) + } + + /// Adds a `LifecycleItem` to a `LifecycleItems` collection. + /// + /// - parameters: + /// - label: label of the item, useful for debugging. + /// - shutdown: closure to perform the shutdown. + func registerShutdown(label: String, _ handler: @escaping () throws -> Void) { + self.lifecycle.registerShutdown(label: label, handler) + } +} + /// `Lifecycle` provides a basic mechanism to cleanly startup and shutdown the application, freeing resources in order before exiting. -public class Lifecycle { - private let logger = Logger(label: "\(Lifecycle.self)") - private let shutdownGroup = DispatchGroup() +public class Lifecycle: LifecycleItem { + public let label: String + internal let logger: Logger private var state = State.idle private let stateLock = Lock() @@ -32,50 +148,51 @@ public class Lifecycle { private var items = [LifecycleItem]() private let itemsLock = Lock() + internal let shutdownGroup = DispatchGroup() + /// Creates a `Lifecycle` instance. - public init() { + public init(label: String) { + self.label = label + self.logger = Logger(label: "\(Lifecycle.self) \(label)") self.shutdownGroup.enter() } - /// Starts the provided `LifecycleItem` array and waits (blocking) until a shutdown `Signal` is captured or `Lifecycle.shutdown` is called on another thread. + /// Starts the provided `LifecycleItem` array. /// Startup is performed in the order of items provided. /// /// - parameters: - /// - configuration: Defines lifecycle `Configuration` - public func startAndWait(configuration: Configuration = .init()) throws { - let waitLock = Lock() - let group = DispatchGroup() - var startError: Error? - let items = self.itemsLock.withLock { self.items } - group.enter() - self._start(configuration: configuration, items: items) { error in - waitLock.withLock { - startError = error - } - group.leave() - } - self.shutdownGroup.wait() - try waitLock.withLock { - startError - }.map { error in - throw error - } + /// - callback: The handler which is called after the start operation completes. The parameter will be `nil` on success and contain the `Error` otherwise. + public func start(callback: @escaping (Error?) -> Void) { + self.start(on: DispatchQueue.global(), callback: callback) } /// Starts the provided `LifecycleItem` array. /// Startup is performed in the order of items provided. /// /// - parameters: - /// - configuration: Defines lifecycle `Configuration` + /// - on: `DispatchQueue` to run the handler on /// - callback: The handler which is called after the start operation completes. The parameter will be `nil` on success and contain the `Error` otherwise. - public func start(configuration: Configuration = .init(), callback: @escaping (Error?) -> Void) { + public func start(on queue: DispatchQueue, callback: @escaping (Error?) -> Void) { let items = self.itemsLock.withLock { self.items } - self._start(configuration: configuration, items: items, callback: callback) + self._start(on: queue, items: items, callback: callback) } /// Shuts down the `LifecycleItem` array provided in `start` or `startAndWait`. /// Shutdown is performed in reverse order of items provided. - public func shutdown(on queue: DispatchQueue = DispatchQueue.global()) { + /// + /// - parameters: + /// - callback: The handler which is called after the start operation completes. The parameter will be `nil` on success and contain the `Error` otherwise. + public func shutdown(callback: @escaping (Error?) -> Void = { _ in }) { + self.shutdown(on: DispatchQueue.global(), callback: callback) + } + + /// Shuts down the `LifecycleItem` array provided in `start` or `startAndWait`. + /// Shutdown is performed in reverse order of items provided. + /// + /// - parameters: + /// - on: `DispatchQueue` to run the handler on + /// - callback: The handler which is called after the start operation completes. The parameter will be `nil` on success and contain the `Error` otherwise. + public func shutdown(on queue: DispatchQueue, callback: @escaping (Error?) -> Void = { _ in }) { self.stateLock.lock() switch self.state { case .idle: @@ -90,33 +207,30 @@ public class Lifecycle { case .started(let items): self.state = .shuttingDown self.stateLock.unlock() - self._shutdown(on: queue, items: items) { - self.shutdownGroup.leave() + self._shutdown(on: queue, items: items) { errors in + defer { self.shutdownGroup.leave() } + callback(errors.count > 0 ? ShutdownError(errors: errors) : nil) } } } - /// Waits (blocking) until shutdown signal is captured or `Lifecycle.shutdown` is invoked on another thread. public func wait() { self.shutdownGroup.wait() } // MARK: - private - private func _start(configuration: Configuration, items: [LifecycleItem], callback: @escaping (Error?) -> Void) { + private func _start(on queue: DispatchQueue = DispatchQueue.global(), items: [LifecycleItem], callback: @escaping (Error?) -> Void) { self.stateLock.withLock { guard case .idle = self.state else { preconditionFailure("invalid state, \(self.state)") } precondition(items.count > 0, "invalid number of items, must be > 0") self.logger.info("starting lifecycle") - if configuration.installBacktrace { - self.logger.info("installing backtrace") - Backtrace.install() - } + self.state = .starting } - self._start(on: configuration.callbackQueue, items: items, index: 0) { _, error in + self._start(on: queue, items: items, index: 0) { _, error in self.stateLock.lock() if error != nil { self.state = .shuttingDown @@ -125,23 +239,13 @@ public class Lifecycle { case .shuttingDown: self.stateLock.unlock() // shutdown was called while starting, or start failed, shutdown what we can - self._shutdown(on: configuration.callbackQueue, items: items) { + self._shutdown(on: queue, items: items) { _ in callback(error) self.shutdownGroup.leave() } case .starting: self.state = .started(items) self.stateLock.unlock() - configuration.shutdownSignal?.forEach { signal in - self.logger.info("setting up shutdown hook on \(signal)") - let signalSource = Lifecycle.trap(signal: signal, handler: { signal in - self.logger.info("intercepted signal: \(signal)") - self.shutdown(on: configuration.callbackQueue) - }) - self.shutdownGroup.notify(queue: DispatchQueue.global()) { - signalSource.cancel() - } - } return callback(nil) default: preconditionFailure("invalid state, \(self.state)") @@ -160,7 +264,7 @@ public class Lifecycle { self.logger.info("starting item [\(items[index].label)]") start { error in if let error = error { - self.logger.info("failed to start [\(items[index].label)]: \(error)") + self.logger.error("failed to start [\(items[index].label)]: \(error)") return callback(index, error) } // shutdown called while starting @@ -171,12 +275,12 @@ public class Lifecycle { } } - private func _shutdown(on queue: DispatchQueue, items: [LifecycleItem], callback: @escaping () -> Void) { + private func _shutdown(on queue: DispatchQueue, items: [LifecycleItem], callback: @escaping ([String: Error]) -> Void) { self.stateLock.withLock { self.logger.info("shutting down lifecycle") self.state = .shuttingDown } - self._shutdown(on: queue, items: items.reversed(), index: 0) { + self._shutdown(on: queue, items: items.reversed(), index: 0, errors: [:]) { errors in self.stateLock.withLock { guard case .shuttingDown = self.state else { preconditionFailure("invalid state, \(self.state)") @@ -184,24 +288,26 @@ public class Lifecycle { self.state = .shutdown } self.logger.info("bye") - callback() + callback(errors) } } - private func _shutdown(on queue: DispatchQueue, items: [LifecycleItem], index: Int, callback: @escaping () -> Void) { + private func _shutdown(on queue: DispatchQueue, items: [LifecycleItem], index: Int, errors: [String: Error], callback: @escaping ([String: Error]) -> Void) { // async barrier let shutdown = { (callback) -> Void in queue.async { items[index].shutdown(callback: callback) } } - let callback = { () -> Void in queue.async { callback() } } + let callback = { (errors) -> Void in queue.async { callback(errors) } } if index >= items.count { - return callback() + return callback(errors) } self.logger.info("stopping item [\(items[index].label)]") shutdown { error in + var errors = errors if let error = error { - self.logger.info("failed to stop [\(items[index].label)]: \(error)") + errors[items[index].label] = error + self.logger.error("failed to stop [\(items[index].label)]: \(error)") } - self._shutdown(on: queue, items: items, index: index + 1, callback: callback) + self._shutdown(on: queue, items: items, index: index + 1, errors: errors, callback: callback) } } @@ -212,6 +318,10 @@ public class Lifecycle { case shuttingDown case shutdown } + + public struct ShutdownError: Error { + let errors: [String: Error] + } } extension Lifecycle { @@ -221,31 +331,11 @@ extension Lifecycle { let shutdown: Handler func start(callback: @escaping (Error?) -> Void) { - self.start.run(callback) + self.start.run(callback: callback) } func shutdown(callback: @escaping (Error?) -> Void) { - self.shutdown.run(callback) - } - } -} - -extension Lifecycle { - /// `Lifecycle` configuration options. - public struct Configuration { - /// Defines the `DispatchQueue` on which startup and shutdown handlers are executed. - public let callbackQueue: DispatchQueue - /// Defines if to install a crash signal trap that prints backtraces. - public let shutdownSignal: [Signal]? - /// Defines what, if any, signals to trap for invoking shutdown. - public let installBacktrace: Bool - - public init(callbackQueue: DispatchQueue = DispatchQueue.global(), - shutdownSignal: [Signal]? = [.TERM, .INT], - installBacktrace: Bool = true) { - self.callbackQueue = callbackQueue - self.shutdownSignal = shutdownSignal - self.installBacktrace = installBacktrace + self.shutdown.run(callback: callback) } } } @@ -323,7 +413,7 @@ public extension Lifecycle { /// /// - parameters: /// - callback: the underlying completion handler - public init(_ callback: @escaping (@escaping (Error?) -> Void) -> Void) { + public init(callback: @escaping (@escaping (Error?) -> Void) -> Void) { self.body = callback } @@ -332,7 +422,7 @@ public extension Lifecycle { /// - parameters: /// - callback: the underlying completion handler public static func async(_ callback: @escaping (@escaping (Error?) -> Void) -> Void) -> Handler { - return Handler(callback) + return Handler(callback: callback) } /// Asynchronous `Lifecycle.Handler` based on a blocking, throwing function. @@ -340,12 +430,12 @@ public extension Lifecycle { /// - parameters: /// - body: the underlying function public static func sync(_ body: @escaping () throws -> Void) -> Handler { - return Handler { completionHandler in + return Handler { callback in do { try body() - completionHandler(nil) + callback(nil) } catch { - completionHandler(error) + callback(error) } } } @@ -357,7 +447,7 @@ public extension Lifecycle { } } - internal func run(_ callback: @escaping (Error?) -> Void) { + internal func run(callback: @escaping (Error?) -> Void) { self.body(callback) } } @@ -370,7 +460,27 @@ public protocol LifecycleItem { func shutdown(callback: @escaping (Error?) -> Void) } -extension Lifecycle { +extension TopLevelLifecycle { + /// `Lifecycle` configuration options. + public struct Configuration { + /// Defines the `DispatchQueue` on which startup and shutdown handlers are executed. + public let callbackQueue: DispatchQueue + /// Defines if to install a crash signal trap that prints backtraces. + public let shutdownSignal: [Signal]? + /// Defines what, if any, signals to trap for invoking shutdown. + public let installBacktrace: Bool + + public init(callbackQueue: DispatchQueue = DispatchQueue.global(), + shutdownSignal: [Signal]? = [.TERM, .INT], + installBacktrace: Bool = true) { + self.callbackQueue = callbackQueue + self.shutdownSignal = shutdownSignal + self.installBacktrace = installBacktrace + } + } +} + +extension TopLevelLifecycle { /// Setup a signal trap. /// /// - parameters: diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift index 5d64127..3b5be51 100644 --- a/Tests/LinuxMain.swift +++ b/Tests/LinuxMain.swift @@ -26,6 +26,7 @@ import XCTest @testable import ServiceLauncherTests XCTMain([ - testCase(Tests.allTests), + testCase(LifeycleTests.allTests), + testCase(TopLevelTests.allTests), ]) #endif diff --git a/Tests/ServiceLauncherTests/Helpers.swift b/Tests/ServiceLauncherTests/Helpers.swift new file mode 100644 index 0000000..af98c56 --- /dev/null +++ b/Tests/ServiceLauncherTests/Helpers.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLauncher open source project +// +// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLauncher project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLauncher project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import NIO +import NIOConcurrencyHelpers +import ServiceLauncher + +class GoodItem: LifecycleItem { + let queue = DispatchQueue(label: "GoodItem", attributes: .concurrent) + + let id: String + let startDelay: Double + let shutdownDelay: Double + + var state = State.idle + let stateLock = Lock() + + init(id: String = UUID().uuidString, + startDelay: Double = Double.random(in: 0.01 ... 0.1), + shutdownDelay: Double = Double.random(in: 0.01 ... 0.1)) { + self.id = id + self.startDelay = startDelay + self.shutdownDelay = shutdownDelay + } + + var label: String { + return self.id + } + + func start(callback: @escaping (Error?) -> Void) { + self.queue.asyncAfter(deadline: .now() + self.startDelay) { + self.stateLock.withLock { self.state = .started } + callback(nil) + } + } + + func shutdown(callback: @escaping (Error?) -> Void) { + self.queue.asyncAfter(deadline: .now() + self.shutdownDelay) { + self.stateLock.withLock { self.state = .shutdown } + callback(nil) + } + } + + enum State { + case idle + case started + case shutdown + } +} + +class NIOItem { + let id: String + let eventLoopGroup: EventLoopGroup + let startDelay: Int64 + let shutdownDelay: Int64 + + var state = State.idle + let stateLock = Lock() + + init(eventLoopGroup: EventLoopGroup, + id: String = UUID().uuidString, + startDelay: Int64 = Int64.random(in: 10 ... 20), + shutdownDelay: Int64 = Int64.random(in: 10 ... 20)) { + self.id = id + self.eventLoopGroup = eventLoopGroup + self.startDelay = startDelay + self.shutdownDelay = shutdownDelay + } + + func start() -> EventLoopFuture { + return self.eventLoopGroup.next().scheduleTask(in: .milliseconds(self.startDelay)) { + self.stateLock.withLock { self.state = .started } + }.futureResult + } + + func shutdown() -> EventLoopFuture { + return self.eventLoopGroup.next().scheduleTask(in: .milliseconds(self.shutdownDelay)) { + self.stateLock.withLock { self.state = .shutdown } + }.futureResult + } + + enum State { + case idle + case started + case shutdown + } +} + +struct TestError: Error {} diff --git a/Tests/ServiceLauncherTests/LifecycleTests+XCTest.swift b/Tests/ServiceLauncherTests/LifecycleTests+XCTest.swift index cd5b953..bc70477 100644 --- a/Tests/ServiceLauncherTests/LifecycleTests+XCTest.swift +++ b/Tests/ServiceLauncherTests/LifecycleTests+XCTest.swift @@ -22,15 +22,13 @@ import XCTest /// Do NOT edit this file directly as it will be regenerated automatically when needed. /// -extension Tests { - static var allTests: [(String, (Tests) -> () throws -> Void)] { +extension LifeycleTests { + static var allTests: [(String, (LifeycleTests) -> () throws -> Void)] { return [ ("testStartThenShutdown", testStartThenShutdown), ("testImmediateShutdown", testImmediateShutdown), ("testBadStartup", testBadStartup), ("testBadShutdown", testBadShutdown), - ("testStartAndWait", testStartAndWait), - ("testBadStartAndWait", testBadStartAndWait), ("testShutdownInOrder", testShutdownInOrder), ("testSync", testSync), ("testAyncBarrier", testAyncBarrier), diff --git a/Tests/ServiceLauncherTests/LifecycleTests.swift b/Tests/ServiceLauncherTests/LifecycleTests.swift index ca4194d..d5e0686 100644 --- a/Tests/ServiceLauncherTests/LifecycleTests.swift +++ b/Tests/ServiceLauncherTests/LifecycleTests.swift @@ -17,39 +17,25 @@ import NIO import ServiceLauncherNIOCompat import XCTest -final class Tests: XCTestCase { +final class LifeycleTests: XCTestCase { func testStartThenShutdown() { let items = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") lifecycle.register(items) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") + items.forEach { XCTAssertEqual($0.state, .started, "expected item to be started, but \($0.state)") } lifecycle.shutdown() } lifecycle.wait() items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } } - // FIXME: this test does not work in rio - func _testShutdownWithSignal() { - let signal = Lifecycle.Signal.ALRM - let items = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - let lifecycle = Lifecycle() - lifecycle.register(items) - let configuration = Lifecycle.Configuration(shutdownSignal: [signal]) - lifecycle.start(configuration: configuration) { error in - XCTAssertNil(error, "not expecting error") - kill(getpid(), signal.rawValue) - } - lifecycle.wait() - items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - } - func testImmediateShutdown() { let items = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") lifecycle.register(items) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { _ in } + lifecycle.start { _ in } lifecycle.shutdown() lifecycle.wait() items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } @@ -69,9 +55,9 @@ final class Tests: XCTestCase { } let items: [LifecycleItem] = [GoodItem(), BadItem(), GoodItem()] - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") lifecycle.register(items) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssert(error is TestError, "expected error to match") } lifecycle.wait() @@ -81,7 +67,7 @@ final class Tests: XCTestCase { func testBadShutdown() { class BadItem: LifecycleItem { - let label: String = UUID().uuidString + let label = UUID().uuidString func start(callback: (Error?) -> Void) { callback(nil) @@ -92,76 +78,23 @@ final class Tests: XCTestCase { } } - let items: [LifecycleItem] = [GoodItem(), BadItem(), GoodItem()] - let lifecycle = Lifecycle() + var shutdownError: Lifecycle.ShutdownError? + let items: [LifecycleItem] = [GoodItem(), BadItem(), BadItem(), GoodItem(), BadItem()] + let lifecycle = Lifecycle(label: "test") lifecycle.register(items) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - items.compactMap { $0 as? GoodItem }.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - } - - func testStartAndWait() { - class Item: LifecycleItem { - private let semaphore: DispatchSemaphore - var state = State.idle - - init(_ semaphore: DispatchSemaphore) { - self.semaphore = semaphore - } - - let label: String = UUID().uuidString - - func start(callback: (Error?) -> Void) { - self.state = .started - self.semaphore.signal() - callback(nil) - } - - func shutdown(callback: (Error?) -> Void) { - self.state = .shutdown - callback(nil) - } - - enum State { - case idle - case started - case shutdown - } - } - - let lifecycle = Lifecycle() - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue(label: "test").asyncAfter(deadline: .now() + 0.1) { - semaphore.wait() - lifecycle.shutdown() - } - let item = Item(semaphore) - lifecycle.register(item) - XCTAssertNoThrow(try lifecycle.startAndWait(configuration: .init(shutdownSignal: nil))) - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown") - } - - func testBadStartAndWait() { - class BadItem: LifecycleItem { - let label: String = UUID().uuidString - - func start(callback: (Error?) -> Void) { - callback(TestError()) - } - - func shutdown(callback: (Error?) -> Void) { - callback(nil) + lifecycle.shutdown { error in + shutdownError = error as? Lifecycle.ShutdownError } } + lifecycle.wait() - let lifecycle = Lifecycle() - lifecycle.register(GoodItem(), BadItem()) - XCTAssertThrowsError(try lifecycle.startAndWait(configuration: .init(shutdownSignal: nil))) { error in - XCTAssert(error is TestError, "expected error to match") - } + let goodItems = items.compactMap { $0 as? GoodItem } + goodItems.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } + let badItems = items.compactMap { $0 as? BadItem } + XCTAssertEqual(shutdownError?.errors.count, badItems.count, "expected shutdown errors") + badItems.forEach { XCTAssert(shutdownError!.errors[$0.label] is TestError, "expected error to match") } } func testShutdownInOrder() { @@ -193,9 +126,9 @@ final class Tests: XCTestCase { var result = [String]() let items = [Item(&result), Item(&result), Item(&result), Item(&result)] - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") lifecycle.register(items) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -227,13 +160,13 @@ final class Tests: XCTestCase { } } - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let items = (0 ... Int.random(in: 10 ... 20)).map { _ in Sync() } items.forEach { item in lifecycle.register(label: item.id, start: item.start, shutdown: item.shutdown) } - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -243,7 +176,7 @@ final class Tests: XCTestCase { func testAyncBarrier() { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item1 = NIOItem(eventLoopGroup: eventLoopGroup) lifecycle.register(label: "item1", start: .eventLoopFuture(item1.start), shutdown: .eventLoopFuture(item1.shutdown)) @@ -255,7 +188,7 @@ final class Tests: XCTestCase { let item2 = NIOItem(eventLoopGroup: eventLoopGroup) lifecycle.register(label: "item2", start: .eventLoopFuture(item2.start), shutdown: .eventLoopFuture(item2.shutdown)) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -264,7 +197,7 @@ final class Tests: XCTestCase { } func testConcurrency() { - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let items = (0 ... 50000).map { _ in GoodItem(startDelay: 0, shutdownDelay: 0) } let group = DispatchGroup() items.forEach { item in @@ -276,7 +209,7 @@ final class Tests: XCTestCase { } group.wait() - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -303,14 +236,14 @@ final class Tests: XCTestCase { } } - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = Sync() lifecycle.register(label: "test", start: item.start, shutdown: item.shutdown) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -337,12 +270,12 @@ final class Tests: XCTestCase { } } - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = Sync() lifecycle.registerShutdown(label: "test", item.shutdown) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -351,14 +284,14 @@ final class Tests: XCTestCase { } func testRegisterAsync() { - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = GoodItem() lifecycle.register(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -367,12 +300,12 @@ final class Tests: XCTestCase { } func testRegisterShutdownAsync() { - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = GoodItem() lifecycle.registerShutdown(label: "test", .async(item.shutdown)) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -381,7 +314,7 @@ final class Tests: XCTestCase { } func testRegisterAsyncClosure() { - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = GoodItem() lifecycle.register(label: "test", @@ -394,7 +327,7 @@ final class Tests: XCTestCase { item.shutdown(callback: callback) }) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -403,7 +336,7 @@ final class Tests: XCTestCase { } func testRegisterShutdownAsyncClosure() { - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = GoodItem() lifecycle.registerShutdown(label: "test", .async { callback in @@ -411,7 +344,7 @@ final class Tests: XCTestCase { item.shutdown(callback: callback) }) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -421,14 +354,14 @@ final class Tests: XCTestCase { func testRegisterNIO() { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = NIOItem(eventLoopGroup: eventLoopGroup) lifecycle.register(label: item.id, start: .eventLoopFuture(item.start), shutdown: .eventLoopFuture(item.shutdown)) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -438,12 +371,12 @@ final class Tests: XCTestCase { func testRegisterShutdownNIO() { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = NIOItem(eventLoopGroup: eventLoopGroup) lifecycle.registerShutdown(label: item.id, .eventLoopFuture(item.shutdown)) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -453,7 +386,7 @@ final class Tests: XCTestCase { func testRegisterNIOClosure() { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = NIOItem(eventLoopGroup: eventLoopGroup) lifecycle.register(label: item.id, @@ -466,7 +399,7 @@ final class Tests: XCTestCase { return item.shutdown() }) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -476,7 +409,7 @@ final class Tests: XCTestCase { func testRegisterShutdownNIOClosure() { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") let item = NIOItem(eventLoopGroup: eventLoopGroup) lifecycle.registerShutdown(label: item.id, .eventLoopFuture { @@ -484,7 +417,7 @@ final class Tests: XCTestCase { return item.shutdown() }) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") lifecycle.shutdown() } @@ -494,13 +427,13 @@ final class Tests: XCTestCase { func testNIOFailure() { let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") lifecycle.register(label: "test", start: .eventLoopFuture { eventLoopGroup.next().makeFailedFuture(TestError()) }, shutdown: .eventLoopFuture { eventLoopGroup.next().makeSucceededFuture(()) }) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssert(error is TestError, "expected error to match") lifecycle.shutdown() } @@ -535,7 +468,7 @@ final class Tests: XCTestCase { let expectedData = UUID().uuidString let item = Item(expectedData) - let lifecycle = Lifecycle() + let lifecycle = Lifecycle(label: "test") lifecycle.register(label: "test", start: .eventLoopFuture { item.start().map { data -> Void in @@ -548,7 +481,7 @@ final class Tests: XCTestCase { } }) - lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in + lifecycle.start { error in XCTAssertNil(error, "not expecting error") XCTAssertEqual(state, .started(expectedData), "expected item to be shutdown, but \(state)") lifecycle.shutdown() @@ -557,76 +490,3 @@ final class Tests: XCTestCase { XCTAssertEqual(state, .shutdown, "expected item to be shutdown, but \(state)") } } - -private class GoodItem: LifecycleItem { - let queue = DispatchQueue(label: "GoodItem", attributes: .concurrent) - let startDelay: Double - let shutdownDelay: Double - - var state = State.idle - let stateLock = Lock() - - init(startDelay: Double = Double.random(in: 0.01 ... 0.1), shutdownDelay: Double = Double.random(in: 0.01 ... 0.1)) { - self.startDelay = startDelay - self.shutdownDelay = shutdownDelay - } - - let label: String = UUID().uuidString - - func start(callback: @escaping (Error?) -> Void) { - self.queue.asyncAfter(deadline: .now() + self.startDelay) { - self.stateLock.withLock { self.state = .started } - callback(nil) - } - } - - func shutdown(callback: @escaping (Error?) -> Void) { - self.queue.asyncAfter(deadline: .now() + self.shutdownDelay) { - self.stateLock.withLock { self.state = .shutdown } - callback(nil) - } - } - - enum State { - case idle - case started - case shutdown - } -} - -private class NIOItem { - let id: String - let eventLoopGroup: EventLoopGroup - let startDelay: Int64 - let shutdownDelay: Int64 - - var state = State.idle - let stateLock = Lock() - - init(eventLoopGroup: EventLoopGroup, startDelay: Int64 = Int64.random(in: 10 ... 20), shutdownDelay: Int64 = Int64.random(in: 10 ... 20)) { - self.id = UUID().uuidString - self.eventLoopGroup = eventLoopGroup - self.startDelay = startDelay - self.shutdownDelay = shutdownDelay - } - - func start() -> EventLoopFuture { - return self.eventLoopGroup.next().scheduleTask(in: .milliseconds(self.startDelay)) { - self.stateLock.withLock { self.state = .started } - }.futureResult - } - - func shutdown() -> EventLoopFuture { - return self.eventLoopGroup.next().scheduleTask(in: .milliseconds(self.shutdownDelay)) { - self.stateLock.withLock { self.state = .shutdown } - }.futureResult - } - - enum State { - case idle - case started - case shutdown - } -} - -private struct TestError: Error {} diff --git a/Tests/ServiceLauncherTests/TopLevelTests+XCTest.swift b/Tests/ServiceLauncherTests/TopLevelTests+XCTest.swift new file mode 100644 index 0000000..bed76b1 --- /dev/null +++ b/Tests/ServiceLauncherTests/TopLevelTests+XCTest.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLauncher open source project +// +// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLauncher project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLauncher project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +// +// TopLevelTests+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 TopLevelTests { + static var allTests: [(String, (TopLevelTests) -> () throws -> Void)] { + return [ + ("testShutdownWithSignal", testShutdownWithSignal), + ("testStartAndWait", testStartAndWait), + ("testBadStartAndWait", testBadStartAndWait), + ("testNesting", testNesting), + ] + } +} diff --git a/Tests/ServiceLauncherTests/TopLevelTests.swift b/Tests/ServiceLauncherTests/TopLevelTests.swift new file mode 100644 index 0000000..20ff3e9 --- /dev/null +++ b/Tests/ServiceLauncherTests/TopLevelTests.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLauncher open source project +// +// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLauncher project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLauncher project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@testable import ServiceLauncher +import ServiceLauncherNIOCompat +import XCTest + +final class TopLevelTests: XCTestCase { + func testShutdownWithSignal() { + let signal = TopLevelLifecycle.Signal.ALRM + let items = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } + let lifecycle = TopLevelLifecycle() + lifecycle.register(items) + lifecycle.start(configuration: .init(shutdownSignal: [signal])) { error in + XCTAssertNil(error, "not expecting error") + kill(getpid(), signal.rawValue) + } + lifecycle.wait() + items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } + } + + func testStartAndWait() { + class Item: LifecycleItem { + private let semaphore: DispatchSemaphore + var state = State.idle + + init(_ semaphore: DispatchSemaphore) { + self.semaphore = semaphore + } + + var label: String { + return "\(self)" + } + + func start(callback: (Error?) -> Void) { + self.state = .started + self.semaphore.signal() + callback(nil) + } + + func shutdown(callback: (Error?) -> Void) { + self.state = .shutdown + callback(nil) + } + + enum State { + case idle + case started + case shutdown + } + } + + let lifecycle = TopLevelLifecycle() + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue(label: "test").asyncAfter(deadline: .now() + 0.1) { + semaphore.wait() + lifecycle.shutdown() + } + let item = Item(semaphore) + lifecycle.register(item) + XCTAssertNoThrow(try lifecycle.startAndWait(configuration: .init(shutdownSignal: nil))) + XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown") + } + + func testBadStartAndWait() { + class BadItem: LifecycleItem { + var label: String { + return "\(self)" + } + + func start(callback: (Error?) -> Void) { + callback(TestError()) + } + + func shutdown(callback: (Error?) -> Void) { + callback(nil) + } + } + + let lifecycle = TopLevelLifecycle() + lifecycle.register(GoodItem(), BadItem()) + XCTAssertThrowsError(try lifecycle.startAndWait(configuration: .init(shutdownSignal: nil))) { error in + XCTAssert(error is TestError, "expected error to match") + } + } + + func testNesting() { + let items1 = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } + let lifecycle1 = Lifecycle(label: "test1") + lifecycle1.register(items1) + + let items2 = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } + let lifecycle2 = Lifecycle(label: "test2") + lifecycle2.register(items2) + + let lifecycle = TopLevelLifecycle() + let items3 = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } + lifecycle.register([lifecycle1, lifecycle2] + items3) + + lifecycle.start { error in + XCTAssertNil(error, "not expecting error") + items1.forEach { XCTAssertEqual($0.state, .started, "expected item to be started, but \($0.state)") } + items2.forEach { XCTAssertEqual($0.state, .started, "expected item to be started, but \($0.state)") } + items3.forEach { XCTAssertEqual($0.state, .started, "expected item to be started, but \($0.state)") } + lifecycle.shutdown() + } + lifecycle.wait() + items1.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } + items2.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } + items3.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } + } +}