diff --git a/.spi.yml b/.spi.yml index 815fec5..4274536 100644 --- a/.spi.yml +++ b/.spi.yml @@ -1,4 +1,4 @@ version: 1 builder: configs: - - documentation_targets: [Lifecycle, LifecycleNIOCompat] + - documentation_targets: [ServiceLifecycle, UnixSignals] diff --git a/.swiftformat b/.swiftformat index ffccb20..754fc70 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,13 +1,26 @@ # file options ---swiftversion 5.0 +--swiftversion 5.7 --exclude .build # format options --self insert --patternlet inline +--ranges nospace --stripunusedargs unnamed-only --ifdef no-indent +--extensionacl on-declarations +--disable typeSugar # https://github.com/nicklockwood/SwiftFormat/issues/636 +--disable andOperator +--disable wrapMultilineStatementBraces +--disable enumNamespaces +--disable redundantExtensionACL +--disable redundantReturn +--disable preferKeyPath +--disable sortedSwitchCases +--disable hoistTry +--disable hoistAwait +--disable redundantOptionalBinding # rules diff --git a/NOTICE.txt b/NOTICE.txt new file mode 100644 index 0000000..d9b516c --- /dev/null +++ b/NOTICE.txt @@ -0,0 +1,36 @@ + +The ServiceLifecycle Project +=========================== + +Please visit the ServiceLifecycle web site for more information: + +* https://github.com/swift-server/swift-service-lifecycle + +Copyright 2019-2023 The ServiceLifecycle Project + +The ServiceLifecycle Project licenses this file to you under the Apache License, +version 2.0 (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at: + +https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. + +Also, please refer to each LICENSE.txt file, which is located in +the 'license' directory of the distribution file, for the license terms of the +components that this product depends on. + +--- + +This product contains derivations of the Lock and LockedValueBox implementations from SwiftNIO. + +* LICENSE (Apache License 2.0): +* https://www.apache.org/licenses/LICENSE-2.0 +* HOMEPAGE: +* https://github.com/apple/swift-nio + +--- diff --git a/Package.swift b/Package.swift index 039c974..01c65df 100644 --- a/Package.swift +++ b/Package.swift @@ -4,31 +4,83 @@ import PackageDescription let package = Package( name: "swift-service-lifecycle", + platforms: [ + .macOS(.v10_15), + .iOS(.v13), + .watchOS(.v6), + .tvOS(.v13), + ], products: [ - .library(name: "Lifecycle", targets: ["Lifecycle"]), - .library(name: "LifecycleNIOCompat", targets: ["LifecycleNIOCompat"]), + .library( + name: "ServiceLifecycle", + targets: ["ServiceLifecycle"] + ), + .library( + name: "ServiceLifecycleTestKit", + targets: ["ServiceLifecycleTestKit"] + ), + .library( + name: "UnixSignals", + targets: ["UnixSignals"] + ), ], dependencies: [ - .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/swift-server/swift-backtrace.git", from: "1.1.1"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), // used in tests - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"), + .package( + url: "https://github.com/apple/swift-log.git", + from: "1.5.2" + ), + .package( + url: "https://github.com/apple/swift-docc-plugin", + from: "1.0.0" + ), + .package( + url: "https://github.com/apple/swift-async-algorithms", + from: "0.1.0" + ), ], targets: [ - .target(name: "Lifecycle", dependencies: [ - .product(name: "Atomics", package: "swift-atomics"), - .product(name: "Logging", package: "swift-log"), - .product(name: "Metrics", package: "swift-metrics"), - .product(name: "Backtrace", package: "swift-backtrace"), - ]), - - .target(name: "LifecycleNIOCompat", dependencies: [ - "Lifecycle", - .product(name: "NIO", package: "swift-nio"), - ]), - - .testTarget(name: "LifecycleTests", dependencies: ["Lifecycle", "LifecycleNIOCompat"]), + .target( + name: "ServiceLifecycle", + dependencies: [ + .product( + name: "Logging", + package: "swift-log" + ), + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms" + ), + .target(name: "UnixSignals"), + .target(name: "ConcurrencyHelpers"), + ] + ), + .target( + name: "ServiceLifecycleTestKit", + dependencies: [ + .target(name: "ServiceLifecycle"), + ] + ), + .target( + name: "UnixSignals", + dependencies: [ + .target(name: "ConcurrencyHelpers"), + ] + ), + .target( + name: "ConcurrencyHelpers" + ), + .testTarget( + name: "ServiceLifecycleTests", + dependencies: [ + .target(name: "ServiceLifecycle"), + .target(name: "ServiceLifecycleTestKit"), + ] + ), + .testTarget( + name: "UnixSignalsTests", + dependencies: [ + .target(name: "UnixSignals"), + ] + ), ] ) diff --git a/Package@swift-5.0.swift b/Package@swift-5.0.swift deleted file mode 100644 index 7ca9b37..0000000 --- a/Package@swift-5.0.swift +++ /dev/null @@ -1,37 +0,0 @@ -// swift-tools-version:5.0 - -import PackageDescription - -let package = Package( - name: "swift-service-lifecycle", - products: [ - .library(name: "Lifecycle", targets: ["Lifecycle"]), - .library(name: "LifecycleNIOCompat", targets: ["LifecycleNIOCompat"]), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/swift-server/swift-backtrace.git", from: "1.1.1"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), // used in tests - ], - targets: [] -) - -#if compiler(>=5.3) -package.dependencies += [ - .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.0"), -] -package.targets += [ - .target(name: "Lifecycle", dependencies: ["Logging", "Metrics", "Backtrace", "Atomics"]), -] -#else -package.targets += [ - .target(name: "CLifecycleHelpers", dependencies: []), - .target(name: "Lifecycle", dependencies: ["CLifecycleHelpers", "Logging", "Metrics", "Backtrace"]), -] -#endif - -package.targets += [ - .target(name: "LifecycleNIOCompat", dependencies: ["Lifecycle", "NIO"]), - .testTarget(name: "LifecycleTests", dependencies: ["Lifecycle", "LifecycleNIOCompat"]), -] diff --git a/Package@swift-5.1.swift b/Package@swift-5.1.swift deleted file mode 100644 index 7ca9b37..0000000 --- a/Package@swift-5.1.swift +++ /dev/null @@ -1,37 +0,0 @@ -// swift-tools-version:5.0 - -import PackageDescription - -let package = Package( - name: "swift-service-lifecycle", - products: [ - .library(name: "Lifecycle", targets: ["Lifecycle"]), - .library(name: "LifecycleNIOCompat", targets: ["LifecycleNIOCompat"]), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/swift-server/swift-backtrace.git", from: "1.1.1"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), // used in tests - ], - targets: [] -) - -#if compiler(>=5.3) -package.dependencies += [ - .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.0"), -] -package.targets += [ - .target(name: "Lifecycle", dependencies: ["Logging", "Metrics", "Backtrace", "Atomics"]), -] -#else -package.targets += [ - .target(name: "CLifecycleHelpers", dependencies: []), - .target(name: "Lifecycle", dependencies: ["CLifecycleHelpers", "Logging", "Metrics", "Backtrace"]), -] -#endif - -package.targets += [ - .target(name: "LifecycleNIOCompat", dependencies: ["Lifecycle", "NIO"]), - .testTarget(name: "LifecycleTests", dependencies: ["Lifecycle", "LifecycleNIOCompat"]), -] diff --git a/Package@swift-5.2.swift b/Package@swift-5.2.swift deleted file mode 100644 index 7ca9b37..0000000 --- a/Package@swift-5.2.swift +++ /dev/null @@ -1,37 +0,0 @@ -// swift-tools-version:5.0 - -import PackageDescription - -let package = Package( - name: "swift-service-lifecycle", - products: [ - .library(name: "Lifecycle", targets: ["Lifecycle"]), - .library(name: "LifecycleNIOCompat", targets: ["LifecycleNIOCompat"]), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/swift-server/swift-backtrace.git", from: "1.1.1"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), // used in tests - ], - targets: [] -) - -#if compiler(>=5.3) -package.dependencies += [ - .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.0"), -] -package.targets += [ - .target(name: "Lifecycle", dependencies: ["Logging", "Metrics", "Backtrace", "Atomics"]), -] -#else -package.targets += [ - .target(name: "CLifecycleHelpers", dependencies: []), - .target(name: "Lifecycle", dependencies: ["CLifecycleHelpers", "Logging", "Metrics", "Backtrace"]), -] -#endif - -package.targets += [ - .target(name: "LifecycleNIOCompat", dependencies: ["Lifecycle", "NIO"]), - .testTarget(name: "LifecycleTests", dependencies: ["Lifecycle", "LifecycleNIOCompat"]), -] diff --git a/Package@swift-5.3.swift b/Package@swift-5.3.swift deleted file mode 100644 index 7ca9b37..0000000 --- a/Package@swift-5.3.swift +++ /dev/null @@ -1,37 +0,0 @@ -// swift-tools-version:5.0 - -import PackageDescription - -let package = Package( - name: "swift-service-lifecycle", - products: [ - .library(name: "Lifecycle", targets: ["Lifecycle"]), - .library(name: "LifecycleNIOCompat", targets: ["LifecycleNIOCompat"]), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/swift-server/swift-backtrace.git", from: "1.1.1"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), // used in tests - ], - targets: [] -) - -#if compiler(>=5.3) -package.dependencies += [ - .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.0"), -] -package.targets += [ - .target(name: "Lifecycle", dependencies: ["Logging", "Metrics", "Backtrace", "Atomics"]), -] -#else -package.targets += [ - .target(name: "CLifecycleHelpers", dependencies: []), - .target(name: "Lifecycle", dependencies: ["CLifecycleHelpers", "Logging", "Metrics", "Backtrace"]), -] -#endif - -package.targets += [ - .target(name: "LifecycleNIOCompat", dependencies: ["Lifecycle", "NIO"]), - .testTarget(name: "LifecycleTests", dependencies: ["Lifecycle", "LifecycleNIOCompat"]), -] diff --git a/Package@swift-5.4.swift b/Package@swift-5.4.swift deleted file mode 100644 index 7ca9b37..0000000 --- a/Package@swift-5.4.swift +++ /dev/null @@ -1,37 +0,0 @@ -// swift-tools-version:5.0 - -import PackageDescription - -let package = Package( - name: "swift-service-lifecycle", - products: [ - .library(name: "Lifecycle", targets: ["Lifecycle"]), - .library(name: "LifecycleNIOCompat", targets: ["LifecycleNIOCompat"]), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/swift-server/swift-backtrace.git", from: "1.1.1"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), // used in tests - ], - targets: [] -) - -#if compiler(>=5.3) -package.dependencies += [ - .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.0"), -] -package.targets += [ - .target(name: "Lifecycle", dependencies: ["Logging", "Metrics", "Backtrace", "Atomics"]), -] -#else -package.targets += [ - .target(name: "CLifecycleHelpers", dependencies: []), - .target(name: "Lifecycle", dependencies: ["CLifecycleHelpers", "Logging", "Metrics", "Backtrace"]), -] -#endif - -package.targets += [ - .target(name: "LifecycleNIOCompat", dependencies: ["Lifecycle", "NIO"]), - .testTarget(name: "LifecycleTests", dependencies: ["Lifecycle", "LifecycleNIOCompat"]), -] diff --git a/Package@swift-5.5.swift b/Package@swift-5.5.swift deleted file mode 100644 index 7ca9b37..0000000 --- a/Package@swift-5.5.swift +++ /dev/null @@ -1,37 +0,0 @@ -// swift-tools-version:5.0 - -import PackageDescription - -let package = Package( - name: "swift-service-lifecycle", - products: [ - .library(name: "Lifecycle", targets: ["Lifecycle"]), - .library(name: "LifecycleNIOCompat", targets: ["LifecycleNIOCompat"]), - ], - dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), - .package(url: "https://github.com/apple/swift-metrics.git", "1.0.0" ..< "3.0.0"), - .package(url: "https://github.com/swift-server/swift-backtrace.git", from: "1.1.1"), - .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), // used in tests - ], - targets: [] -) - -#if compiler(>=5.3) -package.dependencies += [ - .package(url: "https://github.com/apple/swift-atomics.git", from: "1.0.0"), -] -package.targets += [ - .target(name: "Lifecycle", dependencies: ["Logging", "Metrics", "Backtrace", "Atomics"]), -] -#else -package.targets += [ - .target(name: "CLifecycleHelpers", dependencies: []), - .target(name: "Lifecycle", dependencies: ["CLifecycleHelpers", "Logging", "Metrics", "Backtrace"]), -] -#endif - -package.targets += [ - .target(name: "LifecycleNIOCompat", dependencies: ["Lifecycle", "NIO"]), - .testTarget(name: "LifecycleTests", dependencies: ["Lifecycle", "LifecycleNIOCompat"]), -] diff --git a/README.md b/README.md index 4e4ac0a..afbc62b 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ Swift Service Lifecycle provides a basic mechanism to cleanly start up and shut It also provides a `Signal`-based shutdown hook, to shut down on signals like `TERM` or `INT`. Swift Service Lifecycle was designed with the idea that every application has some startup and shutdown workflow-like-logic which is often sensitive to failure and hard to get right. -The library codes this common need in a safe and reusable way that is non-framework specific, and designed to be integrated with any server framework or directly in an application. +The library codes this common need in a safe and reusable way that is non-framework specific, and designed to be integrated with any server framework or directly in an application. Furthermore, it integrates natively with Structured Concurrency. -This is the beginning of a community-driven open-source project actively seeking [contributions](CONTRIBUTING.md), be it code, documentation, or ideas. What Swift Service Lifecycle provides today is covered in the [API docs](https://swift-server.github.io/swift-service-lifecycle/), but it will continue to evolve with community input. +This is the beginning of a community-driven open-source project actively seeking [contributions](CONTRIBUTING.md), be it code, documentation, or ideas. What Swift Service Lifecycle provides today is covered in the [API docs](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/main/documentation/lifecycle), but it will continue to evolve with community input. ## Getting started @@ -17,296 +17,53 @@ If you have a server-side Swift application or a cross-platform (e.g. Linux, mac To add a dependency on the package, declare it in your `Package.swift`: ```swift -.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "1.0.0-alpha"), +.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "1.0.0-alpha.2"), ``` -and to your application target, add `Lifecycle` to your dependencies: +and to your application target, add `ServiceLifecycle` to your dependencies: ```swift -.target(name: "MyApplication", dependencies: [.product(name: "Lifecycle", package: "swift-service-lifecycle")]), +.target(name: "MyApplication", dependencies: [.product(name: "ServiceLifecycle", package: "swift-service-lifecycle")]), ``` -### Defining the lifecycle +### Using ServiceLifecycle -```swift -// import the package -import Lifecycle - -// initialize the lifecycle container -let lifecycle = ServiceLifecycle() - -// register a resource that should be shut down when the application exits. -// -// in this case, we are registering a SwiftNIO `EventLoopGroup` -// and passing its `syncShutdownGracefully` function to be called on shutdown -let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) -lifecycle.registerShutdown( - label: "eventLoopGroup", - .sync(eventLoopGroup.syncShutdownGracefully) -) - -// register another resource that should be started when the application starts -// and shut down when the application exits. -// -// in this case, we are registering a contrived `DatabaseMigrator` -// and passing its `migrate` function to be called on startup -// and `shutdown` function to be called on shutdown -let migrator = DatabaseMigrator() -lifecycle.register( - label: "migrator", - start: .async(migrator.migrate), - shutdown: .async(migrator.shutdown) -) - -// start the application -// -// start handlers passed using the `register` function -// will be called in the order they were registered in -lifecycle.start { error in - // start completion handler. - // if a startup error occurred you can capture it here - if let error = error { - logger.error("failed starting \(self) ☠️: \(error)") - } else { - logger.info("\(self) started successfully 🚀") - } -} -// wait for the application to exit -// -// this is a blocking operation that typically waits for a signal -// the signal can be configured at `lifecycle.start`, and defaults to `INT` and `TERM` -// shutdown handlers passed using the `register` or `registerShutdown` functions -// will be called in the reverse order they were registered in -lifecycle.wait() -``` - -## Detailed design - -The main types in the library are `ServiceLifecycle` and `ComponentLifecycle`. - -`ServiceLifecycle` is the most commonly used type. -It is designed to manage the top-level Application (Service) lifecycle, -and in addition to managing the startup and shutdown flows it can also set up `Signal` trap for shutdown and install backtraces. - -`ComponentLifecycle` manages a state machine representing the startup and shutdown logic flow. -In larger Applications (Services) `ComponentLifecycle` can be used to manage the lifecycle of subsystems, such that `ServiceLifecycle` can start and shutdown `ComponentLifecycle`s. - -### Registering items +Below is a short usage example however you can find detailed documentation on how to use ServiceLifecycle over [here](https://swiftpackageindex.com/swift-server/swift-service-lifecycle/main/documentation/lifecycle). -`ServiceLifecycle` and `ComponentLifecycle` are containers for `LifecycleTask`s which need to be registered using a `LifecycleHandler` - a container for synchronous or asynchronous closures. - -Synchronous handlers are defined as `() throws -> Void`. - -Asynchronous handlers defined are as `(@escaping (Error?) -> Void) -> Void`. - -`LifecycleHandler` comes with static helpers named `async` and `sync` designed to help simplify the registration call to: - -```swift -let foo = ... -lifecycle.register( - label: "foo", - start: .sync(foo.syncStart), - shutdown: .sync(foo.syncShutdown) -) -``` - -Or the async version: - -```swift -let foo = ... -lifecycle.register( - label: "foo", - start: .async(foo.asyncStart), - shutdown: .async(foo.asyncShutdown) -) -``` - -or, just shutdown: +ServiceLifecycle consists of two main building blocks. First, the `Service` protocol and secondly +the `ServiceGroup`. As a library or application developer you should model your long-running work +as services that implement the `Service` protocol. The protocol only requires a single `func run() async throws` +method to be implemented. +Afterwards, in your application you can use the `ServiceGroup` to orchestrate multiple services. +The group will spawn a child task for each service and call the respective `run` method in the child task. +Furthermore, the group will setup signal listeners for the configured signals and trigger a graceful shutdown +on each service. ```swift -let foo = ... -lifecycle.registerShutdown( - label: "foo", - .sync(foo.syncShutdown) -) -``` -Or the async version: - -```swift -let foo = ... -lifecycle.registerShutdown( - label: "foo", - .async(foo.asyncShutdown) -) -``` - -you can also register a collection of `LifecycleTask`s (less typical) using: - -```swift -func register(_ tasks: [LifecycleTask]) - -func register(_ tasks: LifecycleTask...) -``` - -### Configuration - -`ServiceLifecycle` initializer takes optional `ServiceLifecycle.Configuration` to further refine the `ServiceLifecycle` behavior: - -* `logger`: Defines the `Logger` to work with. By default, `Logger(label: "Lifecycle")` is used. - -* `callbackQueue`: Defines the `DispatchQueue` on which startup and shutdown handlers are executed. By default, `DispatchQueue.global` is used. - -* `shutdownSignal`: Defines what, if any, signals to trap for invoking shutdown. By default, `INT` and `TERM` are trapped. - -* `installBacktrace`: Defines if to install a crash signal trap that prints backtraces. This is especially useful for applications running on Linux since Swift does not provide backtraces on Linux out of the box. This functionality is provided via the [Swift Backtrace](https://github.com/swift-server/swift-backtrace) library. - -### Starting the lifecycle - -Use the `start` function to start the application. -Start handlers passed using the `register` function will be called in the order the items were registered in. - -`start` is an asynchronous operation. -If a startup error occurred, it will be logged and the startup sequence will halt on the first error, and bubble it up to the provided completion handler. - -```swift -lifecycle.start { error in - if let error = error { - logger.error("failed starting \(self) ☠️: \(error)") - } else { - logger.info("\(self) started successfully 🚀") +actor FooService { + func run() async throws { + print("FooService starting") + try await Task.sleep(for: .seconds(10)) + print("FooService done") } } -``` - -### Shutdown -The typical use of the library is to call on `wait` after calling `start`. - -```swift -lifecycle.start { error in - ... -} -lifecycle.wait() // <-- blocks the thread -``` - -If you are not interested in handling start completion, there is also a convenience method: - -```swift -lifecycle.startAndWait() // <-- blocks the thread -``` - -Both `wait` and `startAndWait` are blocking operations that wait for the lifecycle library to finish the shutdown sequence. -The shutdown sequence is typically triggered by the `shutdownSignal` defined in the configuration. By default, `INT` and `TERM` are trapped. - -During shutdown, the shutdown handlers passed using the `register` or `registerShutdown` functions are called in the reverse order of the registration. E.g. - -``` -lifecycle.register("1", ...) -lifecycle.register("2", ...) -lifecycle.register("3", ...) -``` - -startup order will be 1, 2, 3 and shutdown order will be 3, 2, 1. - -If a shutdown error occurred, it will be logged and the shutdown sequence will *continue* to the next item, and attempt to shut it down until all registered items that have been started are shut down. - -In more complex cases, when `Signal`-trapping-based shutdown is not appropriate, you may pass `nil` as the `shutdownSignal` configuration, and call `shutdown` manually when appropriate. This is designed to be a rarely used pressure valve. - -`shutdown` is an asynchronous operation. Errors will be logged and bubbled up to the provided completion handler. - -### Stateful handlers - -In some cases it is useful to have the Start handlers return a state that can be passed on to the Shutdown handlers for shutdown. -For example, when establishing some sort of a connection that needs to be closed at shutdown. - -```swift -struct Foo { - func start() throws -> Connection { - return ... - } - - func shutdown(state: Connection) throws { - ... - } -} -``` - -```swift -let foo = ... -lifecycle.registerStateful( - label: "foo", - start: .sync(foo.start), - shutdown: .sync(foo.shutdown) -) -``` - -### Complex Systems and Nesting of Subsystems - -In larger Applications (Services) `ComponentLifecycle` can be used to manage the lifecycle of subsystems, such that `ServiceLifecycle` can start and shutdown `ComponentLifecycle`s. - -In fact, since `ComponentLifecycle` conforms to `LifecycleTask`, -it can start and stop other `ComponentLifecycle`s, forming a tree. E.g.: - -```swift -struct SubSystem { - let lifecycle = ComponentLifecycle(label: "SubSystem") - let subsystem: SubSubSystem - - init() { - self.subsystem = SubSubSystem() - self.lifecycle.register(self.subsystem.lifecycle) +@main +struct Application { + static func main() async throws { + let service1 = FooService() + let service2 = FooService() + + let serviceGroup = ServiceGroup( + services: [service1, service2], + configuration: .init(gracefulShutdownSignals: [.sigterm]) + ) + try await serviceGroup.run() } - - struct SubSubSystem { - let lifecycle = ComponentLifecycle(label: "SubSubSystem") - - init() { - self.lifecycle.register(...) - } - } -} - -let lifecycle = ServiceLifecycle() -let subsystem = SubSystem() -lifecycle.register(subsystem.lifecycle) - -lifecycle.start { error in - ... } -lifecycle.wait() -``` - -### Compatibility with SwiftNIO Futures - -[SwiftNIO](https://github.com/apple/swift-nio) is a popular networking library that among other things provides Future abstraction named `EventLoopFuture`. - -Swift Service Lifecycle comes with a compatibility module designed to make managing SwiftNIO based resources easy. - -Once you import `LifecycleNIOCompat` module, `LifecycleHandler` gains a static helper named `eventLoopFuture` designed to help simplify the registration call to: - -```swift -let foo = ... -lifecycle.register( - label: "foo", - start: .eventLoopFuture(foo.start), - shutdown: .eventLoopFuture(foo.shutdown) -) -``` - -or, just shutdown: -```swift -let foo = ... -lifecycle.registerShutdown( - label: "foo", - .eventLoopFuture(foo.shutdown) -) ``` -------- - -Do not hesitate to get in touch as well, over on https://forums.swift.org/c/server. - ## Security Please see [SECURITY.md](SECURITY.md) for details on the security process. diff --git a/Sources/CLifecycleHelpers/CLifecycleAtomics.c b/Sources/CLifecycleHelpers/CLifecycleAtomics.c deleted file mode 100644 index 6c337c6..0000000 --- a/Sources/CLifecycleHelpers/CLifecycleAtomics.c +++ /dev/null @@ -1,29 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2021 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#include - -#include -#include - -struct c_lifecycle_atomic_bool *c_lifecycle_atomic_bool_create(bool value) { - struct c_lifecycle_atomic_bool *wrapper = malloc(sizeof(*wrapper)); - atomic_init(&wrapper->value, value); - return wrapper; -} - -bool c_lifecycle_atomic_bool_compare_and_exchange(struct c_lifecycle_atomic_bool *wrapper, bool expected, bool desired) { - bool expected_copy = expected; - return atomic_compare_exchange_strong(&wrapper->value, &expected_copy, desired); -} diff --git a/Sources/CLifecycleHelpers/include/CLifecycleAtomics.h b/Sources/CLifecycleHelpers/include/CLifecycleAtomics.h deleted file mode 100644 index 9c37680..0000000 --- a/Sources/CLifecycleHelpers/include/CLifecycleAtomics.h +++ /dev/null @@ -1,23 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2021 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#include -#include - -struct c_lifecycle_atomic_bool { - _Atomic bool value; -}; -struct c_lifecycle_atomic_bool * _Nonnull c_lifecycle_atomic_bool_create(bool value); - -bool c_lifecycle_atomic_bool_compare_and_exchange(struct c_lifecycle_atomic_bool * _Nonnull atomic, bool expected, bool desired); diff --git a/Sources/ConcurrencyHelpers/Lock.swift b/Sources/ConcurrencyHelpers/Lock.swift new file mode 100644 index 0000000..d8cf33e --- /dev/null +++ b/Sources/ConcurrencyHelpers/Lock.swift @@ -0,0 +1,259 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2017-2022 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 +// +//===----------------------------------------------------------------------===// + +#if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) +import Darwin +#elseif os(Windows) +import ucrt +import WinSDK +#else +import Glibc +#endif + +#if os(Windows) +@usableFromInline +typealias LockPrimitive = SRWLOCK +#else +@usableFromInline +typealias LockPrimitive = pthread_mutex_t +#endif + +@usableFromInline +enum LockOperations {} + +extension LockOperations { + @inlinable + static func create(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + + #if os(Windows) + InitializeSRWLock(mutex) + #else + var attr = pthread_mutexattr_t() + pthread_mutexattr_init(&attr) + + let err = pthread_mutex_init(mutex, &attr) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") + #endif + } + + @inlinable + static func destroy(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + + #if os(Windows) + // SRWLOCK does not need to be free'd + #else + let err = pthread_mutex_destroy(mutex) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") + #endif + } + + @inlinable + static func lock(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + + #if os(Windows) + AcquireSRWLockExclusive(mutex) + #else + let err = pthread_mutex_lock(mutex) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") + #endif + } + + @inlinable + static func unlock(_ mutex: UnsafeMutablePointer) { + mutex.assertValidAlignment() + + #if os(Windows) + ReleaseSRWLockExclusive(mutex) + #else + let err = pthread_mutex_unlock(mutex) + precondition(err == 0, "\(#function) failed in pthread_mutex with error \(err)") + #endif + } +} + +// Tail allocate both the mutex and a generic value using ManagedBuffer. +// Both the header pointer and the elements pointer are stable for +// the class's entire lifetime. +// +// However, for safety reasons, we elect to place the lock in the "elements" +// section of the buffer instead of the head. The reasoning here is subtle, +// so buckle in. +// +// _As a practical matter_, the implementation of ManagedBuffer ensures that +// the pointer to the header is stable across the lifetime of the class, and so +// each time you call `withUnsafeMutablePointers` or `withUnsafeMutablePointerToHeader` +// the value of the header pointer will be the same. This is because ManagedBuffer uses +// `Builtin.addressOf` to load the value of the header, and that does ~magic~ to ensure +// that it does not invoke any weird Swift accessors that might copy the value. +// +// _However_, the header is also available via the `.header` field on the ManagedBuffer. +// This presents a problem! The reason there's an issue is that `Builtin.addressOf` and friends +// do not interact with Swift's exclusivity model. That is, the various `with` functions do not +// conceptually trigger a mutating access to `.header`. For elements this isn't a concern because +// there's literally no other way to perform the access, but for `.header` it's entirely possible +// to accidentally recursively read it. +// +// Our implementation is free from these issues, so we don't _really_ need to worry about it. +// However, out of an abundance of caution, we store the Value in the header, and the LockPrimitive +// in the trailing elements. We still don't use `.header`, but it's better to be safe than sorry, +// and future maintainers will be happier that we were cautious. +// +// See also: https://github.com/apple/swift/pull/40000 +@usableFromInline +final class LockStorage: ManagedBuffer { + @inlinable + static func create(value: Value) -> Self { + let buffer = Self.create(minimumCapacity: 1) { _ in + return value + } + let storage = unsafeDowncast(buffer, to: Self.self) + + storage.withUnsafeMutablePointers { _, lockPtr in + LockOperations.create(lockPtr) + } + + return storage + } + + @inlinable + func lock() { + self.withUnsafeMutablePointerToElements { lockPtr in + LockOperations.lock(lockPtr) + } + } + + @inlinable + func unlock() { + self.withUnsafeMutablePointerToElements { lockPtr in + LockOperations.unlock(lockPtr) + } + } + + @inlinable + deinit { + self.withUnsafeMutablePointerToElements { lockPtr in + LockOperations.destroy(lockPtr) + } + } + + @inlinable + func withLockPrimitive(_ body: (UnsafeMutablePointer) throws -> T) rethrows -> T { + try self.withUnsafeMutablePointerToElements { lockPtr in + return try body(lockPtr) + } + } + + @inlinable + func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { + try self.withUnsafeMutablePointers { valuePtr, lockPtr in + LockOperations.lock(lockPtr) + defer { LockOperations.unlock(lockPtr) } + return try mutate(&valuePtr.pointee) + } + } +} + +extension LockStorage: @unchecked Sendable {} + +/// A threading lock based on `libpthread` instead of `libdispatch`. +/// +/// - note: ``Lock`` has reference semantics. +/// +/// This object provides a lock on top of a single `pthread_mutex_t`. This kind +/// of lock is safe to use with `libpthread`-based threading models, such as the +/// one used by . On Windows, the lock is based on the substantially similar +/// `SRWLOCK` type. +public struct Lock { + @usableFromInline + internal let _storage: LockStorage + + /// Create a new lock. + @inlinable + public init() { + self._storage = .create(value: ()) + } + + /// Acquire the lock. + /// + /// Whenever possible, consider using `withLock` instead of this method and + /// `unlock`, to simplify lock handling. + @inlinable + public func lock() { + self._storage.lock() + } + + /// Release the lock. + /// + /// Whenever possible, consider using `withLock` instead of this method and + /// `lock`, to simplify lock handling. + @inlinable + public func unlock() { + self._storage.unlock() + } + + @inlinable + internal func withLockPrimitive(_ body: (UnsafeMutablePointer) throws -> T) rethrows -> T { + return try self._storage.withLockPrimitive(body) + } +} + +extension Lock { + /// Acquire the lock for the duration of the given block. + /// + /// This convenience method should be preferred to `lock` and `unlock` in + /// most situations, as it ensures that the lock will be released regardless + /// of how `body` exits. + /// + /// - Parameter body: The block to execute while holding the lock. + /// - Returns: The value returned by the block. + @inlinable + public func withLock(_ body: () throws -> T) rethrows -> T { + self.lock() + defer { + self.unlock() + } + return try body() + } + + @inlinable + public func withLockVoid(_ body: () throws -> Void) rethrows { + try self.withLock(body) + } +} + +extension Lock: Sendable {} + +extension UnsafeMutablePointer { + @inlinable + func assertValidAlignment() { + assert(UInt(bitPattern: self) % UInt(MemoryLayout.alignment) == 0) + } +} diff --git a/Sources/ConcurrencyHelpers/LockedValueBox.swift b/Sources/ConcurrencyHelpers/LockedValueBox.swift new file mode 100644 index 0000000..e1a5ea0 --- /dev/null +++ b/Sources/ConcurrencyHelpers/LockedValueBox.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftNIO open source project +// +// Copyright (c) 2017-2018 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 +// +//===----------------------------------------------------------------------===// + +/// Provides locked access to `Value`. +/// +/// - note: ``LockedValueBox`` has reference semantics and holds the `Value` +/// alongside a lock behind a reference. +/// +/// This is no different than creating a ``Lock`` and protecting all +/// accesses to a value using the lock. But it's easy to forget to actually +/// acquire/release the lock in the correct place. ``LockedValueBox`` makes +/// that much easier. +public struct LockedValueBox { + @usableFromInline + internal let _storage: LockStorage + + /// Initialize the `Value`. + @inlinable + public init(_ value: Value) { + self._storage = .create(value: value) + } + + /// Access the `Value`, allowing mutation of it. + @inlinable + public func withLockedValue(_ mutate: (inout Value) throws -> T) rethrows -> T { + return try self._storage.withLockedValue(mutate) + } +} + +extension LockedValueBox: Sendable where Value: Sendable {} diff --git a/Sources/Lifecycle/Atomics.swift b/Sources/Lifecycle/Atomics.swift deleted file mode 100644 index 868e4d2..0000000 --- a/Sources/Lifecycle/Atomics.swift +++ /dev/null @@ -1,53 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2021 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if canImport(Atomics) -#if swift(>=5.1) -@_implementationOnly import Atomics -#else -import Atomics -#endif -#else -import CLifecycleHelpers -#endif - -internal class AtomicBoolean { - #if canImport(Atomics) - private let managed: ManagedAtomic - #else - private let unmanaged: UnsafeMutablePointer - #endif - - init(_ value: Bool) { - #if canImport(Atomics) - self.managed = .init(value) - #else - self.unmanaged = c_lifecycle_atomic_bool_create(value) - #endif - } - - deinit { - #if !canImport(Atomics) - self.unmanaged.deinitialize(count: 1) - #endif - } - - func compareAndSwap(expected: Bool, desired: Bool) -> Bool { - #if canImport(Atomics) - return self.managed.compareExchange(expected: expected, desired: desired, ordering: .acquiring).exchanged - #else - return c_lifecycle_atomic_bool_compare_and_exchange(self.unmanaged, expected, desired) - #endif - } -} diff --git a/Sources/Lifecycle/Docs.docc/index.md b/Sources/Lifecycle/Docs.docc/index.md deleted file mode 100644 index b03fb4e..0000000 --- a/Sources/Lifecycle/Docs.docc/index.md +++ /dev/null @@ -1,311 +0,0 @@ -# ``Lifecycle`` - -Swift Service Lifecycle provides a basic mechanism to cleanly start up and shut down the application, freeing resources in order before exiting. -It also provides a `Signal`-based shutdown hook, to shut down on signals like `TERM` or `INT`. - -## Overview - -Swift Service Lifecycle was designed with the idea that every application has some startup and shutdown workflow-like-logic which is often sensitive to failure and hard to get right. -The library codes this common need in a safe and reusable way that is non-framework specific, and designed to be integrated with any server framework or directly in an application. - -## Getting started - -If you have a server-side Swift application or a cross-platform (e.g. Linux, macOS) application, and you would like to manage its startup and shutdown lifecycle, Swift Service Lifecycle is a great idea. Below you will find all you need to know to get started. - -### Adding the dependency - -To add a dependency on the package, declare it in your `Package.swift`: - -```swift -.package(url: "https://github.com/swift-server/swift-service-lifecycle.git", from: "1.0.0-alpha"), -``` - -and to your application target, add `Lifecycle` to your dependencies: - -```swift -.target(name: "MyApplication", dependencies: [.product(name: "Lifecycle", package: "swift-service-lifecycle")]), -``` - -### Defining the lifecycle - -```swift -// import the package -import Lifecycle - -// initialize the lifecycle container -let lifecycle = ServiceLifecycle() - -// register a resource that should be shut down when the application exits. -// -// in this case, we are registering a SwiftNIO `EventLoopGroup` -// and passing its `syncShutdownGracefully` function to be called on shutdown -let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) -lifecycle.registerShutdown( - label: "eventLoopGroup", - .sync(eventLoopGroup.syncShutdownGracefully) -) - -// register another resource that should be started when the application starts -// and shut down when the application exits. -// -// in this case, we are registering a contrived `DatabaseMigrator` -// and passing its `migrate` function to be called on startup -// and `shutdown` function to be called on shutdown -let migrator = DatabaseMigrator() -lifecycle.register( - label: "migrator", - start: .async(migrator.migrate), - shutdown: .async(migrator.shutdown) -) - -// start the application -// -// start handlers passed using the `register` function -// will be called in the order they were registered in -lifecycle.start { error in - // start completion handler. - // if a startup error occurred you can capture it here - if let error = error { - logger.error("failed starting \(self) ☠️: \(error)") - } else { - logger.info("\(self) started successfully 🚀") - } -} -// wait for the application to exit -// -// this is a blocking operation that typically waits for a signal -// the signal can be configured at `lifecycle.start`, and defaults to `INT` and `TERM` -// shutdown handlers passed using the `register` or `registerShutdown` functions -// will be called in the reverse order they were registered in -lifecycle.wait() -``` - -## Detailed design - -The main types in the library are ``ServiceLifecycle`` and ``ComponentLifecycle``. - -``ServiceLifecycle`` is the most commonly used type. -It is designed to manage the top-level Application (Service) lifecycle, -and in addition to managing the startup and shutdown flows it can also set up `Signal` trap for shutdown and install backtraces. - -``ComponentLifecycle`` manages a state machine representing the startup and shutdown logic flow. -In larger Applications (Services) ``ComponentLifecycle`` can be used to manage the lifecycle of subsystems, such that ``ServiceLifecycle`` can start and shutdown ``ComponentLifecycle``s. - -### Registering items - -``ServiceLifecycle`` and ``ComponentLifecycle`` are containers for ``LifecycleTask``s which need to be registered using a ``LifecycleHandler`` - a container for synchronous or asynchronous closures. - -Synchronous handlers are defined as `() throws -> Void`. - -Asynchronous handlers defined are as `(@escaping (Error?) -> Void) -> Void`. - -``LifecycleHandler`` comes with static helpers named ``LifecycleHandler/async(_:)-9say8`` and ``LifecycleHandler/sync(_:)`` designed to help simplify the registration call to: - -```swift -let foo = ... -lifecycle.register( - label: "foo", - start: .sync(foo.syncStart), - shutdown: .sync(foo.syncShutdown) -) -``` - -Or the async version: - -```swift -let foo = ... -lifecycle.register( - label: "foo", - start: .async(foo.asyncStart), - shutdown: .async(foo.asyncShutdown) -) -``` - -or, just shutdown: - -```swift -let foo = ... -lifecycle.registerShutdown( - label: "foo", - .sync(foo.syncShutdown) -) -``` -Or the async version: - -```swift -let foo = ... -lifecycle.registerShutdown( - label: "foo", - .async(foo.asyncShutdown) -) -``` - -you can also register a collection of ``LifecycleTask``s (less typical) using: - -```swift -func register(_ tasks: [LifecycleTask]) - -func register(_ tasks: LifecycleTask...) -``` - -### Configuration - -``ServiceLifecycle`` initializer takes optional ``ServiceLifecycle/Configuration`` to further refine the ``ServiceLifecycle`` behavior: - -* `logger`: Defines the `Logger` to work with. By default, `Logger(label: "Lifecycle")` is used. - -* `callbackQueue`: Defines the `DispatchQueue` on which startup and shutdown handlers are executed. By default, `DispatchQueue.global` is used. - -* `shutdownSignal`: Defines what, if any, signals to trap for invoking shutdown. By default, `INT` and `TERM` are trapped. - -* `installBacktrace`: Defines if to install a crash signal trap that prints backtraces. This is especially useful for applications running on Linux since Swift does not provide backtraces on Linux out of the box. This functionality is provided via the [Swift Backtrace](https://github.com/swift-server/swift-backtrace) library. - -### Starting the lifecycle - -Use ``ServiceLifecycle/start(_:)`` to start the application. -Start handlers passed using ``ServiceLifecycle/register(label:start:shutdown:shutdownIfNotStarted:)`` will be called in the order the items were registered in. - -``ServiceLifecycle/start(_:)`` is an asynchronous operation. -If a startup error occurred, it will be logged and the startup sequence will halt on the first error, and bubble it up to the provided completion handler. - -```swift -lifecycle.start { error in - if let error = error { - logger.error("failed starting \(self) ☠️: \(error)") - } else { - logger.info("\(self) started successfully 🚀") - } -} -``` - -### Shutdown - -The typical use of the library is to call on ``ServiceLifecycle/wait()`` after calling ``ServiceLifecycle/start(_:)``. - -```swift -lifecycle.start { error in - ... -} -lifecycle.wait() // <-- blocks the thread -``` - -If you are not interested in handling start completion, there is also a convenience method: - -```swift -lifecycle.startAndWait() // <-- blocks the thread -``` - -Both ``ServiceLifecycle/wait()`` and ``ServiceLifecycle/startAndWait()`` are blocking operations that wait for the lifecycle library to finish the shutdown sequence. -The shutdown sequence is typically triggered by the ``ServiceLifecycle/Configuration/shutdownSignal`` defined in the configuration. By default, `INT` and `TERM` are trapped. - -During shutdown, the shutdown handlers passed using ``ServiceLifecycle/register(label:start:shutdown:shutdownIfNotStarted:)`` or ``ServiceLifecycle/registerShutdown(label:_:)`` are called in the reverse order of the registration. E.g. - -``` -lifecycle.register("1", ...) -lifecycle.register("2", ...) -lifecycle.register("3", ...) -``` - -startup order will be 1, 2, 3 and shutdown order will be 3, 2, 1. - -If a shutdown error occurred, it will be logged and the shutdown sequence will *continue* to the next item, and attempt to shut it down until all registered items that have been started are shut down. - -In more complex cases, when `Signal`-trapping-based shutdown is not appropriate, you may pass `nil` as the ``ServiceLifecycle/Configuration/shutdownSignal``, and call ``ServiceLifecycle/shutdown(_:)`` manually when appropriate. This is designed to be a rarely used pressure valve. - -``ServiceLifecycle/shutdown(_:)`` is an asynchronous operation. Errors will be logged and bubbled up to the provided completion handler. - -### Stateful handlers - -In some cases it is useful to have the Start handlers return a state that can be passed on to the Shutdown handlers for shutdown. -For example, when establishing some sort of a connection that needs to be closed at shutdown. - -```swift -struct Foo { - func start() throws -> Connection { - return ... - } - - func shutdown(state: Connection) throws { - ... - } -} -``` - -```swift -let foo = ... -lifecycle.registerStateful( - label: "foo", - start: .sync(foo.start), - shutdown: .sync(foo.shutdown) -) -``` - -### Complex systems and nesting of subsystems - -In larger Applications (Services) ``ComponentLifecycle`` can be used to manage the lifecycle of subsystems, such that ``ServiceLifecycle`` can start and shutdown ``ComponentLifecycle``s. - -In fact, since ``ComponentLifecycle`` conforms to ``LifecycleTask``, -it can start and stop other ``ComponentLifecycle``s, forming a tree. E.g.: - -```swift -struct SubSystem { - let lifecycle = ComponentLifecycle(label: "SubSystem") - let subsystem: SubSubSystem - - init() { - self.subsystem = SubSubSystem() - self.lifecycle.register(self.subsystem.lifecycle) - } - - struct SubSubSystem { - let lifecycle = ComponentLifecycle(label: "SubSubSystem") - - init() { - self.lifecycle.register(...) - } - } -} - -let lifecycle = ServiceLifecycle() -let subsystem = SubSystem() -lifecycle.register(subsystem.lifecycle) - -lifecycle.start { error in - ... -} -lifecycle.wait() -``` - -### Compatibility with SwiftNIO Futures - -[SwiftNIO](https://github.com/apple/swift-nio) is a popular networking library that among other things provides Future abstraction named `EventLoopFuture`. - -Swift Service Lifecycle comes with a compatibility module designed to make managing SwiftNIO based resources easy. - -Once you import `LifecycleNIOCompat` module, ``LifecycleHandler`` gains a static helper named `eventLoopFuture` designed to help simplify the registration call to: - -```swift -let foo = ... -lifecycle.register( - label: "foo", - start: .eventLoopFuture(foo.start), - shutdown: .eventLoopFuture(foo.shutdown) -) -``` - -or, just shutdown: - -```swift -let foo = ... -lifecycle.registerShutdown( - label: "foo", - .eventLoopFuture(foo.shutdown) -) -``` - -## Topics - -### Main types - -- ``ServiceLifecycle`` -- ``ComponentLifecycle`` diff --git a/Sources/Lifecycle/Lifecycle.swift b/Sources/Lifecycle/Lifecycle.swift deleted file mode 100644 index a461112..0000000 --- a/Sources/Lifecycle/Lifecycle.swift +++ /dev/null @@ -1,922 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2019-2022 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -#if os(macOS) || os(iOS) || os(watchOS) || os(tvOS) -import Darwin -#else -import Glibc -#endif -import Backtrace -import CoreMetrics -import Dispatch -import Logging - -// MARK: - LifecycleTask - -/// Represents an item that can be started and shut down. -public protocol LifecycleTask { - var label: String { get } - var shutdownIfNotStarted: Bool { get } - func start(_ callback: @escaping (Error?) -> Void) - func shutdown(_ callback: @escaping (Error?) -> Void) - var logStart: Bool { get } - var logShutdown: Bool { get } -} - -extension LifecycleTask { - public var shutdownIfNotStarted: Bool { - return false - } - - public var logStart: Bool { - return true - } - - public var logShutdown: Bool { - return true - } -} - -// MARK: - LifecycleHandler - -/// Supported startup and shutdown method styles. -public struct LifecycleHandler { - @available(*, deprecated) - public typealias Callback = (@escaping (Error?) -> Void) -> Void - - private let underlying: ((@escaping (Error?) -> Void) -> Void)? - - /// Initialize a ``LifecycleHandler`` based on a completion handler. - /// - /// - parameters: - /// - handler: the underlying completion handler - public init(_ handler: ((@escaping (Error?) -> Void) -> Void)?) { - self.underlying = handler - } - - /// Asynchronous ``LifecycleHandler`` based on a completion handler. - /// - /// - parameters: - /// - handler: the underlying async handler - public static func async(_ handler: @escaping (@escaping (Error?) -> Void) -> Void) -> LifecycleHandler { - return LifecycleHandler(handler) - } - - /// Asynchronous ``LifecycleHandler`` based on a blocking, throwing function. - /// - /// - parameters: - /// - body: the underlying function - public static func sync(_ body: @escaping () throws -> Void) -> LifecycleHandler { - return LifecycleHandler { completionHandler in - do { - try body() - completionHandler(nil) - } catch { - completionHandler(error) - } - } - } - - /// Noop ``LifecycleHandler``. - public static var none: LifecycleHandler { - return LifecycleHandler(nil) - } - - internal func run(_ completionHandler: @escaping (Error?) -> Void) { - let body = self.underlying ?? { callback in - callback(nil) - } - body(completionHandler) - } - - internal var noop: Bool { - return self.underlying == nil - } -} - -#if canImport(_Concurrency) && compiler(>=5.5.2) -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension LifecycleHandler { - public init(_ handler: @escaping () async throws -> Void) { - self = LifecycleHandler { callback in - Task { - do { - try await handler() - callback(nil) - } catch { - callback(error) - } - } - } - } - - public static func async(_ handler: @escaping () async throws -> Void) -> LifecycleHandler { - return LifecycleHandler(handler) - } -} -#endif - -// MARK: - Stateful Lifecycle Handlers - -/// LifecycleHandler for starting stateful tasks. The state can then be fed into a ``LifecycleShutdownHandler``. -public struct LifecycleStartHandler { - private let underlying: (@escaping (Result) -> Void) -> Void - - /// Initialize a `LifecycleHandler` based on a completion handler. - /// - /// - parameters: - /// - callback: the underlying completion handler - public init(_ handler: @escaping (@escaping (Result) -> Void) -> Void) { - self.underlying = handler - } - - /// Asynchronous ``LifecycleStartHandler`` based on a completion handler. - /// - /// - parameters: - /// - handler: the underlying async handler - public static func async(_ handler: @escaping (@escaping (Result) -> Void) -> Void) -> LifecycleStartHandler { - return LifecycleStartHandler(handler) - } - - /// Synchronous ``LifecycleStartHandler`` based on a blocking, throwing function. - /// - /// - parameters: - /// - body: the underlying function - public static func sync(_ body: @escaping () throws -> State) -> LifecycleStartHandler { - return LifecycleStartHandler { completionHandler in - do { - let state = try body() - completionHandler(.success(state)) - } catch { - completionHandler(.failure(error)) - } - } - } - - internal func run(_ completionHandler: @escaping (Result) -> Void) { - self.underlying(completionHandler) - } -} - -#if canImport(_Concurrency) && compiler(>=5.5.2) -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension LifecycleStartHandler { - public init(_ handler: @escaping () async throws -> State) { - self = LifecycleStartHandler { callback in - Task { - do { - let state = try await handler() - callback(.success(state)) - } catch { - callback(.failure(error)) - } - } - } - } - - public static func async(_ handler: @escaping () async throws -> State) -> LifecycleStartHandler { - return LifecycleStartHandler(handler) - } -} -#endif - -/// LifecycleHandler for shutting down stateful tasks. The state comes from a ``LifecycleStartHandler``. -public struct LifecycleShutdownHandler { - private let underlying: (State, @escaping (Error?) -> Void) -> Void - - /// Initialize a ``LifecycleShutdownHandler`` based on a completion handler. - /// - /// - parameters: - /// - handler: the underlying completion handler - public init(_ handler: @escaping (State, @escaping (Error?) -> Void) -> Void) { - self.underlying = handler - } - - /// Asynchronous ``LifecycleShutdownHandler`` based on a completion handler. - /// - /// - parameters: - /// - handler: the underlying async handler - public static func async(_ handler: @escaping (State, @escaping (Error?) -> Void) -> Void) -> LifecycleShutdownHandler { - return LifecycleShutdownHandler(handler) - } - - /// Asynchronous ``LifecycleShutdownHandler`` based on a blocking, throwing function. - /// - /// - parameters: - /// - body: the underlying function - public static func sync(_ body: @escaping (State) throws -> Void) -> LifecycleShutdownHandler { - return LifecycleShutdownHandler { state, completionHandler in - do { - try body(state) - completionHandler(nil) - } catch { - completionHandler(error) - } - } - } - - internal func run(state: State, _ completionHandler: @escaping (Error?) -> Void) { - self.underlying(state, completionHandler) - } -} - -#if canImport(_Concurrency) && compiler(>=5.5.2) -@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) -extension LifecycleShutdownHandler { - public init(_ handler: @escaping (State) async throws -> Void) { - self = LifecycleShutdownHandler { state, callback in - Task { - do { - try await handler(state) - callback(nil) - } catch { - callback(error) - } - } - } - } - - public static func async(_ handler: @escaping (State) async throws -> Void) -> LifecycleShutdownHandler { - return LifecycleShutdownHandler(handler) - } -} -#endif - -// MARK: - ServiceLifecycle - -/// ``ServiceLifecycle`` provides a basic mechanism to cleanly startup and shutdown the application, freeing resources in order before exiting. -/// By default, also install shutdown hooks based on `Signal` and backtraces. -public struct ServiceLifecycle { - private static let backtracesInstalled = AtomicBoolean(false) - - private let configuration: Configuration - - /// The underlying ``ComponentLifecycle`` instance - private let underlying: ComponentLifecycle - - /// Creates a ``ServiceLifecycle`` instance. - /// - /// - parameters: - /// - configuration: Defines lifecycle ``Configuration`` - public init(configuration: Configuration = .init()) { - self.configuration = configuration - self.underlying = ComponentLifecycle(label: self.configuration.label, logger: self.configuration.logger) - // setup backtraces as soon as possible, so if we crash during setup we get a backtrace - self.installBacktrace() - } - - /// Starts the provided ``LifecycleTask`` array. - /// Startup is performed in the 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 start(_ callback: @escaping (Error?) -> Void) { - guard self.underlying.idle else { - preconditionFailure("already started") - } - self.setupShutdownHook() - self.underlying.start(on: self.configuration.callbackQueue, callback) - } - - /// Starts the provided ``LifecycleTask`` array and waits (blocking) until a shutdown `Signal` is captured or ``shutdown(_:)`` is called on another thread. - /// Startup is performed in the order of items provided. - public func startAndWait() throws { - guard self.underlying.idle else { - preconditionFailure("already started") - } - self.setupShutdownHook() - try self.underlying.startAndWait(on: self.configuration.callbackQueue) - } - - /// Shuts down the ``LifecycleTask`` 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.underlying.shutdown(callback) - } - - /// Waits (blocking) until shutdown `Signal` is captured or ``shutdown(_:)`` is invoked on another thread. - public func wait() { - self.underlying.wait() - } - - private func installBacktrace() { - if self.configuration.installBacktrace, ServiceLifecycle.backtracesInstalled.compareAndSwap(expected: false, desired: true) { - self.log("installing backtrace") - Backtrace.install() - } - } - - private func setupShutdownHook() { - self.configuration.shutdownSignal?.forEach { signal in - self.log("setting up shutdown hook on \(signal)") - let signalSource = ServiceLifecycle.trap(signal: signal, handler: { signal in - self.log("intercepted signal: \(signal)") - self.shutdown() - }, cancelAfterTrap: true) - // register cleanup as the last task - self.registerShutdown(label: "\(signal) shutdown hook cleanup", .sync { - // cancel if not already canceled by the trap - if !signalSource.isCancelled { - signalSource.cancel() - ServiceLifecycle.removeTrap(signal: signal) - } - }) - } - } - - private func log(_ message: String) { - self.underlying.logger.info("\(message)") - } -} - -extension ServiceLifecycle { - private static var trapped: Set = [] - private static let trappedLock = Lock() - - /// Setup a signal trap. - /// - /// - parameters: - /// - signal: The signal to trap. - /// - handler: closure to invoke when the signal is captured. - /// - on: DispatchQueue to run the signal handler on (default global dispatch queue) - /// - cancelAfterTrap: Defaults to false, which means the signal handler can be run multiple times. If true, the `DispatchSignalSource` will be cancelled after being trapped once. - /// - returns: a `DispatchSourceSignal` for the given trap. The source must be cancelled by the caller. - public static func trap(signal sig: Signal, handler: @escaping (Signal) -> Void, on queue: DispatchQueue = .global(), cancelAfterTrap: Bool = false) -> DispatchSourceSignal { - // on linux, we can call singal() once per process - self.trappedLock.withLockVoid { - if !self.trapped.contains(sig.rawValue) { - signal(sig.rawValue, SIG_IGN) - self.trapped.insert(sig.rawValue) - } - } - let signalSource = DispatchSource.makeSignalSource(signal: sig.rawValue, queue: queue) - signalSource.setEventHandler { - // run handler first - handler(sig) - // then cancel trap if so requested - if cancelAfterTrap { - signalSource.cancel() - self.removeTrap(signal: sig) - } - } - signalSource.resume() - return signalSource - } - - public static func removeTrap(signal sig: Signal) { - self.trappedLock.withLockVoid { - if self.trapped.contains(sig.rawValue) { - signal(sig.rawValue, SIG_DFL) - self.trapped.remove(sig.rawValue) - } - } - } - - /// A system signal - public struct Signal: Equatable, CustomStringConvertible { - internal var rawValue: CInt - - public static let TERM = Signal(rawValue: SIGTERM) - public static let INT = Signal(rawValue: SIGINT) - public static let USR1 = Signal(rawValue: SIGUSR1) - public static let USR2 = Signal(rawValue: SIGUSR2) - public static let HUP = Signal(rawValue: SIGHUP) - - // for testing - internal static let ALRM = Signal(rawValue: SIGALRM) - - public var description: String { - var result = "Signal(" - switch self { - case Signal.TERM: result += "TERM, " - case Signal.INT: result += "INT, " - case Signal.ALRM: result += "ALRM, " - case Signal.USR1: result += "USR1, " - case Signal.USR2: result += "USR2, " - case Signal.HUP: result += "HUP, " - default: () // ok to ignore - } - result += "rawValue: \(self.rawValue))" - return result - } - } -} - -extension ServiceLifecycle: LifecycleTasksContainer { - @discardableResult - public func register(_ tasks: [LifecycleTask]) -> [RegistrationKey] { - return self.underlying.register(tasks) - } - - public func deregister(_ key: RegistrationKey) { - self.underlying.deregister(key) - } -} - -extension ServiceLifecycle { - /// ``ServiceLifecycle`` configuration options. - public struct Configuration { - /// Defines the `label` for the lifeycle and its Logger - public var label: String - /// Defines the `Logger` to log with. - public var logger: Logger - /// Defines the `DispatchQueue` on which startup and shutdown callback handlers are run. - public var callbackQueue: DispatchQueue - /// Defines what, if any, signals to trap for invoking shutdown. - public var shutdownSignal: [Signal]? - /// Defines if to install a crash signal trap that prints backtraces. - public var installBacktrace: Bool - - public init(label: String = "Lifecycle", - logger: Logger? = nil, - callbackQueue: DispatchQueue = .global(), - shutdownSignal: [Signal]? = [.TERM, .INT], - installBacktrace: Bool = true) { - self.label = label - self.logger = logger ?? Logger(label: label) - self.callbackQueue = callbackQueue - self.shutdownSignal = shutdownSignal - self.installBacktrace = installBacktrace - } - } -} - -struct ShutdownError: Error { - public let errors: [String: Error] -} - -// MARK: - ComponentLifecycle - -/// ``ComponentLifecycle`` provides a basic mechanism to cleanly startup and shutdown a subsystem in a larger application, freeing resources in order before exiting. -public class ComponentLifecycle: LifecycleTask { - public let label: String - fileprivate let logger: Logger - fileprivate let shutdownGroup = DispatchGroup() - - private var state = State.idle(Registry()) - private let stateLock = Lock() - - /// Creates a ``ComponentLifecycle`` instance. - /// - /// - parameters: - /// - label: label of the item, useful for debugging. - /// - logger: `Logger` to log with. - public init(label: String, logger: Logger? = nil) { - self.label = label - self.logger = logger ?? Logger(label: label) - self.shutdownGroup.enter() - } - - /// Starts the provided ``LifecycleTask`` array. - /// Startup is performed in the 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 start(_ callback: @escaping (Error?) -> Void) { - self.start(on: .global(), callback) - } - - /// Starts the provided ``LifecycleTask`` array. - /// Startup is performed in the order of items provided. - /// - /// - parameters: - /// - on: `DispatchQueue` to run the handlers callback 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(on queue: DispatchQueue, _ callback: @escaping (Error?) -> Void) { - guard case .idle(let registry) = (self.stateLock.withLock { self.state }) else { - preconditionFailure("invalid state, \(self.state)") - } - self._start(on: queue, registry: registry, callback: callback) - } - - /// Starts the provided ``LifecycleTask`` array and waits (blocking) until ``shutdown(_:)`` is called on another thread. - /// Startup is performed in the order of items provided. - /// - /// - parameters: - /// - on: `DispatchQueue` to run the handlers callback on - public func startAndWait(on queue: DispatchQueue = .global()) throws { - var startError: Error? - let startSemaphore = DispatchSemaphore(value: 0) - - self.start(on: queue) { error in - startError = error - startSemaphore.signal() - } - startSemaphore.wait() - try startError.map { throw $0 } - self.wait() - } - - /// Shuts down the ``LifecycleTask`` array provided in ``start(on:_:)`` or ``startAndWait(on:)``. - /// Shutdown is performed in reverse order of items provided. - public func shutdown(_ callback: @escaping (Error?) -> Void = { _ in }) { - let setupShutdownListener = { (queue: DispatchQueue) in - self.shutdownGroup.notify(queue: queue) { - guard case .shutdown(let errors) = self.state else { - preconditionFailure("invalid state, \(self.state)") - } - callback(errors.flatMap(Lifecycle.ShutdownError.init)) - } - } - - self.stateLock.lock() - switch self.state { - case .idle(let registry) where registry.isEmpty: - self.state = .shutdown(nil) - self.stateLock.unlock() - defer { self.shutdownGroup.leave() } - callback(nil) - case .idle(let registry): - self.stateLock.unlock() - // attempt to shutdown any registered tasks - let stoppable = registry.tasks.filter { $0.shutdownIfNotStarted } - setupShutdownListener(.global()) - self._shutdown(on: .global(), tasks: stoppable, callback: self.shutdownGroup.leave) - case .shutdown: - self.stateLock.unlock() - self.logger.warning("already shutdown") - callback(nil) - case .starting(let queue): - self.state = .shuttingDown(queue) - self.stateLock.unlock() - setupShutdownListener(queue) - case .shuttingDown(let queue): - self.stateLock.unlock() - setupShutdownListener(queue) - case .started(let queue, let registry): - self.stateLock.unlock() - setupShutdownListener(queue) - self._shutdown(on: queue, tasks: registry.tasks, callback: self.shutdownGroup.leave) - } - } - - /// Waits (blocking) until ``shutdown(_:)`` is invoked on another thread. - public func wait() { - self.shutdownGroup.wait() - } - - // MARK: - internal - - internal var idle: Bool { - if case .idle = self.state { - return true - } else { - return false - } - } - - // MARK: - private - - private func _start(on queue: DispatchQueue, registry: Registry, callback: @escaping (Error?) -> Void) { - self.stateLock.withLock { - guard case .idle = self.state else { - preconditionFailure("invalid state, \(self.state)") - } - self.state = .starting(queue) - } - - self.logger.info("starting") - Counter(label: "\(self.label).lifecycle.start").increment() - - if registry.isEmpty { - self.logger.notice("no tasks provided") - } - self.startTask(on: queue, tasks: registry.tasks, index: 0) { started, error in - self.stateLock.lock() - if error != nil { - self.state = .shuttingDown(queue) - } - switch self.state { - case .shuttingDown: - self.stateLock.unlock() - // shutdown was called while starting, or start failed, shutdown what we can - var stoppable = started - if started.count < registry.tasks.count { - let shutdownIfNotStarted = registry.tasks.enumerated() - .filter { $0.offset >= started.count } - .map { $0.element } - .filter { $0.shutdownIfNotStarted } - stoppable.append(contentsOf: shutdownIfNotStarted) - } - self._shutdown(on: queue, tasks: stoppable) { - callback(error) - self.shutdownGroup.leave() - } - case .starting: - self.state = .started(queue, registry) - self.stateLock.unlock() - callback(nil) - default: - preconditionFailure("invalid state, \(self.state)") - } - } - } - - private func startTask(on queue: DispatchQueue, tasks: [LifecycleTask], index: Int, callback: @escaping ([LifecycleTask], Error?) -> Void) { - // async barrier - let start = { callback in queue.async { tasks[index].start(callback) } } - let callback = { index, error in queue.async { callback(index, error) } } - - if index >= tasks.count { - return callback(tasks, nil) - } - if tasks[index].logStart { - self.logger.info("starting tasks [\(tasks[index].label)]") - } - let startTime = DispatchTime.now() - start { error in - Timer(label: "\(self.label).\(tasks[index].label).lifecycle.start").recordNanoseconds(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) - if let error = error { - self.logger.error("failed to start [\(tasks[index].label)]: \(error)") - let started = Array(tasks.prefix(index)) - return callback(started, error) - } - // shutdown called while starting - if case .shuttingDown = self.stateLock.withLock({ self.state }) { - let started = index < tasks.count ? Array(tasks.prefix(index + 1)) : tasks - return callback(started, nil) - } - self.startTask(on: queue, tasks: tasks, index: index + 1, callback: callback) - } - } - - private func _shutdown(on queue: DispatchQueue, tasks: [LifecycleTask], callback: @escaping () -> Void) { - self.stateLock.withLock { - self.state = .shuttingDown(queue) - } - - self.logger.info("shutting down") - Counter(label: "\(self.label).lifecycle.shutdown").increment() - - self.shutdownTask(on: queue, tasks: tasks.reversed(), index: 0, errors: nil) { errors in - self.stateLock.withLock { - guard case .shuttingDown = self.state else { - preconditionFailure("invalid state, \(self.state)") - } - self.state = .shutdown(errors) - } - self.logger.info("bye") - callback() - } - } - - private func shutdownTask(on queue: DispatchQueue, tasks: [LifecycleTask], index: Int, errors: [String: Error]?, callback: @escaping ([String: Error]?) -> Void) { - // async barrier - let shutdown = { callback in queue.async { tasks[index].shutdown(callback) } } - let callback = { errors in queue.async { callback(errors) } } - - if index >= tasks.count { - return callback(errors) - } - - if tasks[index].logShutdown { - self.logger.info("stopping tasks [\(tasks[index].label)]") - } - let startTime = DispatchTime.now() - shutdown { error in - Timer(label: "\(self.label).\(tasks[index].label).lifecycle.shutdown").recordNanoseconds(DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds) - var errors = errors - if let error = error { - if errors == nil { - errors = [:] - } - errors![tasks[index].label] = error - self.logger.error("failed to stop [\(tasks[index].label)]: \(error)") - } - self.shutdownTask(on: queue, tasks: tasks, index: index + 1, errors: errors, callback: callback) - } - } - - private enum State { - case idle(Registry) - case starting(DispatchQueue) - case started(DispatchQueue, Registry) - case shuttingDown(DispatchQueue) - case shutdown([String: Error]?) - } -} - -extension ComponentLifecycle: LifecycleTasksContainer { - @discardableResult - public func register(_ newTasks: [LifecycleTask]) -> [RegistrationKey] { - let registrationKeys = self.stateLock.withLock { () -> [RegistrationKey] in - guard case .idle(let registry) = self.state else { - preconditionFailure("invalid state, \(self.state)") - } - return registry.add(newTasks) - } - return registrationKeys - } - - public func deregister(_ key: RegistrationKey) { - func remove(key: RegistrationKey, tasks: [LifecycleTask], keys: [RegistrationKey]) -> ([LifecycleTask], [RegistrationKey]) { - guard let index = keys.firstIndex(of: key) else { - return (tasks, keys) - } - var updatedTasks = tasks - updatedTasks.remove(at: index) - var updatedKeys = keys - updatedKeys.remove(at: index) - return (updatedTasks, updatedKeys) - } - - self.stateLock.withLock { - switch self.state { - case .idle(let registry), .started(_, let registry): - registry.remove(key) - default: - preconditionFailure("invalid state, \(self.state)") - } - } - } -} - -/// A container of ``LifecycleTask``, used to register additional ``LifecycleTask``. -public protocol LifecycleTasksContainer { - typealias RegistrationKey = String - - /// Register a ``LifecycleTask`` with a ``LifecycleTasksContainer``. - /// - /// - parameters: - /// - tasks: array of ``LifecycleTask``. - @discardableResult - func register(_ tasks: [LifecycleTask]) -> [RegistrationKey] - - /// De-register a ``LifecycleTask`` from a ``LifecycleTasksContainer``. - /// - /// - parameters: - /// - registrationKey: The key returned by a register operation. - func deregister(_ key: RegistrationKey) -} - -extension LifecycleTasksContainer { - /// Register a ``LifecycleTask`` with a ``LifecycleTasksContainer``. - /// - /// - parameters: - /// - tasks: one or more ``LifecycleTask``. - @discardableResult - public func register(_ tasks: LifecycleTask ...) -> [RegistrationKey] { - return self.register(tasks) - } - - /// Register a ``LifecycleTask`` with a ``LifecycleTasksContainer``. - /// - /// - parameters: - /// - tasks: one or more ``LifecycleTask``. - @discardableResult - public func register(_ tasks: LifecycleTask) -> RegistrationKey { - return self.register(tasks).first! // force the optional on the first in this case is safe - } - - /// Register a ``LifecycleTask`` with a ``LifecycleTasksContainer``. - /// - /// - parameters: - /// - label: label of the item, useful for debugging. - /// - start: ``LifecycleHandler`` to perform the startup. - /// - shutdown: ``LifecycleHandler`` to perform the shutdown. - @discardableResult - public func register(label: String, start: LifecycleHandler, shutdown: LifecycleHandler, shutdownIfNotStarted: Bool? = nil) -> RegistrationKey { - return self.register(_LifecycleTask(label: label, shutdownIfNotStarted: shutdownIfNotStarted, start: start, shutdown: shutdown)) - } - - /// Register a ``LifecycleTask`` with a ``LifecycleTasksContainer``. - /// - /// - parameters: - /// - label: label of the item, useful for debugging. - /// - handler: ``LifecycleHandler`` to perform the shutdown. - @discardableResult - public func registerShutdown(label: String, _ handler: LifecycleHandler) -> RegistrationKey { - return self.register(label: label, start: .none, shutdown: handler) - } - - /// Register a stateful ``LifecycleTask`` with a ``LifecycleTasksContainer``. - /// - /// - parameters: - /// - label: label of the item, useful for debugging. - /// - start: ``LifecycleStartHandler`` to perform the startup and return the state. - /// - shutdown: ``LifecycleShutdownHandler`` to perform the shutdown given the state. - @discardableResult - public func registerStateful(label: String, start: LifecycleStartHandler, shutdown: LifecycleShutdownHandler) -> RegistrationKey { - return self.register(StatefulLifecycleTask(label: label, start: start, shutdown: shutdown)) - } -} - -// internal for testing -internal struct _LifecycleTask: LifecycleTask { - let label: String - let shutdownIfNotStarted: Bool - let start: LifecycleHandler - let shutdown: LifecycleHandler - let logStart: Bool - let logShutdown: Bool - - init(label: String, shutdownIfNotStarted: Bool? = nil, start: LifecycleHandler, shutdown: LifecycleHandler) { - self.label = label - self.shutdownIfNotStarted = shutdownIfNotStarted ?? start.noop - self.start = start - self.shutdown = shutdown - self.logStart = !start.noop - self.logShutdown = !shutdown.noop - } - - func start(_ callback: @escaping (Error?) -> Void) { - self.start.run(callback) - } - - func shutdown(_ callback: @escaping (Error?) -> Void) { - self.shutdown.run(callback) - } -} - -// internal (instead of private) for testing -internal class StatefulLifecycleTask: LifecycleTask { - let label: String - let shutdownIfNotStarted: Bool = false - let start: LifecycleStartHandler - let shutdown: LifecycleShutdownHandler - - let stateLock = Lock() - var state: State? - - init(label: String, start: LifecycleStartHandler, shutdown: LifecycleShutdownHandler) { - self.label = label - self.start = start - self.shutdown = shutdown - } - - func start(_ callback: @escaping (Error?) -> Void) { - self.start.run { result in - switch result { - case .failure(let error): - callback(error) - case .success(let state): - self.stateLock.withLock { - self.state = state - } - callback(nil) - } - } - } - - func shutdown(_ callback: @escaping (Error?) -> Void) { - guard let state = (self.stateLock.withLock { self.state }) else { - return callback(UnknownState()) - } - self.shutdown.run(state: state, callback) - } - - struct UnknownState: Error {} -} - -private class Registry { - typealias RegistrationKey = LifecycleTasksContainer.RegistrationKey - - private var _tasks: [LifecycleTask] = [] - private var keys: [LifecycleTasksContainer.RegistrationKey] = [] - private let lock = Lock() - - func add(_ tasks: [LifecycleTask]) -> [RegistrationKey] { - // FIXME: better id generation scheme (cant use UUID) - let keys: [RegistrationKey] = tasks.map { _ in - let random = UInt64.random(in: UInt64.min ..< UInt64.max).addingReportingOverflow(DispatchTime.now().uptimeNanoseconds).partialValue - return "task-\(random)" - } - self.lock.withLock { - self._tasks.append(contentsOf: tasks) - self.keys.append(contentsOf: keys) - } - return keys - } - - func remove(_ key: RegistrationKey) { - self.lock.withLock { - guard let index = self.keys.firstIndex(of: key) else { - return - } - self._tasks.remove(at: index) - self.keys.remove(at: index) - } - } - - var tasks: [LifecycleTask] { - return self.lock.withLock { self._tasks } - } - - var isEmpty: Bool { - return self.lock.withLock { self._tasks.isEmpty } - } -} diff --git a/Sources/Lifecycle/Locks.swift b/Sources/Lifecycle/Locks.swift deleted file mode 100644 index ace7823..0000000 --- a/Sources/Lifecycle/Locks.swift +++ /dev/null @@ -1,97 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2020 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftNIO open source project -// -// Copyright (c) 2017-2018 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 -// -//===----------------------------------------------------------------------===// - -#if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) -import Darwin -#else -import Glibc -#endif - -/// A threading lock based on `libpthread` instead of `libdispatch`. -/// -/// This object provides a lock on top of a single `pthread_mutex_t`. This kind -/// of lock is safe to use with `libpthread`-based threading models, such as the -/// one used by NIO. -internal final class Lock { - private let mutex: UnsafeMutablePointer = UnsafeMutablePointer.allocate(capacity: 1) - - /// Create a new lock. - public init() { - let err = pthread_mutex_init(self.mutex, nil) - precondition(err == 0) - } - - deinit { - let err = pthread_mutex_destroy(self.mutex) - precondition(err == 0) - self.mutex.deallocate() - } - - /// Acquire the lock. - /// - /// Whenever possible, consider using `withLock` instead of this method and - /// `unlock`, to simplify lock handling. - public func lock() { - let err = pthread_mutex_lock(self.mutex) - precondition(err == 0) - } - - /// Release the lock. - /// - /// Whenever possible, consider using `withLock` instead of this method and - /// `lock`, to simplify lock handling. - public func unlock() { - let err = pthread_mutex_unlock(self.mutex) - precondition(err == 0) - } -} - -extension Lock { - /// Acquire the lock for the duration of the given block. - /// - /// This convenience method should be preferred to `lock` and `unlock` in - /// most situations, as it ensures that the lock will be released regardless - /// of how `body` exits. - /// - /// - Parameter body: The block to execute while holding the lock. - /// - Returns: The value returned by the block. - @inlinable - func withLock(_ body: () throws -> T) rethrows -> T { - self.lock() - defer { - self.unlock() - } - return try body() - } - - // specialise Void return (for performance) - @inlinable - func withLockVoid(_ body: () throws -> Void) rethrows { - try self.withLock(body) - } -} diff --git a/Sources/LifecycleNIOCompat/Bridge.swift b/Sources/LifecycleNIOCompat/Bridge.swift deleted file mode 100644 index 2c66fcf..0000000 --- a/Sources/LifecycleNIOCompat/Bridge.swift +++ /dev/null @@ -1,102 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Lifecycle -import NIO - -extension LifecycleHandler { - /// Asynchronous `LifecycleHandler` based on an `EventLoopFuture`. - /// - /// - parameters: - /// - future: function returning the underlying `EventLoopFuture` - public static func eventLoopFuture(_ future: @escaping () -> EventLoopFuture) -> LifecycleHandler { - return LifecycleHandler { callback in - future().whenComplete { result in - switch result { - case .success: - callback(nil) - case .failure(let error): - callback(error) - } - } - } - } -} - -extension LifecycleHandler { - /// `LifecycleHandler` that cancels a `RepeatedTask`. - /// - /// - parameters: - /// - task: `RepeatedTask` to be cancelled - /// - on: `EventLoop` to use for cancelling the task - public static func cancelRepeatedTask(_ task: RepeatedTask, on eventLoop: EventLoop) -> LifecycleHandler { - return self.eventLoopFuture { - let promise = eventLoop.makePromise(of: Void.self) - task.cancel(promise: promise) - return promise.futureResult - } - } -} - -extension LifecycleStartHandler { - /// Asynchronous `LifecycleStartHandler` based on an `EventLoopFuture`. - /// - /// - parameters: - /// - future: function returning the underlying `EventLoopFuture` - public static func eventLoopFuture(_ future: @escaping () -> EventLoopFuture) -> LifecycleStartHandler { - return LifecycleStartHandler { callback in - future().whenComplete { result in - callback(result) - } - } - } -} - -extension LifecycleShutdownHandler { - /// Asynchronous `LifecycleShutdownHandler` based on an `EventLoopFuture`. - /// - /// - parameters: - /// - future: function returning the underlying `EventLoopFuture` - public static func eventLoopFuture(_ future: @escaping (State) -> EventLoopFuture) -> LifecycleShutdownHandler { - return LifecycleShutdownHandler { state, callback in - future(state).whenComplete { result in - switch result { - case .success: - callback(nil) - case .failure(let error): - callback(error) - } - } - } - } -} - -extension ComponentLifecycle { - /// Starts the provided `LifecycleItem` array. - /// Startup is performed in the order of items provided. - /// - /// - parameters: - /// - eventLoop: The `eventLoop` which is used to generate the `EventLoopFuture` that is returned. After the start the future is fulfilled: - public func start(on eventLoop: EventLoop) -> EventLoopFuture { - let promise = eventLoop.makePromise(of: Void.self) - self.start { error in - if let error = error { - promise.fail(error) - } else { - promise.succeed(()) - } - } - return promise.futureResult - } -} diff --git a/Sources/ServiceLifecycle/AsyncCancelOnGracefulShutdownSequence.swift b/Sources/ServiceLifecycle/AsyncCancelOnGracefulShutdownSequence.swift new file mode 100644 index 0000000..b10e932 --- /dev/null +++ b/Sources/ServiceLifecycle/AsyncCancelOnGracefulShutdownSequence.swift @@ -0,0 +1,161 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AsyncAlgorithms + +extension AsyncSequence where Self: Sendable, Element: Sendable { + /// Creates an asynchronous sequence that is cancelled once graceful shutdown has triggered. + /// + /// Use this in places where the only logical thing on graceful shutdown is to cancel your iteration. + public func cancelOnGracefulShutdown() -> AsyncCancelOnGracefulShutdownSequence { + AsyncCancelOnGracefulShutdownSequence(base: self) + } +} + +/// An asynchronous sequence that is cancelled once graceful shutdown has triggered. +public struct AsyncCancelOnGracefulShutdownSequence: AsyncSequence, Sendable where Base.Element: Sendable { + @usableFromInline + enum _ElementOrGracefulShutdown: Sendable { + case base(AsyncMapNilSequence.Element) + case gracefulShutdown + } + + @usableFromInline + typealias Merged = AsyncMerge2Sequence< + AsyncMapSequence, _ElementOrGracefulShutdown>, + AsyncMapSequence, _ElementOrGracefulShutdown> + > + + public typealias Element = Base.Element + + @usableFromInline + let _merge: Merged + + @inlinable + public init(base: Base) { + self._merge = .init( + base.mapNil().map { .base($0) }, + AsyncGracefulShutdownSequence().mapNil().map { _ in .gracefulShutdown } + ) + } + + @inlinable + public func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(iterator: self._merge.makeAsyncIterator()) + } + + public struct AsyncIterator: AsyncIteratorProtocol { + @usableFromInline + var _iterator: Merged.AsyncIterator + + @usableFromInline + var _isFinished = false + + @inlinable + init(iterator: Merged.AsyncIterator) { + self._iterator = iterator + } + + @inlinable + public mutating func next() async rethrows -> Element? { + guard !self._isFinished else { + return nil + } + + let value = try await self._iterator.next() + + switch value { + case .base(let element): + + switch element { + case .element(let element): + return element + + case .end: + self._isFinished = true + return nil + } + + case .gracefulShutdown: + return nil + + case .none: + return nil + } + } + } +} + +/// This is just a helper extension and sequence to allow us to get the `nil` value as an element of the sequence. +/// We need this since merge is only finishing when both upstreams are finished but we need to finish when either is done. +/// In the future, we should move to something in async algorithms if it exists. +extension AsyncSequence where Self: Sendable, Element: Sendable { + @inlinable + func mapNil() -> AsyncMapNilSequence { + AsyncMapNilSequence(base: self) + } +} + +@usableFromInline +struct AsyncMapNilSequence: AsyncSequence, Sendable where Base.Element: Sendable { + @usableFromInline + enum ElementOrEnd: Sendable { + case element(Base.Element) + case end + } + + @usableFromInline + typealias Element = ElementOrEnd + + @usableFromInline + let _base: Base + + @inlinable + init(base: Base) { + self._base = base + } + + @inlinable + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(iterator: self._base.makeAsyncIterator()) + } + + @usableFromInline + struct AsyncIterator: AsyncIteratorProtocol { + @usableFromInline + var _iterator: Base.AsyncIterator + + @usableFromInline + var _hasSeenEnd = false + + @inlinable + init(iterator: Base.AsyncIterator) { + self._iterator = iterator + } + + @inlinable + mutating func next() async rethrows -> Element? { + let value = try await self._iterator.next() + + if let value { + return .element(value) + } else if self._hasSeenEnd { + return nil + } else { + self._hasSeenEnd = true + return .end + } + } + } +} diff --git a/Sources/ServiceLifecycle/AsyncGracefulShutdownSequence.swift b/Sources/ServiceLifecycle/AsyncGracefulShutdownSequence.swift new file mode 100644 index 0000000..4c398fe --- /dev/null +++ b/Sources/ServiceLifecycle/AsyncGracefulShutdownSequence.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// An async sequence that emits an element once graceful shutdown has been triggered. +/// +/// This sequence is a broadcast async sequence and will only produce one value and then finish. +@usableFromInline +struct AsyncGracefulShutdownSequence: AsyncSequence, Sendable { + @usableFromInline + typealias Element = Void + + @inlinable + init() {} + + @inlinable + func makeAsyncIterator() -> AsyncIterator { + AsyncIterator() + } + + @usableFromInline + struct AsyncIterator: AsyncIteratorProtocol { + @inlinable + init() {} + + @inlinable + func next() async -> Element? { + var cont: AsyncStream.Continuation! + let stream = AsyncStream { cont = $0 } + let continuation = cont! + + return await withTaskGroup(of: Void.self) { _ in + await withGracefulShutdownHandler { + await stream.first { _ in true } + } onGracefulShutdown: { + continuation.yield(()) + continuation.finish() + } + } + } + } +} diff --git a/Sources/ServiceLifecycle/Docs.docc/How to adopt ServiceLifecycle in applications.md b/Sources/ServiceLifecycle/Docs.docc/How to adopt ServiceLifecycle in applications.md new file mode 100644 index 0000000..e64b09f --- /dev/null +++ b/Sources/ServiceLifecycle/Docs.docc/How to adopt ServiceLifecycle in applications.md @@ -0,0 +1,200 @@ +# How to adopt ServiceLifecycle in applications + +``ServiceLifecycle`` aims to provide a unified API that services should adopt to make orchestrating +them in an application easier. To achieve this ``ServiceLifecycle`` is providing the ``ServiceGroup`` actor. + +## Why do we need this? + +When building applications we often have a bunch of services that comprise the internals of the applications. +These services include fundamental needs like logging or metrics. Moreover, they also include +services that compromise the application's business logic such as long-running actors. +Lastly, they might also include HTTP, gRPC, or similar servers that the application is exposing. +One important requirement of the application is to orchestrate the various services currently during +startup and shutdown. Furthermore, the application also needs to handle a single service failing. + +Swift introduced Structured Concurrency which already helps tremendously with running multiple +async services concurrently. This can be achieved with the use of task groups. However, Structured +Concurrency doesn't enforce consistent interfaces between the services, so it becomes hard to orchestrate them. +This is where ``ServiceLifecycle`` comes in. It provides the ``Service`` protocol which enforces +a common API. Additionally, it provides the ``ServiceGroup`` which is responsible for orchestrating +all services in an application. + +## Adopting the ServiceGroup in your application + +This article is focusing on how the ``ServiceGroup`` works and how you can adopt it in your application. +If you are interested in how to properly implement a service, go check out the article: . + +### How is the ServiceGroup working? + +The ``ServiceGroup`` is just a slightly complicated task group under the hood that runs each service +in a separate child task. Furthermore, the ``ServiceGroup`` handles individual services exiting +or throwing unexpectedly. Lastly, it also introduces a concept called graceful shutdown which allows +tearing down all services in reverse order safely. Graceful shutdown is often used in server +scenarios i.e. when rolling out a new version and draining traffic from the old version. + +### How to use the ServiceGroup? + +Let's take a look how the ``ServiceGroup`` can be used in an application. First, we define some +fictional services. + +```swift +struct FooService: Service { + func run() async throws { ... } +} + +public struct BarService: Service { + private let fooService: FooService + + init(fooService: FooService) { + self.fooService = fooService + } + + func run() async throws { ... } +} +``` + +The `BarService` is depending in our example on the `FooService`. A dependency between services +is quite common and the ``ServiceGroup`` is inferring the dependencies from the order of the +services passed to the ``ServiceGroup/init(services:configuration:logger:)``. Services with a higher +index can depend on services with a lower index. The following example shows how this can be applied +to our `BarService`. + +```swift +@main +struct Application { + static func main() async throws { + let fooService = FooServer() + let barService = BarService(fooService: fooService) + + let serviceGroup = ServiceGroup( + // We are encoding the dependency hierarchy here by listing the fooService first + services: [fooService, barService], + configuration: .init(gracefulShutdownSignals: []), + logger: logger + ) + + try await serviceGroup.run() + } +} +``` + +### Graceful shutdown + +The ``ServiceGroup`` supports graceful shutdown by taking an array of `UnixSignal`s that trigger +the shutdown. Commonly `SIGTERM` is used to indicate graceful shutdowns in container environments +such as Docker or Kubernetes. The ``ServiceGroup`` is then gracefully shutting down each service +one by one in the reverse order of the array passed to the init. +Importantly, the ``ServiceGroup`` is going to wait for the ``Service/run()`` method to return +before triggering the graceful shutdown on the next service. + +Since graceful shutdown is up to the individual services and application it requires explicit support. +We recommend that every service author makes sure their implementation is handling graceful shutdown +correctly. Lastly, application authors also have to make sure they are handling graceful shutdown. +A common example of this is for applications that implement streaming behaviours. + +```swift +struct StreamingService: Service { + struct RequestStream: AsyncSequence { ... } + struct ResponseWriter { + func write() + } + + private let streamHandler: (RequestStream, ResponseWriter) async -> Void + + init(streamHandler: @escaping (RequestStream, ResponseWriter) async -> Void) { + self.streamHandler = streamHandler + } + + func run() async throws { + await withDiscardingTaskGroup { group in + group.addTask { + for stream in makeStreams() { + await streamHandler(stream.requestStream, stream.responseWriter) + } + } + } + } +} + +@main +struct Application { + static func main() async throws { + let streamingService = StreamingService(streamHandler: { requestStream, responseWriter in + for await request in requestStream { + responseWriter.write("response") + } + }) + + let serviceGroup = ServiceGroup( + services: [streamingService], + configuration: .init(gracefulShutdownSignals: [.sigterm]), + logger: logger + ) + + try await serviceGroup.run() + } +} +``` + +The code above demonstrates a hypothetical `StreamingService` with a configurable handler that +is invoked per stream. Each stream is handled in a separate child task concurrently. +The above code doesn't support graceful shutdown right now. There are two places where we are missing it. +First, the service's `run()` method is iterating the `makeStream()` async sequence. This iteration is +not stopped on graceful shutdown and we are continuing to accept new streams. Furthermore, +the `streamHandler` that we pass in our main method is also not supporting graceful shutdown since it +is iterating over the incoming requests. + +Luckily, adding support in both places is trivial with the helpers that ``ServiceLifecycle`` exposes. +In both cases, we are iterating an async sequence and what we want to do is stop the iteration. +To do this we can use the `cancelOnGracefulShutdown()` method that ``ServiceLifecycle`` adds to +`AsyncSequence`. The updated code looks like this: + +```swift +struct StreamingService: Service { + struct RequestStream: AsyncSequence { ... } + struct ResponseWriter { + func write() + } + + private let streamHandler: (RequestStream, ResponseWriter) async -> Void + + init(streamHandler: @escaping (RequestStream, ResponseWriter) async -> Void) { + self.streamHandler = streamHandler + } + + func run() async throws { + await withDiscardingTaskGroup { group in + group.addTask { + for stream in makeStreams().cancelOnGracefulShutdown() { + await streamHandler(stream.requestStream, stream.responseWriter) + } + } + } + } +} + +@main +struct Application { + static func main() async throws { + let streamingService = StreamingService(streamHandler: { requestStream, responseWriter in + for await request in requestStream.cancelOnGracefulShutdown() { + responseWriter.write("response") + } + }) + + let serviceGroup = ServiceGroup( + services: [streamingService], + configuration: .init(gracefulShutdownSignals: [.sigterm]), + logger: logger + ) + + try await serviceGroup.run() + } +} +``` + +Now one could ask - Why aren't we using cancellation in the first place here? The problem is that +cancellation is forceful and doesn't allow users to make a decision if they want to cancel or not. +However, graceful shutdown is very specific to business logic often. In our case, we were fine with just +stopping to handle new requests on a stream. Other applications might want to send a response indicating +to the client that the server is shutting down and waiting for an acknowledgment of that message. diff --git a/Sources/ServiceLifecycle/Docs.docc/How to adopt ServiceLifecycle in libraries.md b/Sources/ServiceLifecycle/Docs.docc/How to adopt ServiceLifecycle in libraries.md new file mode 100644 index 0000000..d1d681a --- /dev/null +++ b/Sources/ServiceLifecycle/Docs.docc/How to adopt ServiceLifecycle in libraries.md @@ -0,0 +1,153 @@ +# How to adopt ServiceLifecycle in libraries + +``ServiceLifecycle`` aims to provide a unified API that services should adopt to make orchestrating +them in an application easier. To achieve this ``ServiceLifecycle`` is providing the ``Service`` protocol. + +## Why do we need this? + +Before diving into how to adopt this protocol in your library, let's take a step back and +talk about why we even need to have this unified API. A common need for services is to either +schedule long running work like sending keep alive pings in the background or to handle new +incoming work like handling new TCP connections. Before Concurrency was introduced services put +their work into separate threads using things like `DispatchQueue`s or NIO `EventLoop`s. +This often required explicit lifetime management of the services to make sure to shutdown the threads correctly. +With the introduction of Concurrency, specifically Structured Concurrency, we now have a better way +to structure our programs and model our work as a tree of tasks. +The ``Service`` protocol is providing a common interface that requires a single `run()` method where +services can put their long running work in. Having all services in an application conform to this +protocol enables easy orchestration of them and makes sure they interact nicely with each other. + +## Adopting the Service protocol in your service + +Adopting the ``Service`` protocol is quite easy in your services. The protocol has only a single requirement +which is the ``Service/run()`` method. There are a few important caveats to it which we are going over in the +next sections. Make sure that your service is following those. + +### Make sure to use Structured Concurrency + +Swift offers multiple ways to use Structured Concurrency. The primary primitives are the +`async` and `await` keywords which enable straight-line code to make asynchronous calls. +Furthermore, the language provides the concept of task groups which allow the creation of +concurrent work while still staying tied to the parent task. On the other hand, Swift also provides +`Task(priority:operation:)` and `Task.detached(priority:operation:)` which create a new unstructured Task. + +Imagine our library wants to offer a simple `TCPEchoClient`. To make it interesting let's assume we +need to send keep-alive pings on every open connection every second. Below you can see how we could +implement this using unstructured Concurrency. + +```swift +public actor TCPEchoClient { + public init() { + Task { + for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) { + self.sendKeepAlivePings() + } + } + } + + private func sendKeepAlivePings() async { ... } +} +``` + +The above code has a few problems. First, we are never canceling the `Task` that is running the +keep-alive pings. To do this we would need to store the `Task` in our actor and cancel it at the +appropriate time. Secondly, we actually would need to expose a `cancel()` method on the actor to cancel +the `Task`. At this point, we have just reinvented Structured Concurrency. +To avoid all of these problems we can just conform to the ``Service`` protocol which requires a `run()` +method. This requirement already guides us to implement the long running work inside the `run()` method. +Having this method allows the user of the client to decide in which task to schedule the keep-alive pings. +They can still decide to create an unstructured `Task` for this, but that is up to the user now. +Furthermore, we now get automatic cancellation propagation from the task that called our `run()` method. +Below is an overhauled implementation that exposes such a `run()` method. + +```swift +public actor TCPEchoClient: Service { + public init() { } + + public func run() async throws { + for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous) { + self.sendKeepAlivePings() + } + } + + private func sendKeepAlivePings() async { ... } +} +``` + + +### Returning from your `run()` method + +Since the `run()` method contains long running work, returning from it is seen as a failure and will +lead to the ``ServiceGroup`` cancelling all other services by cancelling the task that is running +their respective `run()` method. + +### Cancellation + +Structured Concurrency propagates task cancellation down the task tree. Every task in the tree can +check for cancellation or react to it with cancellation handlers. The ``ServiceGroup`` is using task +cancellation to tear everything down in the case of an early return or thrown error from the `run()` +method of any of the services. Hence it is important that each service properly implements task +cancellation in their `run()` methods. + +Note: If your `run()` method is only calling other async methods that support cancellation themselves +or is consuming an `AsyncSequence`, you don't have to do anything explicitly here. Looking at the +`TCPEchoClient` example from above we can see that we only call `Task.sleep` in our `run()` method +which is supporting task cancellation. + +### Graceful shutdown + +When running an application in a real environment it is often required to gracefully shutdown the application. +For example, the application might be running in Kubernetes and a new version of it got deployed. In this +case, Kubernetes is going to send a `SIGTERM` signal to the application and expects it to terminate +within a grace period. If the application isn't stopping in time then Kubernetes will send the `SIGKILL` +signal and forcefully terminate the process. +For this reason ``ServiceLifecycle`` introduces a new _shutdown gracefully_ concept that allows terminating +the work in a structured and graceful manner. This works similarly to task cancellation but +it is fully opt-in and up to the business logic of the application to decide what to do. + +``ServiceLifecycle`` exposes one free function called ``withGracefulShutdownHandler(operation:onGracefulShutdown:)`` +that works similarly to the `withTaskCancellationHandler` function from the Concurrency library. +Library authors are expected to make sure that any work they spawn from the `run()` method +properly supports graceful shutdown. For example, a server might be closing its listening socket +to stop accepting new connections. +Importantly here though is that the server is not force closing the currently open ones. Rather it +expects the business logic on these connections to handle graceful shutdown on their own. + +An example implementation of a `TCPEchoServer` on a high level that supports graceful shutdown +might look like this. + +```swift +public actor TCPEchoClient: Service { + public init() { } + + public func run() async throws { + await withGracefulShutdownHandler { + for connection in self.listeningSocket.connections { + // Handle incoming connections + } + } onGracefulShutdown: { + self.listeningSocket.close() + } + } +} +```` + +In the case of our `TCPEchoClient`, the only reasonable thing to do is cancel the iteration of our +timer sequence when we receive the graceful shutdown sequence. ``ServiceLifecycle`` is providing +a convenience on `AsyncSequence` to cancel on graceful shutdown. Let's take a look at how this works. + +```swift +public actor TCPEchoClient: Service { + public init() { } + + public func run() async throws { + for await _ in AsyncTimerSequence(interval: .seconds(1), clock: .continuous).cancelOnGracefulShutdown() { + self.sendKeepAlivePings() + } + } + + private func sendKeepAlivePings() async { ... } +} +``` + +As you can see in the code above, it is as simple as adding a `cancelOnGracefulShutdown()` call. diff --git a/Sources/ServiceLifecycle/Docs.docc/index.md b/Sources/ServiceLifecycle/Docs.docc/index.md new file mode 100644 index 0000000..885c46b --- /dev/null +++ b/Sources/ServiceLifecycle/Docs.docc/index.md @@ -0,0 +1,48 @@ +# ``ServiceLifecycle`` + +A library for cleanly starting up and shutting down applications. + +## Overview + +Applications often have to orchestrate multiple internal services such as +clients or servers to implement their business logic. Doing this can become +tedious; especially when the APIs of the various services are not interoping nicely +with each other. This library tries to solve this issue by providing a ``Service`` protocol +that services should implement and an orchestrator, the ``ServiceGroup``, that handles +running the various services. + +This library is fully based on Swift Structured Concurrency which allows it to +safely orchestrate the individual services in separate child tasks. Furthermore, this library +complements the cooperative task cancellation from Structured Concurrency with a new mechanism called +_graceful shutdown_. Cancellation is indicating the tasks to stop their work as soon as possible +whereas _graceful shutdown_ just indicates them that they should come to an end but it is up +to their business logic if and how to do that. + +``ServiceLifecycle`` should be used by both library and application authors to create a seamless experience. +Library authors should conform their services to the ``Service`` protocol and application authors +should use the ``ServiceGroup`` to orchestrate all their services. + +## Topics + +### Articles + +- +- + +### Service protocol + +- ``Service`` + +### Service Group + +- ``ServiceGroup`` +- ``ServiceGroupConfiguration`` + +### Graceful Shutdown + +- ``withGracefulShutdownHandler(operation:onGracefulShutdown:)`` +- ``cancelOnGracefulShutdown(_:)`` + +### Errors + +- ``ServiceGroupError`` diff --git a/Sources/ServiceLifecycle/GracefulShutdown.swift b/Sources/ServiceLifecycle/GracefulShutdown.swift new file mode 100644 index 0000000..088650b --- /dev/null +++ b/Sources/ServiceLifecycle/GracefulShutdown.swift @@ -0,0 +1,196 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import ConcurrencyHelpers + +/// Execute an operation with a graceful shutdown handler that’s immediately invoked if the current task is shutting down gracefully. +/// +/// This doesn’t check for graceful shutdown, and always executes the passed operation. +/// The operation executes on the calling execution context and does not suspend by itself, unless the code contained within the closure does. +/// If graceful shutdown occurs while the operation is running, the graceful shutdown handler will execute concurrently with the operation. +/// +/// When `withGracefulShutdownHandler` is used in a Task that has already been gracefully shutdown, the `onGracefulShutdown` handler +/// will be executed immediately before operation gets to execute. This allows the `onGracefulShutdown` handler to set some external “shutdown” flag +/// that the operation may be atomically checking for in order to avoid performing any actual work once the operation gets to run. +/// +/// A common use-case is to listen to graceful shutdown and use the `ServerQuiescingHelper` from `swift-nio-extras` to +/// trigger the quiescing sequence. Furthermore, graceful shutdown will propagate to any child task that is currently executing +/// +/// - Important: This method will only set up a handler if run inside ``ServiceGroup`` otherwise no graceful shutdown handler +/// will be set up. +/// +/// - Parameters: +/// - operation: The actual operation. +/// - handler: The handler which is invoked once graceful shutdown has been triggered. +// Unsafely inheriting the executor is safe to do here since we are not calling any other async method +// except the operation. This makes sure no other executor hops would occur here. +@_unsafeInheritExecutor +public func withGracefulShutdownHandler( + operation: () async throws -> T, + onGracefulShutdown handler: @Sendable @escaping () -> Void +) async rethrows -> T { + guard let gracefulShutdownManager = TaskLocals.gracefulShutdownManager else { + return try await operation() + } + + // We have to keep track of our handler here to remove it once the operation is finished. + let handlerID = gracefulShutdownManager.registerHandler(handler) + defer { + if let handlerID = handlerID { + gracefulShutdownManager.removeHandler(handlerID) + } + } + + return try await operation() +} + +/// This is just a helper type for the result of our task group. +enum ValueOrGracefulShutdown { + case value(T) + case gracefulShutdown +} + +/// Cancels the closure when a graceful shutdown was triggered. +/// +/// - Parameter operation: The actual operation. +public func cancelOnGracefulShutdown(_ operation: @Sendable @escaping () async throws -> T) async rethrows -> T? { + return try await withThrowingTaskGroup(of: ValueOrGracefulShutdown.self) { group in + group.addTask { + let value = try await operation() + return .value(value) + } + + group.addTask { + for await _ in AsyncGracefulShutdownSequence() { + return .gracefulShutdown + } + + throw CancellationError() + } + + let result = try await group.next() + + switch result { + case .value(let t): + return t + case .gracefulShutdown: + group.cancelAll() + switch try await group.next() { + case .value(let t): + return t + case .gracefulShutdown: + fatalError("Unexpectedly got gracefulShutdown from group.next()") + + case nil: + fatalError("Unexpectedly got nil from group.next()") + } + + case nil: + fatalError("Unexpectedly got nil from group.next()") + } + } +} + +extension Task where Success == Never, Failure == Never { + /// A Boolean value that indicates whether the task is gracefully shutting down + /// + /// After the value of this property becomes `true`, it remains `true` indefinitely. There is no way to undo a graceful shutdown. + public static var isShuttingDownGracefully: Bool { + guard let gracefulShutdownManager = TaskLocals.gracefulShutdownManager else { + return false + } + + return gracefulShutdownManager.isShuttingDown + } +} + +@_spi(TestKit) +public enum TaskLocals { + @TaskLocal + @_spi(TestKit) + public static var gracefulShutdownManager: GracefulShutdownManager? +} + +@_spi(TestKit) +public final class GracefulShutdownManager: @unchecked Sendable { + struct Handler { + /// The id of the handler. + var id: UInt64 + /// The actual handler. + var handler: () -> Void + } + + struct State { + /// The currently registered handlers. + fileprivate var handlers = [Handler]() + /// A counter to assign a unique number to each handler. + fileprivate var handlerCounter: UInt64 = 0 + /// A boolean indicating if we have been shutdown already. + fileprivate var isShuttingDown = false + } + + private let state = LockedValueBox(State()) + + var isShuttingDown: Bool { + self.state.withLockedValue { return $0.isShuttingDown } + } + + @_spi(TestKit) + public init() {} + + func registerHandler(_ handler: @Sendable @escaping () -> Void) -> UInt64? { + return self.state.withLockedValue { state in + if state.isShuttingDown { + // We are already shutting down so we just run the handler now. + handler() + return nil + } else { + defer { + state.handlerCounter += 1 + } + let handlerID = state.handlerCounter + state.handlers.append(.init(id: handlerID, handler: handler)) + + return handlerID + } + } + } + + func removeHandler(_ handlerID: UInt64) { + self.state.withLockedValue { state in + guard let index = state.handlers.firstIndex(where: { $0.id == handlerID }) else { + // This can happen because if shutdownGracefully ran while the operation was still in progress + return + } + + state.handlers.remove(at: index) + } + } + + @_spi(TestKit) + public func shutdownGracefully() { + self.state.withLockedValue { state in + guard !state.isShuttingDown else { + return + } + state.isShuttingDown = true + + for handler in state.handlers { + handler.handler() + } + + state.handlers.removeAll() + } + } +} diff --git a/Sources/ServiceLifecycle/Service.swift b/Sources/ServiceLifecycle/Service.swift new file mode 100644 index 0000000..01787e4 --- /dev/null +++ b/Sources/ServiceLifecycle/Service.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// This is the basic protocol that a service has to implement. +public protocol Service: Sendable { + /// This method is called when the ``ServiceGroup`` is starting all the services. + /// + /// Concrete implementation should execute their long running work in this method such as: + /// - Handling incoming connections and requests + /// - Background refreshes + /// + /// - Important: Returning or throwing from this method is indicating a failure of the service and will cause the ``ServiceGroup`` + /// to cancel the child tasks of all other running services. + func run() async throws +} diff --git a/Sources/ServiceLifecycle/ServiceGroup.swift b/Sources/ServiceLifecycle/ServiceGroup.swift new file mode 100644 index 0000000..b765321 --- /dev/null +++ b/Sources/ServiceLifecycle/ServiceGroup.swift @@ -0,0 +1,383 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import UnixSignals + +/// A ``ServiceGroup`` is responsible for running a number of services, setting up signal handling and signalling graceful shutdown to the services. +public actor ServiceGroup: Sendable { + /// The internal state of the ``ServiceGroup``. + private enum State { + /// The initial state of the group. + case initial + /// The state once ``ServiceGroup/run()`` has been called. + case running( + gracefulShutdownStreamContinuation: AsyncStream.Continuation + ) + /// The state once ``ServiceGroup/run()`` has finished. + case finished + } + + /// The services to run. + private let services: [any Service] + /// The group's configuration. + private let configuration: ServiceGroupConfiguration + /// The logger. + private let logger: Logger + + /// The current state of the group. + private var state: State = .initial + + /// Initializes a new ``ServiceGroup``. + /// + /// - Parameters: + /// - services: The services to run. + /// - configuration: The group's configuration. + /// - logger: The logger. + public init( + services: [any Service], + configuration: ServiceGroupConfiguration, + logger: Logger + ) { + self.services = services + self.configuration = configuration + self.logger = logger + } + + /// Runs all the services by spinning up a child task per service. + /// Furthermore, this method sets up the correct signal handlers + /// for graceful shutdown. + public func run(file: String = #file, line: Int = #line) async throws { + switch self.state { + case .initial: + guard !self.services.isEmpty else { + self.state = .finished + return + } + + let (gracefulShutdownStream, gracefulShutdownContinuation) = AsyncStream.makeStream(of: Void.self) + + self.state = .running( + gracefulShutdownStreamContinuation: gracefulShutdownContinuation + ) + + var potentialError: Error? + do { + try await self._run(gracefulShutdownStream: gracefulShutdownStream) + } catch { + potentialError = error + } + + switch self.state { + case .initial, .finished: + fatalError("ServiceGroup is in an invalid state \(self.state)") + + case .running: + self.state = .finished + + if let potentialError { + throw potentialError + } + } + + case .running: + throw ServiceGroupError.alreadyRunning(file: file, line: line) + + case .finished: + throw ServiceGroupError.alreadyFinished(file: file, line: line) + } + } + + /// Triggers the graceful shutdown of all services. + /// + /// This method returns immediately after triggering the graceful shutdown and doesn't wait until the service have shutdown. + public func shutdownGracefully() async { + switch self.state { + case .initial: + // We aren't even running so we can stop right away. + self.state = .finished + return + + case .running(let gracefulShutdownStreamContinuation): + // We cannot transition to shuttingDown here since we are signalling over to the task + // that runs `run`. This task is responsible for transitioning to shuttingDown since + // there might be multiple signals racing to trigger it + + // We are going to signal the run method that graceful shutdown + // should be triggered + gracefulShutdownStreamContinuation.yield() + gracefulShutdownStreamContinuation.finish() + + case .finished: + // Already finished running so nothing to do here + return + } + } + + private enum ChildTaskResult { + case serviceFinished(service: any Service, index: Int) + case serviceThrew(service: any Service, index: Int, error: any Error) + case signalCaught(UnixSignal) + case signalSequenceFinished + case gracefulShutdownCaught + case gracefulShutdownFinished + } + + private func _run(gracefulShutdownStream: AsyncStream) async throws { + self.logger.debug( + "Starting service lifecycle", + metadata: [ + self.configuration.logging.keys.signalsKey: "\(self.configuration.gracefulShutdownSignals)", + self.configuration.logging.keys.servicesKey: "\(self.services)", + ] + ) + + // Using a result here since we want a task group that has non-throwing child tasks + // but the body itself is throwing + let result = await withTaskGroup(of: ChildTaskResult.self, returning: Result.self) { group in + // First we have to register our signals. + let unixSignals = await UnixSignalsSequence(trapping: self.configuration.gracefulShutdownSignals) + + // This is the task that listens to signals + group.addTask { + for await signal in unixSignals { + return .signalCaught(signal) + } + + return .signalSequenceFinished + } + + // This is the task that listens to manual graceful shutdown + group.addTask { + for await _ in gracefulShutdownStream { + return .gracefulShutdownCaught + } + + return .gracefulShutdownFinished + } + + // This is an optional task that listens to graceful shutdowns from the parent task + if let _ = TaskLocals.gracefulShutdownManager { + group.addTask { + for await _ in AsyncGracefulShutdownSequence() { + return .gracefulShutdownCaught + } + + return .gracefulShutdownFinished + } + } + + // We have to create a graceful shutdown manager per service + // since we want to signal them individually and wait for a single service + // to finish before moving to the next one + var gracefulShutdownManagers = [GracefulShutdownManager]() + gracefulShutdownManagers.reserveCapacity(self.services.count) + + for (index, service) in self.services.enumerated() { + self.logger.debug( + "Starting service", + metadata: [ + self.configuration.logging.keys.serviceKey: "\(service)", + ] + ) + + let gracefulShutdownManager = GracefulShutdownManager() + gracefulShutdownManagers.append(gracefulShutdownManager) + + // This must be addTask and not addTaskUnlessCancelled + // because we must run all the services for the below logic to work. + group.addTask { + return await TaskLocals.$gracefulShutdownManager.withValue(gracefulShutdownManager) { + do { + try await service.run() + return .serviceFinished(service: service, index: index) + } catch { + return .serviceThrew(service: service, index: index, error: error) + } + } + } + } + + precondition(gracefulShutdownManagers.count == self.services.count, "We did not create a graceful shutdown manager per service") + + // We are going to wait for any of the services to finish or + // the signal sequence to throw an error. + while !group.isEmpty { + let result: ChildTaskResult? = await group.next() + + switch result { + case .serviceFinished(let service, _): + // If a long running service finishes early we treat this as an unexpected + // early exit and have to cancel the rest of the services. + self.logger.error( + "Service finished unexpectedly. Cancelling all other services now", + metadata: [ + self.configuration.logging.keys.serviceKey: "\(service)", + ] + ) + + group.cancelAll() + return .failure(ServiceGroupError.serviceFinishedUnexpectedly()) + + case .serviceThrew(let service, _, let error): + // One of the servers threw an error. We have to cancel everything else now. + self.logger.error( + "Service threw error. Cancelling all other services now", + metadata: [ + self.configuration.logging.keys.serviceKey: "\(service)", + self.configuration.logging.keys.errorKey: "\(error)", + ] + ) + group.cancelAll() + + return .failure(error) + + case .signalCaught(let unixSignal): + // We got a signal. Let's initiate graceful shutdown. + self.logger.debug( + "Signal caught. Shutting down services", + metadata: [ + self.configuration.logging.keys.signalKey: "\(unixSignal)", + ] + ) + + do { + try await self.shutdownGracefully( + group: &group, + gracefulShutdownManagers: gracefulShutdownManagers + ) + } catch { + return .failure(error) + } + + case .gracefulShutdownCaught: + // We got a manual or inherited graceful shutdown. Let's initiate graceful shutdown. + self.logger.debug("Graceful shutdown caught. Cascading shutdown to services") + + do { + try await self.shutdownGracefully( + group: &group, + gracefulShutdownManagers: gracefulShutdownManagers + ) + } catch { + return .failure(error) + } + + case .signalSequenceFinished, .gracefulShutdownFinished: + // This can happen when we are either cancelling everything or + // when the user did not specify any shutdown signals. We just have to tolerate + // this. + continue + + case nil: + fatalError("Invalid result from group.next(). We checked if the group is empty before and still got nil") + } + } + + return .success(()) + } + + self.logger.debug( + "Service lifecycle ended" + ) + try result.get() + } + + private func shutdownGracefully( + group: inout TaskGroup, + gracefulShutdownManagers: [GracefulShutdownManager] + ) async throws { + guard case .running = self.state else { + fatalError("Unexpected state") + } + + // We have to shutdown the services in reverse. To do this + // we are going to signal each child task the graceful shutdown and then wait for + // its exit. + for (gracefulShutdownIndex, gracefulShutdownManager) in gracefulShutdownManagers.lazy.enumerated().reversed() { + self.logger.debug( + "Triggering graceful shutdown for service", + metadata: [ + self.configuration.logging.keys.serviceKey: "\(self.services[gracefulShutdownIndex])", + ] + ) + + gracefulShutdownManager.shutdownGracefully() + + let result = await group.next() + + switch result { + case .serviceFinished(let service, let index): + if index == gracefulShutdownIndex { + // The service that we signalled graceful shutdown did exit/ + // We can continue to the next one. + self.logger.debug( + "Service finished", + metadata: [ + self.configuration.logging.keys.serviceKey: "\(service)", + ] + ) + continue + } else { + // Another service exited unexpectedly + self.logger.debug( + "Service finished unexpectedly during graceful shutdown. Cancelling all other services now", + metadata: [ + self.configuration.logging.keys.serviceKey: "\(service)", + ] + ) + + group.cancelAll() + throw ServiceGroupError.serviceFinishedUnexpectedly() + } + + case .serviceThrew(let service, _, let error): + self.logger.debug( + "Service threw error during graceful shutdown. Cancelling all other services now", + metadata: [ + self.configuration.logging.keys.serviceKey: "\(service)", + self.configuration.logging.keys.errorKey: "\(error)", + ] + ) + group.cancelAll() + + throw error + + case .signalCaught, .signalSequenceFinished, .gracefulShutdownCaught, .gracefulShutdownFinished: + // We just have to tolerate this since signals and parent graceful shutdowns downs can race. + continue + + case nil: + fatalError("Invalid result from group.next().") + } + } + + // If we hit this then all services are shutdown. The only thing remaining + // are the tasks that listen to the various graceful shutdown signals. We + // just have to cancel those + group.cancelAll() + } +} + +// This should be removed once we support Swift 5.9+ +extension AsyncStream { + fileprivate static func makeStream( + of elementType: Element.Type = Element.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) -> (stream: AsyncStream, continuation: AsyncStream.Continuation) { + var continuation: AsyncStream.Continuation! + let stream = AsyncStream(bufferingPolicy: limit) { continuation = $0 } + return (stream: stream, continuation: continuation!) + } +} diff --git a/Sources/ServiceLifecycle/ServiceRunnerConfiguration.swift b/Sources/ServiceLifecycle/ServiceRunnerConfiguration.swift new file mode 100644 index 0000000..bc29a1a --- /dev/null +++ b/Sources/ServiceLifecycle/ServiceRunnerConfiguration.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import UnixSignals + +/// The configuration for the ``ServiceGroup``. +public struct ServiceGroupConfiguration: Hashable, Sendable { + /// The group's logging configuration. + public struct LoggingConfiguration: Hashable, Sendable { + public struct Keys: Hashable, Sendable { + /// The logging key used for logging the unix signal. + public var signalKey = "signal" + /// The logging key used for logging the unix signals. + public var signalsKey = "signals" + /// The logging key used for logging the service. + public var serviceKey = "service" + /// The logging key used for logging the services. + public var servicesKey = "services" + /// The logging key used for logging an error. + public var errorKey = "error" + + /// Initializes a new ``ServiceGroupConfiguration/LoggingConfiguration/Keys``. + public init() {} + } + + /// The keys used for logging. + public var keys = Keys() + + /// Initializes a new ``ServiceGroupConfiguration/LoggingConfiguration``. + public init() {} + } + + /// The signals that lead to graceful shutdown. + public var gracefulShutdownSignals: [UnixSignal] + + /// The group's logging configuration. + public var logging: LoggingConfiguration + + /// Initializes a new ``ServiceGroupConfiguration``. + /// + /// - Parameter gracefulShutdownSignals: The signals that lead to graceful shutdown. + public init(gracefulShutdownSignals: [UnixSignal]) { + self.gracefulShutdownSignals = gracefulShutdownSignals + self.logging = .init() + } +} diff --git a/Sources/ServiceLifecycle/ServiceRunnerError.swift b/Sources/ServiceLifecycle/ServiceRunnerError.swift new file mode 100644 index 0000000..48245e5 --- /dev/null +++ b/Sources/ServiceLifecycle/ServiceRunnerError.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// Errors thrown by the ``ServiceGroup``. +public struct ServiceGroupError: Error, Hashable, Sendable { + /// A struct representing the possible error codes. + public struct Code: Hashable, Sendable, CustomStringConvertible { + private enum _Code: Hashable, Sendable { + case alreadyRunning + case alreadyFinished + case serviceFinishedUnexpectedly + } + + private var code: _Code + + private init(code: _Code) { + self.code = code + } + + public var description: String { + switch self.code { + case .alreadyRunning: + return "The service group is already running the services." + case .alreadyFinished: + return "The service group has already finished running the services." + case .serviceFinishedUnexpectedly: + return "A service has finished unexpectedly." + } + } + + /// Indicates that the service group is already running. + public static let alreadyRunning = Code(code: .alreadyRunning) + /// Indicates that the service group has already finished running. + public static let alreadyFinished = Code(code: .alreadyFinished) + /// Indicates that a service finished unexpectedly. + public static let serviceFinishedUnexpectedly = Code(code: .serviceFinishedUnexpectedly) + } + + /// Internal class that contains the actual error code. + private final class Backing: Hashable, Sendable { + let errorCode: Code + let file: String + let line: Int + + init(errorCode: Code, file: String, line: Int) { + self.errorCode = errorCode + self.file = file + self.line = line + } + + static func == (lhs: Backing, rhs: Backing) -> Bool { + lhs.errorCode == rhs.errorCode + } + + func hash(into hasher: inout Hasher) { + hasher.combine(self.errorCode) + } + } + + /// The backing storage of the error. + private let backing: Backing + + /// The error code. + /// + /// - Note: This is the only thing used for the `Equatable` and `Hashable` comparison. + public var errorCode: Code { + self.backing.errorCode + } + + private init(_ backing: Backing) { + self.backing = backing + } + + /// Indicates that the service group is already running. + public static func alreadyRunning(file: String = #fileID, line: Int = #line) -> Self { + Self( + .init( + errorCode: .alreadyRunning, + file: file, + line: line + ) + ) + } + + /// Indicates that the service group has already finished running. + public static func alreadyFinished(file: String = #fileID, line: Int = #line) -> Self { + Self( + .init( + errorCode: .alreadyFinished, + file: file, + line: line + ) + ) + } + + /// Indicates that a service finished unexpectedly even though it indicated it is a long running service. + public static func serviceFinishedUnexpectedly(file: String = #fileID, line: Int = #line) -> Self { + Self( + .init( + errorCode: .serviceFinishedUnexpectedly, + file: file, + line: line + ) + ) + } +} + +extension ServiceGroupError: CustomStringConvertible { + public var description: String { + "ServiceGroupError: errorCode: \(self.backing.errorCode), file: \(self.backing.file), line: \(self.backing.line)" + } +} diff --git a/Sources/ServiceLifecycleTestKit/GracefulShutdown.swift b/Sources/ServiceLifecycleTestKit/GracefulShutdown.swift new file mode 100644 index 0000000..e37c0c5 --- /dev/null +++ b/Sources/ServiceLifecycleTestKit/GracefulShutdown.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@_spi(TestKit) import ServiceLifecycle + +/// This struct is used in testing graceful shutdown. +/// +/// It is passed to the `operation` closure of the ``testGracefulShutdown(operation:)`` method and allows +/// to trigger the graceful shutdown for testing purposes. +public struct GracefulShutdownTestTrigger: Sendable { + private let gracefulShutdownManager: GracefulShutdownManager + + init(gracefulShutdownManager: GracefulShutdownManager) { + self.gracefulShutdownManager = gracefulShutdownManager + } + + /// Triggers the graceful shutdown. + public func triggerGracefulShutdown() { + self.gracefulShutdownManager.shutdownGracefully() + } +} + +/// Use this method for testing your graceful shutdown behaviour. +/// +/// Call the code that you want to test inside the `operation` closure and trigger the graceful shutdown by calling ``GracefulShutdownTestTrigger/triggerGracefulShutdown()`` +/// on the ``GracefulShutdownTestTrigger`` that is passed to the `operation` closure. +public func testGracefulShutdown(operation: (GracefulShutdownTestTrigger) async throws -> T) async rethrows -> T { + let gracefulShutdownManager = GracefulShutdownManager() + return try await TaskLocals.$gracefulShutdownManager.withValue(gracefulShutdownManager) { + let gracefulShutdownTestTrigger = GracefulShutdownTestTrigger(gracefulShutdownManager: gracefulShutdownManager) + return try await operation(gracefulShutdownTestTrigger) + } +} diff --git a/Sources/UnixSignals/UnixSignal.swift b/Sources/UnixSignals/UnixSignal.swift new file mode 100644 index 0000000..14b25b0 --- /dev/null +++ b/Sources/UnixSignals/UnixSignal.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +import Dispatch + +/// A struct representing a Unix signal. +/// +/// Signals are standardized messages sent to a running program to trigger specific behavior, such as quitting or error handling +public struct UnixSignal: Hashable, Sendable, CustomStringConvertible { + internal enum Wrapped { + case sighup + case sigint + case sigterm + case sigusr1 + case sigusr2 + case sigalrm + } + + private let wrapped: Wrapped + private init(_ wrapped: Wrapped) { + self.wrapped = wrapped + } + + public var rawValue: Int32 { + return self.wrapped.rawValue + } + + public var description: String { + return String(describing: self.wrapped) + } + + /// Hang up detected on controlling terminal or death of controlling process. + public static let sighup = Self(.sighup) + /// Issued if the user sends an interrupt signal. + public static let sigint = Self(.sigint) + /// Software termination signal. + public static let sigterm = Self(.sigterm) + public static let sigusr1 = Self(.sigusr1) + public static let sigusr2 = Self(.sigusr2) + public static let sigalrm = Self(.sigalrm) +} + +extension UnixSignal.Wrapped: Hashable {} +extension UnixSignal.Wrapped: Sendable {} + +extension UnixSignal.Wrapped: CustomStringConvertible { + var description: String { + switch self { + case .sighup: + return "SIGHUP" + case .sigint: + return "SIGINT" + case .sigterm: + return "SIGTERM" + case .sigusr1: + return "SIGUSR1" + case .sigusr2: + return "SIGUSR2" + case .sigalrm: + return "SIGALRM" + } + } +} + +extension UnixSignal.Wrapped { + var rawValue: Int32 { + switch self { + case .sighup: + return SIGHUP + case .sigint: + return SIGINT + case .sigterm: + return SIGTERM + case .sigusr1: + return SIGUSR1 + case .sigusr2: + return SIGUSR2 + case .sigalrm: + return SIGALRM + } + } +} diff --git a/Sources/UnixSignals/UnixSignalsSequence.swift b/Sources/UnixSignals/UnixSignalsSequence.swift new file mode 100644 index 0000000..73f1679 --- /dev/null +++ b/Sources/UnixSignals/UnixSignalsSequence.swift @@ -0,0 +1,287 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif +import ConcurrencyHelpers +import Dispatch + +/// An unterminated `AsyncSequence` of ``UnixSignal``s. +/// +/// This can be used to setup signal handlers and receive the signals on the sequence. +/// +/// - Important: There can only be a single signal handler for a signal installed. So you should avoid creating multiple handlers +/// for the same signal. +public struct UnixSignalsSequence: AsyncSequence, Sendable { + private static let queue = DispatchQueue(label: "com.service-lifecycle.unix-signals") + + public typealias Element = UnixSignal + + fileprivate struct Source { + var dispatchSource: any DispatchSourceSignal + var signal: UnixSignal + } + + private let storage: Storage + + public init(trapping signals: UnixSignal...) async { + await self.init(trapping: signals) + } + + public init(trapping signals: [UnixSignal]) async { + // We are converting the signals to a Set here to remove duplicates + self.storage = await .init(signals: Set(signals)) + } + + public func makeAsyncIterator() -> AsyncIterator { + return .init(iterator: self.storage.makeAsyncIterator(), storage: self.storage) + } + + public struct AsyncIterator: AsyncIteratorProtocol { + private var iterator: AsyncStream.Iterator + // We need to keep the underlying `DispatchSourceSignal` alive to receive notifications. + private let storage: Storage + + fileprivate init(iterator: AsyncStream.Iterator, storage: Storage) { + self.iterator = iterator + self.storage = storage + } + + public mutating func next() async -> UnixSignal? { + return await self.iterator.next() + } + } +} + +extension UnixSignalsSequence { + fileprivate final class Storage: @unchecked Sendable { + private let stateMachine: LockedValueBox + + init(signals: Set) async { + let sources: [Source] = signals.map { sig in + #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) + // On Darwin platforms Dispatch's signal source uses kqueue and EVFILT_SIGNAL for + // delivering signals. This exists alongside but with lower precedence than signal and + // sigaction: ignore signal handling here to kqueue can deliver signals. + signal(sig.rawValue, SIG_IGN) + #endif + return .init( + dispatchSource: DispatchSource.makeSignalSource(signal: sig.rawValue, queue: UnixSignalsSequence.queue), + signal: sig + ) + } + + let stream = AsyncStream { continuation in + for source in sources { + source.dispatchSource.setEventHandler { + continuation.yield(source.signal) + } + source.dispatchSource.setCancelHandler { + continuation.finish() + } + } + + // Don't wait forever if there's nothing to wait for. + if sources.isEmpty { + continuation.finish() + } + } + + self.stateMachine = .init(.init(sources: sources, stream: stream)) + + // Registering sources is async: await their registration so we don't miss early signals. + await withTaskCancellationHandler { + for source in sources { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let action = self.stateMachine.withLockedValue { $0.registeringSignal(continuation: continuation) } + switch action { + case .setRegistrationHandlerAndResumeDispatchSource: + source.dispatchSource.setRegistrationHandler { + let action = self.stateMachine.withLockedValue { $0.registeredSignal() } + + switch action { + case .none: + break + + case .resumeContinuation(let continuation): + continuation.resume() + } + } + source.dispatchSource.resume() + + case .resumeContinuation(let continuation): + continuation.resume() + } + } + } + } onCancel: { + let action = self.stateMachine.withLockedValue { $0.cancelledInit() } + + switch action { + case .none: + break + + case .cancelAllSources: + for source in sources { + source.dispatchSource.cancel() + } + + case .resumeContinuationAndCancelAllSources(let continuation): + continuation.resume() + + for source in sources { + source.dispatchSource.cancel() + } + } + } + } + + func makeAsyncIterator() -> AsyncStream.AsyncIterator { + return self.stateMachine.withLockedValue { $0.makeAsyncIterator() } + } + } +} + +extension UnixSignalsSequence { + fileprivate struct StateMachine { + private enum State { + /// The initial state. + case canRegisterSignal( + sources: [Source], + stream: AsyncStream + ) + /// The state once we started to register a signal handler. + /// + /// - Note: We can enter this state multiple times. One for each signal handler. + case registeringSignal( + sources: [Source], + stream: AsyncStream, + continuation: CheckedContinuation + ) + /// The state once an iterator has been created. + case producing( + sources: [Source], + stream: AsyncStream + ) + /// The state when the task that creates the UnixSignals gets cancelled during init. + case cancelled( + stream: AsyncStream + ) + } + + private var state: State + + init(sources: [Source], stream: AsyncStream) { + self.state = .canRegisterSignal(sources: sources, stream: stream) + } + + enum RegisteringSignalAction { + case setRegistrationHandlerAndResumeDispatchSource + case resumeContinuation(CheckedContinuation) + } + + mutating func registeringSignal(continuation: CheckedContinuation) -> RegisteringSignalAction { + switch self.state { + case .canRegisterSignal(let sources, let stream): + self.state = .registeringSignal(sources: sources, stream: stream, continuation: continuation) + + return .setRegistrationHandlerAndResumeDispatchSource + + case .registeringSignal: + fatalError("UnixSignals tried to register multiple signals at once") + + case .producing: + fatalError("UnixSignals tried to create an iterator before the init was done") + + case .cancelled: + return .resumeContinuation(continuation) + } + } + + enum RegisteredSignalAction { + case resumeContinuation(CheckedContinuation) + } + + mutating func registeredSignal() -> RegisteredSignalAction? { + switch self.state { + case .canRegisterSignal: + fatalError("UnixSignals tried to register signals more than once") + + case .registeringSignal(let sources, let stream, let continuation): + self.state = .canRegisterSignal(sources: sources, stream: stream) + + return .resumeContinuation(continuation) + + case .producing: + fatalError("UnixSignals tried to create an iterator before the init was done") + + case .cancelled: + // This is okay. The registration and cancelling the source might race. + return .none + } + } + + enum CancelledInitAction { + case cancelAllSources + case resumeContinuationAndCancelAllSources(CheckedContinuation) + } + + mutating func cancelledInit() -> CancelledInitAction? { + switch self.state { + case .canRegisterSignal(_, let stream): + self.state = .cancelled(stream: stream) + + return .cancelAllSources + + case .registeringSignal(_, let stream, let continuation): + self.state = .cancelled(stream: stream) + + return .resumeContinuationAndCancelAllSources(continuation) + + case .producing: + // This is a weird one. The task that created the UnixSignals + // got cancelled but we already made an iterator. I guess + // this can happen while we are racing so let's just tolerate that + // and do nothing. If the task that created the UnixSignals and is + // consuming it is the same one then the stream will terminate anyhow. + return .none + + case .cancelled: + fatalError("UnixSignals registration cancelled more than once") + } + } + + mutating func makeAsyncIterator() -> AsyncStream.AsyncIterator { + switch self.state { + case .canRegisterSignal(let sources, let stream): + // This can happen when we created a UnixSignal without any signals + self.state = .producing(sources: sources, stream: stream) + + return stream.makeAsyncIterator() + + case .registeringSignal: + fatalError("UnixSignals tried to create iterator before all handlers were installed") + + case .producing: + fatalError("UnixSignals only allows a single iterator to be created") + + case .cancelled(let stream): + return stream.makeAsyncIterator() + } + } + } +} diff --git a/Tests/LifecycleTests/ComponentLifecycleTests+XCTest.swift b/Tests/LifecycleTests/ComponentLifecycleTests+XCTest.swift deleted file mode 100644 index 656bb1f..0000000 --- a/Tests/LifecycleTests/ComponentLifecycleTests+XCTest.swift +++ /dev/null @@ -1,82 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -// -// ComponentLifecycleTests+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 ComponentLifecycleTests { - static var allTests: [(String, (ComponentLifecycleTests) -> () throws -> Void)] { - return [ - ("testStartThenShutdown", testStartThenShutdown), - ("testDeregister", testDeregister), - ("testDeregisterAfterStart", testDeregisterAfterStart), - ("testDefaultCallbackQueue", testDefaultCallbackQueue), - ("testUserDefinedCallbackQueue", testUserDefinedCallbackQueue), - ("testShutdownWhileStarting", testShutdownWhileStarting), - ("testShutdownWhenIdle", testShutdownWhenIdle), - ("testShutdownWhenIdleAndNoItems", testShutdownWhenIdleAndNoItems), - ("testIfNotStartedWhenIdle", testIfNotStartedWhenIdle), - ("testShutdownWhenShutdown", testShutdownWhenShutdown), - ("testShutdownDuringHangingStart", testShutdownDuringHangingStart), - ("testShutdownErrors", testShutdownErrors), - ("testStartupErrors", testStartupErrors), - ("testStartAndWait", testStartAndWait), - ("testBadStartAndWait", testBadStartAndWait), - ("testShutdownInOrder", testShutdownInOrder), - ("testSync", testSync), - ("testAyncBarrier", testAyncBarrier), - ("testConcurrency", testConcurrency), - ("testZeroTask", testZeroTask), - ("testRegisterSync", testRegisterSync), - ("testRegisterShutdownSync", testRegisterShutdownSync), - ("testRegisterAsync", testRegisterAsync), - ("testRegisterShutdownAsync", testRegisterShutdownAsync), - ("testRegisterAsyncClosure", testRegisterAsyncClosure), - ("testRegisterShutdownAsyncClosure", testRegisterShutdownAsyncClosure), - ("testRegisterNIO", testRegisterNIO), - ("testRegisterShutdownNIO", testRegisterShutdownNIO), - ("testRegisterNIOClosure", testRegisterNIOClosure), - ("testRegisterShutdownNIOClosure", testRegisterShutdownNIOClosure), - ("testNIOFailure", testNIOFailure), - ("testInternalState", testInternalState), - ("testExternalState", testExternalState), - ("testNOOPHandlers", testNOOPHandlers), - ("testShutdownOnlyStarted", testShutdownOnlyStarted), - ("testShutdownWhenStartFailedIfAsked", testShutdownWhenStartFailedIfAsked), - ("testShutdownWhenStartFailsAndAsked", testShutdownWhenStartFailsAndAsked), - ("testStatefulSync", testStatefulSync), - ("testStatefulSyncStartError", testStatefulSyncStartError), - ("testStatefulSyncShutdownError", testStatefulSyncShutdownError), - ("testStatefulAsync", testStatefulAsync), - ("testStatefulAsyncStartError", testStatefulAsyncStartError), - ("testStatefulAsyncShutdownError", testStatefulAsyncShutdownError), - ("testStatefulNIO", testStatefulNIO), - ("testStatefulNIOStartFailure", testStatefulNIOStartFailure), - ("testStatefulNIOShutdownFailure", testStatefulNIOShutdownFailure), - ("testAsyncAwait", testAsyncAwait), - ("testAsyncAwaitStateful", testAsyncAwaitStateful), - ("testAsyncAwaitErrorOnStart", testAsyncAwaitErrorOnStart), - ("testAsyncAwaitErrorOnStartShutdownRequested", testAsyncAwaitErrorOnStartShutdownRequested), - ("testAsyncAwaitErrorOnShutdown", testAsyncAwaitErrorOnShutdown), - ("testMetrics", testMetrics), - ] - } -} diff --git a/Tests/LifecycleTests/ComponentLifecycleTests.swift b/Tests/LifecycleTests/ComponentLifecycleTests.swift deleted file mode 100644 index b20116d..0000000 --- a/Tests/LifecycleTests/ComponentLifecycleTests.swift +++ /dev/null @@ -1,1762 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -@testable import Lifecycle -import LifecycleNIOCompat -import Metrics -import NIO -import XCTest - -final class ComponentLifecycleTests: XCTestCase { - func testStartThenShutdown() { - let items = (5 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(items) - lifecycle.start { startError in - XCTAssertNil(startError, "not expecting error") - lifecycle.shutdown { shutdownErrors in - XCTAssertNil(shutdownErrors, "not expecting error") - } - } - lifecycle.wait() - items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - } - - func testDeregister() { - class BadItem: LifecycleTask { - let label: String = UUID().uuidString - - func start(_ callback: (Error?) -> Void) { - callback(TestError()) - } - - func shutdown(_ callback: (Error?) -> Void) { - callback(TestError()) - } - } - - let lifecycle = ComponentLifecycle(label: "test") - let itemToDeregister1 = BadItem() - let itemToDeregister2 = BadItem() - lifecycle.register(GoodItem()) - let key1 = lifecycle.register(itemToDeregister1) - lifecycle.register(GoodItem()) - lifecycle.register(GoodItem()) - let key2 = lifecycle.register(itemToDeregister2) - - lifecycle.deregister(key1) - lifecycle.deregister(key2) - - lifecycle.start { startError in - XCTAssertNil(startError, "not expecting error") - lifecycle.shutdown { shutdownErrors in - XCTAssertNil(shutdownErrors, "not expecting error") - } - } - lifecycle.wait() - } - - func testDeregisterAfterStart() { - class BadItem: LifecycleTask { - let label: String = UUID().uuidString - - func start(_ callback: (Error?) -> Void) { - callback(.none) // okay - } - - func shutdown(_ callback: (Error?) -> Void) { - callback(TestError()) - } - } - - let lifecycle = ComponentLifecycle(label: "test") - let itemToDeregister1 = BadItem() - let itemToDeregister2 = BadItem() - lifecycle.register(GoodItem()) - let key1 = lifecycle.register(itemToDeregister1) - lifecycle.register(GoodItem()) - lifecycle.register(GoodItem()) - let key2 = lifecycle.register(itemToDeregister2) - - lifecycle.start { startError in - XCTAssertNil(startError, "not expecting error") - lifecycle.deregister(key1) - lifecycle.deregister(key2) - lifecycle.shutdown { shutdownErrors in - XCTAssertNil(shutdownErrors, "not expecting error") - } - } - lifecycle.wait() - } - - func testDefaultCallbackQueue() throws { - guard #available(OSX 10.12, *) else { - return - } - - let lifecycle = ComponentLifecycle(label: "test") - var startCalls = [String]() - var stopCalls = [String]() - - let items = (1 ... Int.random(in: 10 ... 20)).map { index -> LifecycleTask in - let id = "item-\(index)" - return _LifecycleTask(label: id, - start: .sync { - dispatchPrecondition(condition: .onQueue(.global())) - startCalls.append(id) - }, - shutdown: .sync { - dispatchPrecondition(condition: .onQueue(.global())) - XCTAssertTrue(startCalls.contains(id)) - stopCalls.append(id) - }) - } - lifecycle.register(items) - - lifecycle.start { startError in - dispatchPrecondition(condition: .onQueue(.global())) - XCTAssertNil(startError, "not expecting error") - lifecycle.shutdown { shutdownErrors in - dispatchPrecondition(condition: .onQueue(.global())) - XCTAssertNil(shutdownErrors, "not expecting error") - } - } - lifecycle.wait() - items.forEach { item in XCTAssertTrue(startCalls.contains(item.label), "expected \(item.label) to be started") } - items.forEach { item in XCTAssertTrue(stopCalls.contains(item.label), "expected \(item.label) to be stopped") } - } - - func testUserDefinedCallbackQueue() throws { - guard #available(OSX 10.12, *) else { - return - } - - let lifecycle = ComponentLifecycle(label: "test") - let testQueue = DispatchQueue(label: UUID().uuidString) - var startCalls = [String]() - var stopCalls = [String]() - - let items = (1 ... Int.random(in: 10 ... 20)).map { index -> LifecycleTask in - let id = "item-\(index)" - return _LifecycleTask(label: id, - shutdownIfNotStarted: false, - start: .sync { - dispatchPrecondition(condition: .onQueue(testQueue)) - startCalls.append(id) - }, - shutdown: .sync { - dispatchPrecondition(condition: .onQueue(testQueue)) - XCTAssertTrue(startCalls.contains(id)) - stopCalls.append(id) - }) - } - lifecycle.register(items) - - lifecycle.start(on: testQueue) { startError in - dispatchPrecondition(condition: .onQueue(testQueue)) - XCTAssertNil(startError, "not expecting error") - lifecycle.shutdown { shutdownErrors in - dispatchPrecondition(condition: .onQueue(testQueue)) - XCTAssertNil(shutdownErrors, "not expecting error") - } - } - lifecycle.wait() - items.forEach { item in XCTAssertTrue(startCalls.contains(item.label), "expected \(item.label) to be started") } - items.forEach { item in XCTAssertTrue(stopCalls.contains(item.label), "expected \(item.label) to be stopped") } - } - - func testShutdownWhileStarting() { - class Item: LifecycleTask { - let startedCallback: () -> Void - var state = State.idle - - let label = UUID().uuidString - - init(_ startedCallback: @escaping () -> Void) { - self.startedCallback = startedCallback - } - - func start(_ callback: @escaping (Error?) -> Void) { - DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) { - self.state = .started - self.startedCallback() - callback(nil) - } - } - - func shutdown(_ callback: (Error?) -> Void) { - self.state = .shutdown - callback(nil) - } - - enum State { - case idle - case started - case shutdown - } - } - var started = 0 - let startSempahore = DispatchSemaphore(value: 0) - let items = (5 ... Int.random(in: 10 ... 20)).map { _ in Item { - started += 1 - startSempahore.signal() - } } - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(items) - lifecycle.start { _ in } - startSempahore.wait() - lifecycle.shutdown() - lifecycle.wait() - XCTAssertGreaterThan(started, 0, "expected some start") - XCTAssertLessThan(started, items.count, "exppcts partial start") - items.prefix(started).forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - items.suffix(started + 1).forEach { XCTAssertEqual($0.state, .idle, "expected item to be idle, but \($0.state)") } - } - - func testShutdownWhenIdle() { - let lifecycle = ComponentLifecycle(label: "test") - - let item = GoodItem() - lifecycle.register(item) - - let semaphore1 = DispatchSemaphore(value: 0) - lifecycle.shutdown { errors in - XCTAssertNil(errors) - semaphore1.signal() - } - lifecycle.wait() - XCTAssertEqual(.success, semaphore1.wait(timeout: .now() + 1)) - - let semaphore2 = DispatchSemaphore(value: 0) - lifecycle.shutdown { errors in - XCTAssertNil(errors) - semaphore2.signal() - } - lifecycle.wait() - XCTAssertEqual(.success, semaphore2.wait(timeout: .now() + 1)) - - XCTAssertEqual(item.state, .idle, "expected item to be idle") - } - - func testShutdownWhenIdleAndNoItems() { - let lifecycle = ComponentLifecycle(label: "test") - - let semaphore1 = DispatchSemaphore(value: 0) - lifecycle.shutdown { errors in - XCTAssertNil(errors) - semaphore1.signal() - } - lifecycle.wait() - XCTAssertEqual(.success, semaphore1.wait(timeout: .now() + 1)) - - let semaphore2 = DispatchSemaphore(value: 0) - lifecycle.shutdown { errors in - XCTAssertNil(errors) - semaphore2.signal() - } - lifecycle.wait() - XCTAssertEqual(.success, semaphore2.wait(timeout: .now() + 1)) - } - - func testIfNotStartedWhenIdle() { - var shutdown1Called = false - var shutdown2Called = false - var shutdown3Called = false - - let lifecycle = ComponentLifecycle(label: "test") - - lifecycle.register(label: "shutdown1", - start: .sync {}, - shutdown: .sync { shutdown1Called = true }, - shutdownIfNotStarted: true) - - lifecycle.register(label: "shutdown2", start: .none, shutdown: .sync { - shutdown2Called = true - }) - - lifecycle.registerShutdown(label: "shutdown3", .sync { - shutdown3Called = true - }) - - lifecycle.shutdown() - lifecycle.wait() - - XCTAssertTrue(shutdown1Called, "expected shutdown to be called") - XCTAssertTrue(shutdown2Called, "expected shutdown to be called") - XCTAssertTrue(shutdown3Called, "expected shutdown to be called") - } - - func testShutdownWhenShutdown() { - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(GoodItem()) - let sempahpore1 = DispatchSemaphore(value: 0) - lifecycle.start { _ in - lifecycle.shutdown { errors in - XCTAssertNil(errors) - sempahpore1.signal() - } - } - lifecycle.wait() - XCTAssertEqual(.success, sempahpore1.wait(timeout: .now() + 1)) - - let sempahpore2 = DispatchSemaphore(value: 0) - lifecycle.shutdown { errors in - XCTAssertNil(errors) - sempahpore2.signal() - } - lifecycle.wait() - XCTAssertEqual(.success, sempahpore2.wait(timeout: .now() + 1)) - } - - func testShutdownDuringHangingStart() { - let lifecycle = ComponentLifecycle(label: "test") - let blockStartSemaphore = DispatchSemaphore(value: 0) - var startCalls = [String]() - var stopCalls = [String]() - - do { - let id = UUID().uuidString - lifecycle.register(label: id, - start: .sync { - startCalls.append(id) - blockStartSemaphore.wait() - }, - shutdown: .sync { - XCTAssertTrue(startCalls.contains(id)) - stopCalls.append(id) - }) - } - do { - let id = UUID().uuidString - lifecycle.register(label: id, - start: .sync { - startCalls.append(id) - }, - shutdown: .sync { - XCTAssertTrue(startCalls.contains(id)) - stopCalls.append(id) - }) - } - lifecycle.start { error in - XCTAssertNil(error) - } - lifecycle.shutdown() - blockStartSemaphore.signal() - lifecycle.wait() - XCTAssertEqual(startCalls.count, 1) - XCTAssertEqual(stopCalls.count, 1) - } - - func testShutdownErrors() { - class BadItem: LifecycleTask { - let label = UUID().uuidString - - func start(_ callback: (Error?) -> Void) { - callback(nil) - } - - func shutdown(_ callback: (Error?) -> Void) { - callback(TestError()) - } - } - - var shutdownError: Lifecycle.ShutdownError? - let shutdownSemaphore = DispatchSemaphore(value: 0) - let items: [LifecycleTask] = [GoodItem(), BadItem(), BadItem(), GoodItem(), BadItem()] - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(items) - lifecycle.start { startError in - XCTAssertNil(startError, "not expecting error") - lifecycle.shutdown { error in - shutdownError = error as? Lifecycle.ShutdownError - shutdownSemaphore.signal() - } - } - lifecycle.wait() - XCTAssertEqual(.success, shutdownSemaphore.wait(timeout: .now() + 1)) - - 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 testStartupErrors() { - class BadItem: LifecycleTask { - let label: String = UUID().uuidString - - func start(_ callback: (Error?) -> Void) { - callback(TestError()) - } - - func shutdown(_ callback: (Error?) -> Void) { - callback(nil) - } - } - - let items: [LifecycleTask] = [GoodItem(), GoodItem(), BadItem(), GoodItem()] - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(items) - lifecycle.start { error in - XCTAssert(error is TestError, "expected error to match") - } - lifecycle.wait() - let badItemIndex = items.firstIndex { $0 as? BadItem != nil }! - items.prefix(badItemIndex).compactMap { $0 as? GoodItem }.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - items.suffix(from: badItemIndex + 1).compactMap { $0 as? GoodItem }.forEach { XCTAssertEqual($0.state, .idle, "expected item to be idle, but \($0.state)") } - } - - func testStartAndWait() { - class Item: LifecycleTask { - 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 = ComponentLifecycle(label: "test") - 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()) - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown") - } - - func testBadStartAndWait() { - class BadItem: LifecycleTask { - let label: String = UUID().uuidString - - func start(_ callback: (Error?) -> Void) { - callback(TestError()) - } - - func shutdown(_ callback: (Error?) -> Void) { - callback(nil) - } - } - - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(GoodItem(), BadItem()) - XCTAssertThrowsError(try lifecycle.startAndWait()) { error in - XCTAssert(error is TestError, "expected error to match") - } - } - - func testShutdownInOrder() { - class Item: LifecycleTask { - let id: String - var result: [String] - - init(_ result: inout [String]) { - self.id = UUID().uuidString - self.result = result - } - - var label: String { - return self.id - } - - func start(_ callback: (Error?) -> Void) { - self.result.append(self.id) - callback(nil) - } - - func shutdown(_ callback: (Error?) -> Void) { - if self.result.last == self.id { - _ = self.result.removeLast() - } - callback(nil) - } - } - - var result = [String]() - let items = [Item(&result), Item(&result), Item(&result), Item(&result)] - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(items) - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssert(result.isEmpty, "expected item to be shutdown in order") - } - - func testSync() { - class Sync { - let id: String - var state = State.idle - - init() { - self.id = UUID().uuidString - } - - func start() { - self.state = .started - } - - func shutdown() { - self.state = .shutdown - } - - enum State { - case idle - case started - case shutdown - } - } - - let lifecycle = ComponentLifecycle(label: "test") - let items = (5 ... Int.random(in: 10 ... 20)).map { _ in Sync() } - items.forEach { item in - lifecycle.register(label: item.id, start: .sync(item.start), shutdown: .sync(item.shutdown)) - } - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - } - - func testAyncBarrier() { - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = ComponentLifecycle(label: "test") - - let item1 = NIOItem(eventLoopGroup: eventLoopGroup) - lifecycle.register(label: "item1", start: .eventLoopFuture(item1.start), shutdown: .eventLoopFuture(item1.shutdown)) - - lifecycle.register(label: "blocker", - start: .sync { try eventLoopGroup.next().makeSucceededFuture(()).wait() }, - shutdown: .sync { try eventLoopGroup.next().makeSucceededFuture(()).wait() }) - - let item2 = NIOItem(eventLoopGroup: eventLoopGroup) - lifecycle.register(label: "item2", start: .eventLoopFuture(item2.start), shutdown: .eventLoopFuture(item2.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - [item1, item2].forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - } - - func testConcurrency() { - let lifecycle = ComponentLifecycle(label: "test") - let items = (5000 ... 10000).map { _ in GoodItem(startDelay: 0, shutdownDelay: 0) } - let group = DispatchGroup() - items.forEach { item in - group.enter() - DispatchQueue(label: "test", attributes: .concurrent).async { - defer { group.leave() } - lifecycle.register(item) - } - } - group.wait() - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - } - - func testZeroTask() { - let lifecycle = ComponentLifecycle(label: "test") - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - } - - func testRegisterSync() { - class Sync { - var state = State.idle - - func start() { - self.state = .started - } - - func shutdown() { - self.state = .shutdown - } - - enum State { - case idle - case started - case shutdown - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Sync() - lifecycle.register(label: "test", - start: .sync(item.start), - shutdown: .sync(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testRegisterShutdownSync() { - class Sync { - var state = State.idle - - func start() { - self.state = .started - } - - func shutdown() { - self.state = .shutdown - } - - enum State { - case idle - case started - case shutdown - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Sync() - lifecycle.registerShutdown(label: "test", .sync(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testRegisterAsync() { - let lifecycle = ComponentLifecycle(label: "test") - - let item = GoodItem() - lifecycle.register(label: "test", - start: .async(item.start), - shutdown: .async(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testRegisterShutdownAsync() { - let lifecycle = ComponentLifecycle(label: "test") - - let item = GoodItem() - lifecycle.registerShutdown(label: "test", .async(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testRegisterAsyncClosure() { - let lifecycle = ComponentLifecycle(label: "test") - - let item = GoodItem() - lifecycle.register(label: "test", - start: .async { callback in - item.start(callback) - }, - shutdown: .async { callback in - item.shutdown(callback) - }) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testRegisterShutdownAsyncClosure() { - let lifecycle = ComponentLifecycle(label: "test") - - let item = GoodItem() - lifecycle.registerShutdown(label: "test", .async { callback in - item.shutdown(callback) - }) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testRegisterNIO() { - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = ComponentLifecycle(label: "test") - - let item = NIOItem(eventLoopGroup: eventLoopGroup) - lifecycle.register(label: item.id, - start: .eventLoopFuture(item.start), - shutdown: .eventLoopFuture(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testRegisterShutdownNIO() { - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = ComponentLifecycle(label: "test") - - let item = NIOItem(eventLoopGroup: eventLoopGroup) - lifecycle.registerShutdown(label: item.id, .eventLoopFuture(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testRegisterNIOClosure() { - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = ComponentLifecycle(label: "test") - - let item = NIOItem(eventLoopGroup: eventLoopGroup) - lifecycle.register(label: item.id, - start: .eventLoopFuture { - print("start") - return item.start() - }, - shutdown: .eventLoopFuture { - print("shutdown") - return item.shutdown() - }) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testRegisterShutdownNIOClosure() { - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = ComponentLifecycle(label: "test") - - let item = NIOItem(eventLoopGroup: eventLoopGroup) - lifecycle.registerShutdown(label: item.id, .eventLoopFuture { - print("shutdown") - return item.shutdown() - }) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - func testNIOFailure() { - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let lifecycle = ComponentLifecycle(label: "test") - - lifecycle.register(label: "test", - start: .eventLoopFuture { eventLoopGroup.next().makeFailedFuture(TestError()) }, - shutdown: .eventLoopFuture { eventLoopGroup.next().makeSucceededFuture(()) }) - - lifecycle.start { error in - XCTAssert(error is TestError, "expected error to match") - lifecycle.shutdown() - } - lifecycle.wait() - } - - // this is an example of how state can be managed inside a `LifecycleItem` - // note the use of locks in this example since there could be concurrent access issues - // in case shutdown is called (e.g. via signal trap) during the startup sequence - // see also `testExternalState` test case - func testInternalState() { - class Item { - enum State: Equatable { - case idle - case starting - case started(String) - case shuttingDown - case shutdown - } - - var state = State.idle - let stateLock = Lock() - - let queue = DispatchQueue(label: "test") - - let data: String - - init(_ data: String) { - self.data = data - } - - func start(callback: @escaping (Error?) -> Void) { - self.stateLock.withLock { - self.state = .starting - } - self.queue.asyncAfter(deadline: .now() + Double.random(in: 0.01 ... 0.1)) { - self.stateLock.withLock { - self.state = .started(self.data) - } - callback(nil) - } - } - - func shutdown(callback: @escaping (Error?) -> Void) { - self.stateLock.withLock { - self.state = .shuttingDown - } - self.queue.asyncAfter(deadline: .now() + Double.random(in: 0.01 ... 0.1)) { - self.stateLock.withLock { - self.state = .shutdown - } - callback(nil) - } - } - } - - let expectedData = UUID().uuidString - let item = Item(expectedData) - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(label: "test", - start: .async(item.start), - shutdown: .async(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - XCTAssertEqual(item.state, .started(expectedData), "expected item to be shutdown, but \(item.state)") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown, but \(item.state)") - } - - // this is an example of how state can be managed outside the `Lifecycle` - // note the use of locks in this example since there could be concurrent access issues - // in case shutdown is called (e.g. via signal trap) during the startup sequence - // see also `testInternalState` test case, which is the prefered way to manage item's state - func testExternalState() { - enum State: Equatable { - case idle - case started(String) - case shutdown - } - - class Item { - let queue = DispatchQueue(label: "test") - - let data: String - - init(_ data: String) { - self.data = data - } - - func start(callback: @escaping (String) -> Void) { - self.queue.asyncAfter(deadline: .now() + Double.random(in: 0.01 ... 0.1)) { - callback(self.data) - } - } - - func shutdown(callback: @escaping () -> Void) { - self.queue.asyncAfter(deadline: .now() + Double.random(in: 0.01 ... 0.1)) { - callback() - } - } - } - - var state = State.idle - let stateLock = Lock() - - let expectedData = UUID().uuidString - let item = Item(expectedData) - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(label: "test", - start: .async { callback in - item.start { data in - stateLock.withLock { - state = .started(data) - } - callback(nil) - } - }, - shutdown: .async { callback in - item.shutdown { - stateLock.withLock { - state = .shutdown - } - callback(nil) - } - }) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - XCTAssertEqual(state, .started(expectedData), "expected item to be shutdown, but \(state)") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertEqual(state, .shutdown, "expected item to be shutdown, but \(state)") - } - - func testNOOPHandlers() { - let none = LifecycleHandler.none - XCTAssertEqual(none.noop, true) - - let sync = LifecycleHandler.sync {} - XCTAssertEqual(sync.noop, false) - - let async = LifecycleHandler.async { _ in } - XCTAssertEqual(async.noop, false) - - let custom = LifecycleHandler { _ in } - XCTAssertEqual(custom.noop, false) - } - - func testShutdownOnlyStarted() { - class Item { - let label: String - let semaphore: DispatchSemaphore - let failStart: Bool - let expectedState: State - var state = State.idle - - deinit { - XCTAssertEqual(self.state, self.expectedState, "\"\(self.label)\" should be \(self.expectedState)") - self.semaphore.signal() - } - - init(label: String, failStart: Bool, expectedState: State, semaphore: DispatchSemaphore) { - self.label = label - self.failStart = failStart - self.expectedState = expectedState - self.semaphore = semaphore - } - - func start() throws { - self.state = .started - if self.failStart { - self.state = .error - throw InitError() - } - } - - func shutdown() throws { - self.state = .shutdown - } - - enum State { - case idle - case started - case shutdown - case error - } - - struct InitError: Error {} - } - - let count = Int.random(in: 10 ..< 20) - let semaphore = DispatchSemaphore(value: count) - let lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: nil)) - - for index in 0 ..< count { - let failStart = index == count / 2 - let item = Item(label: "\(index)", failStart: failStart, expectedState: failStart ? .error : index <= count / 2 ? .shutdown : .idle, semaphore: semaphore) - lifecycle.register(label: item.label, start: .sync(item.start), shutdown: .sync(item.shutdown)) - } - - lifecycle.start { error in - XCTAssertNotNil(error, "expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - - XCTAssertEqual(.success, semaphore.wait(timeout: .now() + 1)) - } - - func testShutdownWhenStartFailedIfAsked() { - class DestructionSensitive { - let label: String - let failStart: Bool - let semaphore: DispatchSemaphore - var state = State.idle - - deinit { - if failStart { - XCTAssertEqual(self.state, .error, "\"\(self.label)\" should be error") - } else { - XCTAssertEqual(self.state, .shutdown, "\"\(self.label)\" should be shutdown") - } - self.semaphore.signal() - } - - init(label: String, failStart: Bool = false, semaphore: DispatchSemaphore) { - self.label = label - self.failStart = failStart - self.semaphore = semaphore - } - - func start() throws { - self.state = .started - if self.failStart { - self.state = .error - throw InitError() - } - } - - func shutdown() throws { - self.state = .shutdown - } - - enum State { - case idle - case started - case shutdown - case error - } - - struct InitError: Error {} - } - - let semaphore = DispatchSemaphore(value: 6) - let lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: nil)) - - let item1 = DestructionSensitive(label: "1", semaphore: semaphore) - lifecycle.register(label: item1.label, start: .sync(item1.start), shutdown: .sync(item1.shutdown)) - - let item2 = DestructionSensitive(label: "2", semaphore: semaphore) - lifecycle.registerShutdown(label: item2.label, .sync(item2.shutdown)) - - let item3 = DestructionSensitive(label: "3", failStart: true, semaphore: semaphore) - lifecycle.register(label: item3.label, start: .sync(item3.start), shutdown: .sync(item3.shutdown)) - - let item4 = DestructionSensitive(label: "4", semaphore: semaphore) - lifecycle.registerShutdown(label: item4.label, .sync(item4.shutdown)) - - let item5 = DestructionSensitive(label: "5", semaphore: semaphore) - lifecycle.register(label: item5.label, start: .none, shutdown: .sync(item5.shutdown)) - - let item6 = DestructionSensitive(label: "6", semaphore: semaphore) - lifecycle.register(_LifecycleTask(label: item6.label, shutdownIfNotStarted: true, start: .sync(item6.start), shutdown: .sync(item6.shutdown))) - - lifecycle.start { error in - XCTAssertNotNil(error, "expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - - XCTAssertEqual(.success, semaphore.wait(timeout: .now() + 1)) - } - - func testShutdownWhenStartFailsAndAsked() { - class BadItem: LifecycleTask { - let label: String = UUID().uuidString - var shutdown: Bool = false - - func start(_ callback: (Error?) -> Void) { - callback(TestError()) - } - - func shutdown(_ callback: (Error?) -> Void) { - self.shutdown = true - callback(nil) - } - } - - do { - let lifecycle = ComponentLifecycle(label: "test") - - let item = BadItem() - lifecycle.register(label: "test", start: .async(item.start), shutdown: .async(item.shutdown), shutdownIfNotStarted: true) - - XCTAssertThrowsError(try lifecycle.startAndWait()) { error in - XCTAssert(error is TestError, "expected error to match") - } - - XCTAssertTrue(item.shutdown, "expected item to be shutdown") - } - - do { - let lifecycle = ComponentLifecycle(label: "test") - - let item = BadItem() - lifecycle.register(label: "test", start: .async(item.start), shutdown: .async(item.shutdown), shutdownIfNotStarted: false) - - XCTAssertThrowsError(try lifecycle.startAndWait()) { error in - XCTAssert(error is TestError, "expected error to match") - } - - XCTAssertFalse(item.shutdown, "expected item to be not shutdown") - } - - do { - let lifecycle = ComponentLifecycle(label: "test") - - let item1 = GoodItem() - lifecycle.register(item1) - - let item2 = BadItem() - lifecycle.register(label: "test", start: .async(item2.start), shutdown: .async(item2.shutdown), shutdownIfNotStarted: true) - - let item3 = GoodItem() - lifecycle.register(item3) - - let item4 = GoodItem() - lifecycle.registerShutdown(label: "test", .async(item4.shutdown)) - - XCTAssertThrowsError(try lifecycle.startAndWait()) { error in - XCTAssert(error is TestError, "expected error to match") - } - - XCTAssertEqual(item1.state, .shutdown, "expected item to be shutdown") - XCTAssertTrue(item2.shutdown, "expected item to be shutdown") - XCTAssertEqual(item3.state, .idle, "expected item to be idle") - XCTAssertEqual(item4.state, .shutdown, "expected item to be shutdown") - } - - do { - let lifecycle = ComponentLifecycle(label: "test") - - let item1 = GoodItem() - lifecycle.register(item1) - - let item2 = BadItem() - lifecycle.register(label: "test", start: .async(item2.start), shutdown: .async(item2.shutdown), shutdownIfNotStarted: false) - - let item3 = GoodItem() - lifecycle.register(item3) - - let item4 = GoodItem() - lifecycle.registerShutdown(label: "test", .async(item4.shutdown)) - - XCTAssertThrowsError(try lifecycle.startAndWait()) { error in - XCTAssert(error is TestError, "expected error to match") - } - - XCTAssertEqual(item1.state, .shutdown, "expected item to be shutdown") - XCTAssertFalse(item2.shutdown, "expected item to be not shutdown") - XCTAssertEqual(item3.state, .idle, "expected item to be idle") - XCTAssertEqual(item4.state, .shutdown, "expected item to be shutdown") - } - } - - func testStatefulSync() { - class Item { - let id: String = UUID().uuidString - var shutdown: Bool = false - - func start() throws -> String { - return self.id - } - - func shutdown(state: String) throws { - XCTAssertEqual(self.id, state) - self.shutdown = true // not thread safe but okay for this purpose - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .sync(item.start), shutdown: .sync(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertTrue(item.shutdown, "expected item to be shutdown") - } - - func testStatefulSyncStartError() { - class Item { - let id: String = UUID().uuidString - - func start() throws -> String { - throw TestError() - } - - func shutdown(state: String) throws { - XCTFail("should not be shutdown") - throw TestError() - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .sync(item.start), shutdown: .sync(item.shutdown)) - - XCTAssertThrowsError(try lifecycle.startAndWait()) { error in - XCTAssert(error is TestError, "expected error to match") - } - } - - func testStatefulSyncShutdownError() { - class Item { - let id: String = UUID().uuidString - var shutdown: Bool = false - - func start() throws -> String { - return self.id - } - - func shutdown(state: String) throws { - XCTAssertEqual(self.id, state) - throw TestError() - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .sync(item.start), shutdown: .sync(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown { error in - guard let shutdownError = error as? ShutdownError else { - return XCTFail("expected error to match") - } - XCTAssertEqual(shutdownError.errors.count, 1) - XCTAssert(shutdownError.errors.values.first! is TestError, "expected error to match") - } - } - - XCTAssertFalse(item.shutdown, "expected item to be shutdown") - } - - func testStatefulAsync() { - class Item { - let id: String = UUID().uuidString - var shutdown: Bool = false - - func start(_ callback: @escaping (Result) -> Void) { - callback(.success(self.id)) - } - - func shutdown(state: String, _ callback: @escaping (Error?) -> Void) { - XCTAssertEqual(self.id, state) - self.shutdown = true // not thread safe but okay for this purpose - callback(nil) - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertTrue(item.shutdown, "expected item to be shutdown") - } - - func testStatefulAsyncStartError() { - class Item { - let id: String = UUID().uuidString - - func start(_ callback: @escaping (Result) -> Void) { - callback(.failure(TestError())) - } - - func shutdown(state: String, _ callback: @escaping (Error?) -> Void) { - XCTFail("should not be shutdown") - callback(TestError()) - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) - - XCTAssertThrowsError(try lifecycle.startAndWait()) { error in - XCTAssert(error is TestError, "expected error to match") - } - } - - func testStatefulAsyncShutdownError() { - class Item { - let id: String = UUID().uuidString - var shutdown: Bool = false - - func start(_ callback: @escaping (Result) -> Void) { - callback(.success(self.id)) - } - - func shutdown(state: String, _ callback: @escaping (Error?) -> Void) { - XCTAssertEqual(self.id, state) - callback(TestError()) - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown { error in - guard let shutdownError = error as? ShutdownError else { - return XCTFail("expected error to match") - } - XCTAssertEqual(shutdownError.errors.count, 1) - XCTAssert(shutdownError.errors.values.first! is TestError, "expected error to match") - } - } - - XCTAssertFalse(item.shutdown, "expected item to be shutdown") - } - - func testStatefulNIO() { - class Item { - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let id: String = UUID().uuidString - var shutdown: Bool = false - - func start() -> EventLoopFuture { - return self.eventLoopGroup.next().makeSucceededFuture(self.id) - } - - func shutdown(state: String) -> EventLoopFuture { - XCTAssertEqual(self.id, state) - self.shutdown = true // not thread safe but okay for this purpose - return self.eventLoopGroup.next().makeSucceededFuture(()) - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .eventLoopFuture(item.start), shutdown: .eventLoopFuture(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertTrue(item.shutdown, "expected item to be shutdown") - } - - func testStatefulNIOStartFailure() { - class Item { - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let id: String = UUID().uuidString - - func start() -> EventLoopFuture { - return self.eventLoopGroup.next().makeFailedFuture(TestError()) - } - - func shutdown(state: String) -> EventLoopFuture { - XCTFail("should not be shutdown") - return self.eventLoopGroup.next().makeFailedFuture(TestError()) - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .eventLoopFuture(item.start), shutdown: .eventLoopFuture(item.shutdown)) - - XCTAssertThrowsError(try lifecycle.startAndWait()) { error in - XCTAssert(error is TestError, "expected error to match") - } - } - - func testStatefulNIOShutdownFailure() { - class Item { - let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) - let id: String = UUID().uuidString - var shutdown: Bool = false - - func start() -> EventLoopFuture { - return self.eventLoopGroup.next().makeSucceededFuture(self.id) - } - - func shutdown(state: String) -> EventLoopFuture { - XCTAssertEqual(self.id, state) - return self.eventLoopGroup.next().makeFailedFuture(TestError()) - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .eventLoopFuture(item.start), shutdown: .eventLoopFuture(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown { error in - guard let shutdownError = error as? ShutdownError else { - return XCTFail("expected error to match") - } - XCTAssertEqual(shutdownError.errors.count, 1) - XCTAssert(shutdownError.errors.values.first! is TestError, "expected error to match") - } - } - - XCTAssertFalse(item.shutdown, "expected item to be shutdown") - } - - func testAsyncAwait() throws { - #if compiler(<5.2) - return - #elseif compiler(<5.5) - throw XCTSkip() - #elseif !canImport(_Concurrency) - throw XCTSkip() - #else - guard #available(macOS 12.0, *) else { - throw XCTSkip() - } - - class Item { - var isShutdown: Bool = false - - func start() async throws {} - - func shutdown() async throws { - self.isShutdown = true // not thread safe but okay for this purpose - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.register(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertTrue(item.isShutdown, "expected item to be shutdown") - #endif - } - - func testAsyncAwaitStateful() throws { - #if compiler(<5.2) - return - #elseif compiler(<5.5) - throw XCTSkip() - #elseif !canImport(_Concurrency) - throw XCTSkip() - #else - guard #available(macOS 12.0, *) else { - throw XCTSkip() - } - - class Item { - var isShutdown: Bool = false - let id: String = UUID().uuidString - - func start() async throws -> String { - return self.id - } - - func shutdown(state: String) async throws { - XCTAssertEqual(self.id, state) - self.isShutdown = true // not thread safe but okay for this purpose - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.registerStateful(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertTrue(item.isShutdown, "expected item to be shutdown") - #endif - } - - func testAsyncAwaitErrorOnStart() throws { - #if compiler(<5.2) - return - #elseif compiler(<5.5) - throw XCTSkip() - #elseif !canImport(_Concurrency) - throw XCTSkip() - #else - guard #available(macOS 12.0, *) else { - throw XCTSkip() - } - - class Item { - var isShutdown: Bool = false - - func start() async throws { - throw TestError() - } - - func shutdown() async throws { - self.isShutdown = true // not thread safe but okay for this purpose - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.register(label: "test", start: .async(item.start), shutdown: .async(item.shutdown), shutdownIfNotStarted: false) - - lifecycle.start { error in - XCTAssert(error is TestError, "expected error to match") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertFalse(item.isShutdown, "expected item to be shutdown") - #endif - } - - func testAsyncAwaitErrorOnStartShutdownRequested() throws { - #if compiler(<5.2) - return - #elseif compiler(<5.5) - throw XCTSkip() - #elseif !canImport(_Concurrency) - throw XCTSkip() - #else - guard #available(macOS 12.0, *) else { - throw XCTSkip() - } - - class Item { - var isShutdown: Bool = false - - func start() async throws { - throw TestError() - } - - func shutdown() async throws { - self.isShutdown = true // not thread safe but okay for this purpose - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.register(label: "test", start: .async(item.start), shutdown: .async(item.shutdown), shutdownIfNotStarted: true) - - lifecycle.start { error in - XCTAssert(error is TestError, "expected error to match") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertTrue(item.isShutdown, "expected item to be shutdown") - #endif - } - - func testAsyncAwaitErrorOnShutdown() throws { - #if compiler(<5.2) - return - #elseif compiler(<5.5) - throw XCTSkip() - #elseif !canImport(_Concurrency) - throw XCTSkip() - #else - guard #available(macOS 12.0, *) else { - throw XCTSkip() - } - class Item { - var isShutdown: Bool = false - - func start() async throws {} - - func shutdown() async throws { - self.isShutdown = true // not thread safe but okay for this purpose - throw TestError() - } - } - - let lifecycle = ComponentLifecycle(label: "test") - - let item = Item() - lifecycle.register(label: "test", start: .async(item.start), shutdown: .async(item.shutdown)) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - lifecycle.shutdown() - } - lifecycle.wait() - XCTAssertTrue(item.isShutdown, "expected item to be shutdown") - #endif - } - - func testMetrics() { - let metrics = TestMetrics() - MetricsSystem.bootstrap(metrics) - - let items = (0 ..< 3).map { _ in GoodItem(id: UUID().uuidString, startDelay: 0.1, shutdownDelay: 0.1) } - let lifecycle = ComponentLifecycle(label: "test") - lifecycle.register(items) - lifecycle.start { startError in - XCTAssertNil(startError, "not expecting error") - lifecycle.shutdown { shutdownErrors in - XCTAssertNil(shutdownErrors, "not expecting error") - } - } - lifecycle.wait() - XCTAssertEqual(metrics.counters["\(lifecycle.label).lifecycle.start"]?.value, 1, "expected start counter to be 1") - XCTAssertEqual(metrics.counters["\(lifecycle.label).lifecycle.shutdown"]?.value, 1, "expected shutdown counter to be 1") - items.forEach { XCTAssertGreaterThan(metrics.timers["\(lifecycle.label).\($0.label).lifecycle.start"]?.value ?? 0, 0, "expected start timer to be non-zero") } - items.forEach { XCTAssertGreaterThan(metrics.timers["\(lifecycle.label).\($0.label).lifecycle.shutdown"]?.value ?? 0, 0, "expected shutdown timer to be non-zero") } - } -} - -class TestMetrics: MetricsFactory, RecorderHandler { - var counters = [String: TestCounter]() - var timers = [String: TestTimer]() - let lock = Lock() - - public init() {} - - public func makeCounter(label: String, dimensions: [(String, String)]) -> CounterHandler { - let counter = TestCounter(label: label) - self.lock.withLock { - self.counters[label] = counter - } - return counter - } - - public func makeRecorder(label: String, dimensions: [(String, String)], aggregate: Bool) -> RecorderHandler { - return self - } - - public func makeTimer(label: String, dimensions: [(String, String)]) -> TimerHandler { - let timer = TestTimer(label: label) - self.lock.withLock { - self.timers[label] = timer - } - return timer - } - - public func destroyCounter(_: CounterHandler) {} - public func destroyRecorder(_: RecorderHandler) {} - public func destroyTimer(_: TimerHandler) {} - - public func record(_: Int64) {} - public func record(_: Double) {} - - class TestCounter: CounterHandler { - let label: String - var _value: Int64 - let lock = Lock() - - init(label: String) { - self.label = label - self._value = 0 - } - - public func increment(by: Int64) { - self.lock.withLock { - self._value += by - } - } - - public func reset() { - self.lock.withLock { - self._value = 0 - } - } - - public var value: Int64 { - return self.lock.withLock { - return self._value - } - } - } - - class TestTimer: TimerHandler { - let label: String - var _value: Int64 - let lock = Lock() - - init(label: String) { - self.label = label - self._value = 0 - } - - public func recordNanoseconds(_ value: Int64) { - self.lock.withLock { - self._value = value - } - } - - public var value: Int64 { - return self.lock.withLock { - return self._value - } - } - } -} diff --git a/Tests/LifecycleTests/Helpers.swift b/Tests/LifecycleTests/Helpers.swift deleted file mode 100644 index a68fabf..0000000 --- a/Tests/LifecycleTests/Helpers.swift +++ /dev/null @@ -1,101 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -import Foundation -@testable import Lifecycle -import NIO -import NIOConcurrencyHelpers - -class GoodItem: LifecycleTask { - let queue = DispatchQueue(label: "GoodItem", attributes: .concurrent) - - let id: String - let startDelay: Double - let shutdownDelay: Double - - var state = State.idle - let stateLock = Lifecycle.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 = Lifecycle.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/LifecycleTests/ServiceLifecycleTests+XCTest.swift b/Tests/LifecycleTests/ServiceLifecycleTests+XCTest.swift deleted file mode 100644 index 69f7a60..0000000 --- a/Tests/LifecycleTests/ServiceLifecycleTests+XCTest.swift +++ /dev/null @@ -1,41 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -// -// ServiceLifecycleTests+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 ServiceLifecycleTests { - static var allTests: [(String, (ServiceLifecycleTests) -> () throws -> Void)] { - return [ - ("testStartThenShutdown", testStartThenShutdown), - ("testShutdownWithSignal", testShutdownWithSignal), - ("testStartAndWait", testStartAndWait), - ("testStartAndWaitShutdownWithSignal", testStartAndWaitShutdownWithSignal), - ("testBadStartAndWait", testBadStartAndWait), - ("testNesting", testNesting), - ("testNesting2", testNesting2), - ("testSignalDescription", testSignalDescription), - ("testBacktracesInstalledOnce", testBacktracesInstalledOnce), - ("testRepeatShutdown", testRepeatShutdown), - ("testShutdownCancelSignal", testShutdownCancelSignal), - ] - } -} diff --git a/Tests/LifecycleTests/ServiceLifecycleTests.swift b/Tests/LifecycleTests/ServiceLifecycleTests.swift deleted file mode 100644 index 36c4446..0000000 --- a/Tests/LifecycleTests/ServiceLifecycleTests.swift +++ /dev/null @@ -1,317 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -@testable import Lifecycle -import LifecycleNIOCompat -import Logging -import XCTest - -final class ServiceLifecycleTests: XCTestCase { - func testStartThenShutdown() { - let items = (5 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - let lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: nil)) - lifecycle.register(items) - lifecycle.start { startError in - XCTAssertNil(startError, "not expecting error") - lifecycle.shutdown { shutdownErrors in - XCTAssertNil(shutdownErrors, "not expecting error") - } - } - lifecycle.wait() - items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - } - - func testShutdownWithSignal() { - if ProcessInfo.processInfo.environment["SKIP_SIGNAL_TEST"].flatMap(Bool.init) ?? false { - print("skipping testShutdownWithSignal") - return - } - let signal = ServiceLifecycle.Signal.ALRM - let items = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - let lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: [signal])) - lifecycle.register(items) - lifecycle.start { 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: LifecycleTask { - 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 = ServiceLifecycle(configuration: .init(shutdownSignal: nil)) - 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()) - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown") - } - - func testStartAndWaitShutdownWithSignal() { - if ProcessInfo.processInfo.environment["SKIP_SIGNAL_TEST"].flatMap(Bool.init) ?? false { - print("skipping testStartAndWaitShutdownWithSignal") - return - } - - class Item: LifecycleTask { - 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 signal = ServiceLifecycle.Signal.ALRM - let lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: [signal])) - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue(label: "test").asyncAfter(deadline: .now() + 0.1) { - semaphore.wait() - kill(getpid(), signal.rawValue) - } - let item = Item(semaphore) - lifecycle.register(item) - XCTAssertNoThrow(try lifecycle.startAndWait()) - XCTAssertEqual(item.state, .shutdown, "expected item to be shutdown") - } - - func testBadStartAndWait() { - class BadItem: LifecycleTask { - var label: String { - return "\(self)" - } - - func start(_ callback: (Error?) -> Void) { - callback(TestError()) - } - - func shutdown(_ callback: (Error?) -> Void) { - callback(nil) - } - } - - let lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: nil)) - lifecycle.register(GoodItem(), BadItem()) - XCTAssertThrowsError(try lifecycle.startAndWait()) { error in - XCTAssert(error is TestError, "expected error to match") - } - } - - func testNesting() { - let items1 = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - let subLifecycle1 = ComponentLifecycle(label: "sub1") - subLifecycle1.register(items1) - - let items2 = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - let subLifecycle2 = ComponentLifecycle(label: "sub2") - subLifecycle2.register(items2) - - let toplifecycle = ServiceLifecycle() - let items3 = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - toplifecycle.register([subLifecycle1, subLifecycle2] + items3) - - toplifecycle.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)") } - toplifecycle.shutdown() - } - toplifecycle.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)") } - } - - func testNesting2() { - struct SubSystem { - let lifecycle = ComponentLifecycle(label: "SubSystem") - let subsystem: SubSubSystem - - init() { - self.subsystem = SubSubSystem() - self.lifecycle.register(self.subsystem.lifecycle) - } - - struct SubSubSystem { - let lifecycle = ComponentLifecycle(label: "SubSubSystem") - let items = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() } - - init() { - self.lifecycle.register(self.items) - } - } - } - - let lifecycle = ServiceLifecycle() - let subsystem = SubSystem() - lifecycle.register(subsystem.lifecycle) - - lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - subsystem.subsystem.items.forEach { XCTAssertEqual($0.state, .started, "expected item to be started, but \($0.state)") } - lifecycle.shutdown() - } - lifecycle.wait() - subsystem.subsystem.items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") } - } - - func testSignalDescription() { - XCTAssertEqual("\(ServiceLifecycle.Signal.TERM)", "Signal(TERM, rawValue: \(ServiceLifecycle.Signal.TERM.rawValue))") - XCTAssertEqual("\(ServiceLifecycle.Signal.INT)", "Signal(INT, rawValue: \(ServiceLifecycle.Signal.INT.rawValue))") - XCTAssertEqual("\(ServiceLifecycle.Signal.ALRM)", "Signal(ALRM, rawValue: \(ServiceLifecycle.Signal.ALRM.rawValue))") - } - - func testBacktracesInstalledOnce() { - let config = ServiceLifecycle.Configuration(installBacktrace: true) - _ = ServiceLifecycle(configuration: config) - _ = ServiceLifecycle(configuration: config) - } - - func testRepeatShutdown() { - if ProcessInfo.processInfo.environment["SKIP_SIGNAL_TEST"].flatMap(Bool.init) ?? false { - print("skipping testRepeatShutdown") - return - } - - var count = 0 - - struct Service { - static let signal = ServiceLifecycle.Signal.ALRM - - let lifecycle: ServiceLifecycle - - init() { - self.lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: [Service.signal])) - self.lifecycle.register(GoodItem()) - } - } - - func gracefulShutdown() { - let service = Service() - service.lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - kill(getpid(), Service.signal.rawValue) - } - - service.lifecycle.wait() - count = count + 1 // not thread safe but fine for this purpose - } - - let attempts = Int.random(in: 2 ..< 5) - for _ in 0 ..< attempts { - gracefulShutdown() - } - - XCTAssertEqual(attempts, count) - } - - func testShutdownCancelSignal() { - if ProcessInfo.processInfo.environment["SKIP_SIGNAL_TEST"].flatMap(Bool.init) ?? false { - print("skipping testShutdownCancelSignal") - return - } - - struct Service { - static let signal = ServiceLifecycle.Signal.ALRM - - let lifecycle: ServiceLifecycle - - init() { - self.lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: [Service.signal])) - self.lifecycle.register(GoodItem()) - } - } - - let service = Service() - service.lifecycle.start { error in - XCTAssertNil(error, "not expecting error") - kill(getpid(), Service.signal.rawValue) - } - service.lifecycle.wait() - - var count = 0 - let sync = DispatchGroup() - sync.enter() - let signalSource = ServiceLifecycle.trap(signal: Service.signal, handler: { _ in - count = count + 1 // not thread safe but fine for this purpose - sync.leave() - }, cancelAfterTrap: false) - - // since we are removing the hook added by lifecycle on shutdown, - // this will fail unless a new hook is set up as done above - kill(getpid(), Service.signal.rawValue) - - XCTAssertEqual(.success, sync.wait(timeout: .now() + 2)) - XCTAssertEqual(count, 1) - - signalSource.cancel() - ServiceLifecycle.removeTrap(signal: Service.signal) - } -} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index a82850a..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,32 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -// -// LinuxMain.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. -/// - -#if os(Linux) || os(FreeBSD) -@testable import LifecycleTests - -XCTMain([ - testCase(ComponentLifecycleTests.allTests), - testCase(ServiceLifecycleTests.allTests), -]) -#endif diff --git a/Tests/ServiceLifecycleTests/AsyncCancelOnGracefulShutdownSequenceTests.swift b/Tests/ServiceLifecycleTests/AsyncCancelOnGracefulShutdownSequenceTests.swift new file mode 100644 index 0000000..8902a6e --- /dev/null +++ b/Tests/ServiceLifecycleTests/AsyncCancelOnGracefulShutdownSequenceTests.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import AsyncAlgorithms +import ServiceLifecycle +import ServiceLifecycleTestKit +import XCTest + +final class AsyncCancelOnGracefulShutdownSequenceTests: XCTestCase { + func testCancelOnGracefulShutdown_finishesWhenShutdown() async throws { + await testGracefulShutdown { gracefulShutdownTrigger in + let stream = AsyncStream { + try? await Task.sleep(nanoseconds: 100_000_000) + return 1 + } + + let (resultStream, resultContinuation) = AsyncStream.makeStream() + + await withTaskGroup(of: Void.self) { group in + group.addTask { + for await value in stream.cancelOnGracefulShutdown() { + resultContinuation.yield(value) + } + resultContinuation.finish() + } + + var iterator = resultStream.makeAsyncIterator() + + await XCTAsyncAssertEqual(await iterator.next(), 1) + + gracefulShutdownTrigger.triggerGracefulShutdown() + + await XCTAsyncAssertEqual(await iterator.next(), nil) + } + } + } + + func testCancelOnGracefulShutdown_finishesBaseFinishes() async throws { + await testGracefulShutdown { _ in + let (baseStream, baseContinuation) = AsyncStream.makeStream() + let (resultStream, resultContinuation) = AsyncStream.makeStream() + + await withTaskGroup(of: Void.self) { group in + group.addTask { + for await value in baseStream.cancelOnGracefulShutdown() { + resultContinuation.yield(value) + } + resultContinuation.finish() + } + + var iterator = resultStream.makeAsyncIterator() + baseContinuation.yield(1) + + await XCTAsyncAssertEqual(await iterator.next(), 1) + + baseContinuation.finish() + + await XCTAsyncAssertEqual(await iterator.next(), nil) + } + } + } +} + +extension AsyncStream { + fileprivate static func makeStream( + of elementType: Element.Type = Element.self, + bufferingPolicy limit: Continuation.BufferingPolicy = .unbounded + ) -> (stream: AsyncStream, continuation: AsyncStream.Continuation) { + var continuation: AsyncStream.Continuation! + let stream = AsyncStream(bufferingPolicy: limit) { continuation = $0 } + return (stream: stream, continuation: continuation!) + } +} diff --git a/Tests/ServiceLifecycleTests/GracefulShutdownTests.swift b/Tests/ServiceLifecycleTests/GracefulShutdownTests.swift new file mode 100644 index 0000000..b3f4bb0 --- /dev/null +++ b/Tests/ServiceLifecycleTests/GracefulShutdownTests.swift @@ -0,0 +1,245 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import ServiceLifecycle +import ServiceLifecycleTestKit +import XCTest + +final class GracefulShutdownTests: XCTestCase { + func testWithGracefulShutdownHandler() async { + var cont: AsyncStream.Continuation! + let stream = AsyncStream { cont = $0 } + let continuation = cont! + + await testGracefulShutdown { gracefulShutdownTestTrigger in + await withGracefulShutdownHandler { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await stream.first { _ in true } + } + + gracefulShutdownTestTrigger.triggerGracefulShutdown() + + await group.waitForAll() + } + } onGracefulShutdown: { + continuation.finish() + } + } + } + + func testWithGracefulShutdownHandler_whenAlreadyShuttingDown() async { + var cont: AsyncStream.Continuation! + let stream = AsyncStream { cont = $0 } + let continuation = cont! + + await testGracefulShutdown { gracefulShutdownTestTrigger in + gracefulShutdownTestTrigger.triggerGracefulShutdown() + + _ = await withGracefulShutdownHandler { + continuation.yield("operation") + } onGracefulShutdown: { + continuation.yield("onGracefulShutdown") + } + } + + var iterator = stream.makeAsyncIterator() + + await XCTAsyncAssertEqual(await iterator.next(), "onGracefulShutdown") + await XCTAsyncAssertEqual(await iterator.next(), "operation") + } + + func testWithGracefulShutdownHandler_whenNested() async { + var cont: AsyncStream.Continuation! + let stream = AsyncStream { cont = $0 } + let continuation = cont! + + await testGracefulShutdown { gracefulShutdownTestTrigger in + await withGracefulShutdownHandler { + continuation.yield("outerOperation") + + await withTaskGroup(of: Void.self) { group in + group.addTask { + await withGracefulShutdownHandler { + continuation.yield("innerOperation") + try? await Task.sleep(nanoseconds: 500_000_000) + return () + } onGracefulShutdown: { + continuation.yield("innerOnGracefulShutdown") + } + } + group.addTask { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await withGracefulShutdownHandler { + continuation.yield("innerOperation") + try? await Task.sleep(nanoseconds: 500_000_000) + return () + } onGracefulShutdown: { + continuation.yield("innerOnGracefulShutdown") + } + } + } + } + + var iterator = stream.makeAsyncIterator() + + await XCTAsyncAssertEqual(await iterator.next(), "outerOperation") + await XCTAsyncAssertEqual(await iterator.next(), "innerOperation") + await XCTAsyncAssertEqual(await iterator.next(), "innerOperation") + + gracefulShutdownTestTrigger.triggerGracefulShutdown() + + await XCTAsyncAssertEqual(await iterator.next(), "outerOnGracefulShutdown") + await XCTAsyncAssertEqual(await iterator.next(), "innerOnGracefulShutdown") + await XCTAsyncAssertEqual(await iterator.next(), "innerOnGracefulShutdown") + } + } onGracefulShutdown: { + continuation.yield("outerOnGracefulShutdown") + } + } + } + + func testWithGracefulShutdownHandler_cleansUpHandlerAfterScopeExit() async { + final actor Foo { + func run() async { + await withGracefulShutdownHandler {} onGracefulShutdown: { + self.foo() + } + } + + nonisolated func foo() {} + } + var foo: Foo! = Foo() + weak var weakFoo: Foo? = foo + + await testGracefulShutdown { _ in + await foo.run() + + XCTAssertNotNil(weakFoo) + foo = nil + XCTAssertNil(weakFoo) + } + } + + func testTaskShutdownGracefully() async { + await testGracefulShutdown { gracefulShutdownTestTrigger in + let task = Task { + var cont: AsyncStream.Continuation! + let stream = AsyncStream { cont = $0 } + let continuation = cont! + + await withGracefulShutdownHandler { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await stream.first { _ in true } + } + + await group.waitForAll() + } + } onGracefulShutdown: { + continuation.finish() + } + } + + gracefulShutdownTestTrigger.triggerGracefulShutdown() + + await task.value + } + } + + func testTaskGroupShutdownGracefully() async { + await testGracefulShutdown { gracefulShutdownTestTrigger in + await withTaskGroup(of: Void.self) { group in + group.addTask { + var cont: AsyncStream.Continuation! + let stream = AsyncStream { cont = $0 } + let continuation = cont! + + await withGracefulShutdownHandler { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await stream.first { _ in true } + } + + await group.waitForAll() + } + } onGracefulShutdown: { + continuation.finish() + } + } + + gracefulShutdownTestTrigger.triggerGracefulShutdown() + + await group.waitForAll() + } + } + } + + func testThrowingTaskGroupShutdownGracefully() async { + await testGracefulShutdown { gracefulShutdownTestTrigger in + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + var cont: AsyncStream.Continuation! + let stream = AsyncStream { cont = $0 } + let continuation = cont! + + await withGracefulShutdownHandler { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await stream.first { _ in true } + } + + await group.waitForAll() + } + } onGracefulShutdown: { + continuation.finish() + } + } + + gracefulShutdownTestTrigger.triggerGracefulShutdown() + + try! await group.waitForAll() + } + } + } + + func testCancelOnGracefulShutdown() async throws { + try await testGracefulShutdown { gracefulShutdownTestTrigger in + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await cancelOnGracefulShutdown { + try await Task.sleep(nanoseconds: 1_000_000_000_000) + } + } + + gracefulShutdownTestTrigger.triggerGracefulShutdown() + + await XCTAsyncAssertThrowsError(try await group.next()) { error in + XCTAssertTrue(error is CancellationError) + } + } + } + } + + func testIsShuttingDownGracefully() async throws { + await testGracefulShutdown { gracefulShutdownTestTrigger in + XCTAssertFalse(Task.isShuttingDownGracefully) + + gracefulShutdownTestTrigger.triggerGracefulShutdown() + + XCTAssertTrue(Task.isShuttingDownGracefully) + } + } +} diff --git a/Tests/ServiceLifecycleTests/ServiceGroupTests.swift b/Tests/ServiceLifecycleTests/ServiceGroupTests.swift new file mode 100644 index 0000000..ed27bc8 --- /dev/null +++ b/Tests/ServiceLifecycleTests/ServiceGroupTests.swift @@ -0,0 +1,562 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Logging +import ServiceLifecycle +import UnixSignals +import XCTest + +private actor MockService: Service, CustomStringConvertible { + enum Event { + case run + case runPing + case runCancelled + case shutdownGracefully + } + + let events: AsyncStream + + private let eventsContinuation: AsyncStream.Continuation + + private var runContinuation: CheckedContinuation? + + nonisolated let description: String + + private let pings: AsyncStream + private nonisolated let pingContinuation: AsyncStream.Continuation + + init( + description: String + ) { + var eventsContinuation: AsyncStream.Continuation! + self.events = AsyncStream { eventsContinuation = $0 } + self.eventsContinuation = eventsContinuation! + + var pingContinuation: AsyncStream.Continuation! + self.pings = AsyncStream { pingContinuation = $0 } + self.pingContinuation = pingContinuation! + + self.description = description + } + + func run() async throws { + try await withTaskCancellationHandler { + try await withGracefulShutdownHandler { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + self.eventsContinuation.yield(.run) + for await _ in self.pings { + self.eventsContinuation.yield(.runPing) + } + } + + try await withCheckedThrowingContinuation { + self.runContinuation = $0 + } + + group.cancelAll() + } + } onGracefulShutdown: { + self.eventsContinuation.yield(.shutdownGracefully) + } + } onCancel: { + self.eventsContinuation.yield(.runCancelled) + } + } + + func resumeRunContinuation(with result: Result) { + self.runContinuation?.resume(with: result) + } + + nonisolated func sendPing() { + self.pingContinuation.yield() + } +} + +final class ServiceGroupTests: XCTestCase { + func testRun_whenAlreadyRunning() async throws { + let mockService = MockService(description: "Service1") + let serviceGroup = self.makeServiceGroup(services: [mockService], configuration: .init(gracefulShutdownSignals: [])) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var eventIterator = mockService.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator.next(), .run) + + try await XCTAsyncAssertThrowsError(await serviceGroup.run()) { + XCTAssertEqual($0 as? ServiceGroupError, .alreadyRunning()) + } + + group.cancelAll() + await mockService.resumeRunContinuation(with: .success(())) + } + } + + func testRun_whenAlreadyFinished() async throws { + let group = self.makeServiceGroup(services: [], configuration: .init(gracefulShutdownSignals: [])) + + try await group.run() + + try await XCTAsyncAssertThrowsError(await group.run()) { + XCTAssertEqual($0 as? ServiceGroupError, .alreadyFinished()) + } + } + + func testRun_whenNoService_andNoSignal() async throws { + let group = self.makeServiceGroup(services: [], configuration: .init(gracefulShutdownSignals: [])) + + try await group.run() + } + + func testRun_whenNoSignal() async throws { + let mockService = MockService(description: "Service1") + let serviceGroup = self.makeServiceGroup(services: [mockService], configuration: .init(gracefulShutdownSignals: [])) + + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var eventIterator = mockService.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator.next(), .run) + + group.cancelAll() + await XCTAsyncAssertEqual(await eventIterator.next(), .runCancelled) + + await mockService.resumeRunContinuation(with: .success(())) + } + } + + func test_whenRun_ShutdownGracefully() async throws { + let configuration = ServiceGroupConfiguration(gracefulShutdownSignals: [.sigalrm]) + let mockService = MockService(description: "Service1") + let serviceGroup = self.makeServiceGroup(services: [mockService], configuration: configuration) + + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var eventIterator = mockService.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator.next(), .run) + + let pid = getpid() + kill(pid, UnixSignal.sigalrm.rawValue) + await XCTAsyncAssertEqual(await eventIterator.next(), .shutdownGracefully) + + await mockService.resumeRunContinuation(with: .success(())) + } + } + + func testRun_whenServiceExitsEarly() async throws { + let configuration = ServiceGroupConfiguration(gracefulShutdownSignals: [.sigalrm]) + let mockService = MockService(description: "Service1") + let serviceGroup = self.makeServiceGroup(services: [mockService], configuration: configuration) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var eventIterator = mockService.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator.next(), .run) + + await mockService.resumeRunContinuation(with: .success(())) + + try await XCTAsyncAssertThrowsError(await group.next()) { + XCTAssertEqual($0 as? ServiceGroupError, .serviceFinishedUnexpectedly()) + } + } + } + + func testRun_whenServiceExitsEarly_andOtherRunningService() async throws { + let configuration = ServiceGroupConfiguration(gracefulShutdownSignals: [.sigalrm]) + let shortService = MockService(description: "Service1") + let longService = MockService(description: "Service2") + let serviceGroup = self.makeServiceGroup(services: [shortService, longService], configuration: configuration) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var shortServiceEventIterator = shortService.events.makeAsyncIterator() + var longServiceEventIterator = longService.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await shortServiceEventIterator.next(), .run) + await XCTAsyncAssertEqual(await longServiceEventIterator.next(), .run) + + // Finishing the short running service here + await shortService.resumeRunContinuation(with: .success(())) + + // Checking that the long running service is still running + await XCTAsyncAssertEqual(await longServiceEventIterator.next(), .runCancelled) + // Finishing the long running service here + await longService.resumeRunContinuation(with: .success(())) + + try await XCTAsyncAssertThrowsError(await group.next()) { + XCTAssertEqual($0 as? ServiceGroupError, .serviceFinishedUnexpectedly()) + } + } + } + + func testRun_whenServiceThrows() async throws { + let configuration = ServiceGroupConfiguration(gracefulShutdownSignals: [.sigalrm]) + let service1 = MockService(description: "Service1") + let service2 = MockService(description: "Service2") + let serviceGroup = self.makeServiceGroup(services: [service1, service2], configuration: configuration) + + try await withThrowingTaskGroup(of: Void.self) { group in + struct ExampleError: Error, Hashable {} + + group.addTask { + try await serviceGroup.run() + } + + var service1EventIterator = service1.events.makeAsyncIterator() + var service2EventIterator = service2.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await service1EventIterator.next(), .run) + await XCTAsyncAssertEqual(await service2EventIterator.next(), .run) + service1.sendPing() + service2.sendPing() + await XCTAsyncAssertEqual(await service1EventIterator.next(), .runPing) + await XCTAsyncAssertEqual(await service2EventIterator.next(), .runPing) + + // Throwing from service1 here and expect that service2 gets cancelled + await service1.resumeRunContinuation(with: .failure(ExampleError())) + + await XCTAsyncAssertEqual(await service2EventIterator.next(), .runCancelled) + await service2.resumeRunContinuation(with: .success(())) + + try await XCTAsyncAssertThrowsError(await group.next()) { + XCTAssertTrue($0 is ExampleError) + } + } + } + + func testGracefulShutdownOrdering() async throws { + let configuration = ServiceGroupConfiguration(gracefulShutdownSignals: [.sigalrm]) + let service1 = MockService(description: "Service1") + let service2 = MockService(description: "Service2") + let service3 = MockService(description: "Service3") + let serviceGroup = self.makeServiceGroup(services: [service1, service2, service3], configuration: configuration) + + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var eventIterator1 = service1.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator1.next(), .run) + + var eventIterator2 = service2.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator2.next(), .run) + + var eventIterator3 = service3.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator3.next(), .run) + + let pid = getpid() + kill(pid, UnixSignal.sigalrm.rawValue) + + // The last service should receive the shutdown signal first + await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully) + + // Waiting to see that all three are still running + service1.sendPing() + service2.sendPing() + service3.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing) + + // Let's exit from the last service + await service3.resumeRunContinuation(with: .success(())) + + // The middle service should now receive the signal + await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully) + + // Waiting to see that the two remaining are still running + service1.sendPing() + service2.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + + // Let's exit from the middle service + await service2.resumeRunContinuation(with: .success(())) + + // The first service should now receive the signal + await XCTAsyncAssertEqual(await eventIterator1.next(), .shutdownGracefully) + + // Waiting to see that the one remaining are still running + service1.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + + // Let's exit from the first service + await service1.resumeRunContinuation(with: .success(())) + } + } + + func testGracefulShutdownOrdering_whenServiceThrows() async throws { + let configuration = ServiceGroupConfiguration(gracefulShutdownSignals: [.sigalrm]) + let service1 = MockService(description: "Service1") + let service2 = MockService(description: "Service2") + let service3 = MockService(description: "Service3") + let serviceGroup = self.makeServiceGroup(services: [service1, service2, service3], configuration: configuration) + + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var eventIterator1 = service1.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator1.next(), .run) + + var eventIterator2 = service2.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator2.next(), .run) + + var eventIterator3 = service3.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator3.next(), .run) + + let pid = getpid() + kill(pid, UnixSignal.sigalrm.rawValue) + + // The last service should receive the shutdown signal first + await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully) + + // Waiting to see that all three are still running + service1.sendPing() + service2.sendPing() + service3.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing) + + // Let's exit from the last service + await service3.resumeRunContinuation(with: .success(())) + + // The middle service should now receive the signal + await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully) + + // Waiting to see that the two remaining are still running + service1.sendPing() + service2.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + + // Let's throw from the middle service + await service2.resumeRunContinuation(with: .failure(CancellationError())) + + // The first service should now receive a cancellation + await XCTAsyncAssertEqual(await eventIterator1.next(), .runCancelled) + + // Let's exit from the first service + await service1.resumeRunContinuation(with: .success(())) + } + } + + func testGracefulShutdownOrdering_whenServiceExits() async throws { + let configuration = ServiceGroupConfiguration(gracefulShutdownSignals: [.sigalrm]) + let service1 = MockService(description: "Service1") + let service2 = MockService(description: "Service2") + let service3 = MockService(description: "Service3") + let serviceGroup = self.makeServiceGroup(services: [service1, service2, service3], configuration: configuration) + + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var eventIterator1 = service1.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator1.next(), .run) + + var eventIterator2 = service2.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator2.next(), .run) + + var eventIterator3 = service3.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator3.next(), .run) + + let pid = getpid() + kill(pid, UnixSignal.sigalrm.rawValue) + + // The last service should receive the shutdown signal first + await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully) + + // Waiting to see that all three are still running + service1.sendPing() + service2.sendPing() + service3.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing) + + // Let's exit from the last service + await service3.resumeRunContinuation(with: .success(())) + + // The middle service should now receive the signal + await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully) + + // Waiting to see that the two remaining are still running + service1.sendPing() + service2.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + + // Let's exit from the first service + await service1.resumeRunContinuation(with: .success(())) + + // The middle service should now receive a cancellation + await XCTAsyncAssertEqual(await eventIterator2.next(), .runCancelled) + + // Let's exit from the first service + await service2.resumeRunContinuation(with: .success(())) + } + } + + func testNestedServiceLifecycle() async throws { + struct NestedGroupService: Service { + let group: ServiceGroup + + init(group: ServiceGroup) { + self.group = group + } + + func run() async throws { + try await self.group.run() + } + } + + let configuration = ServiceGroupConfiguration(gracefulShutdownSignals: [.sigalrm]) + let service1 = MockService(description: "Service1") + let service2 = MockService(description: "Service2") + let nestedGroupService = NestedGroupService( + group: self.makeServiceGroup( + services: [service2], + configuration: .init(gracefulShutdownSignals: []) + ) + ) + let serviceGroup = self.makeServiceGroup(services: [service1, nestedGroupService], configuration: configuration) + + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var eventIterator1 = service1.events.makeAsyncIterator() + var eventIterator2 = service2.events.makeAsyncIterator() + service1.sendPing() + service2.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .run) + await XCTAsyncAssertEqual(await eventIterator2.next(), .run) + + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + + let pid = getpid() + kill(pid, UnixSignal.sigalrm.rawValue) + await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully) + + service1.sendPing() + service2.sendPing() + + // Waiting to see that the two remaining are still running + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + + // Let's exit from the second service + await service2.resumeRunContinuation(with: .success(())) + + service1.sendPing() + // Waiting to see that the remaining is still running + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + + // Let's exit from the first service + await service1.resumeRunContinuation(with: .success(())) + } + } + + func testShutdownGracefully() async throws { + let configuration = ServiceGroupConfiguration(gracefulShutdownSignals: []) + let service1 = MockService(description: "Service1") + let service2 = MockService(description: "Service2") + let service3 = MockService(description: "Service3") + let serviceGroup = self.makeServiceGroup(services: [service1, service2, service3], configuration: configuration) + + await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await serviceGroup.run() + } + + var eventIterator1 = service1.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator1.next(), .run) + + var eventIterator2 = service2.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator2.next(), .run) + + var eventIterator3 = service3.events.makeAsyncIterator() + await XCTAsyncAssertEqual(await eventIterator3.next(), .run) + + await serviceGroup.shutdownGracefully() + + // The last service should receive the shutdown signal first + await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully) + + // Waiting to see that all three are still running + service1.sendPing() + service2.sendPing() + service3.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing) + + // Let's exit from the last service + await service3.resumeRunContinuation(with: .success(())) + + // The middle service should now receive the signal + await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully) + + // Waiting to see that the two remaining are still running + service1.sendPing() + service2.sendPing() + await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing) + await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing) + + // Let's exit from the first service + await service1.resumeRunContinuation(with: .success(())) + + // The middle service should now receive a cancellation + await XCTAsyncAssertEqual(await eventIterator2.next(), .runCancelled) + + // Let's exit from the first service + await service2.resumeRunContinuation(with: .success(())) + } + } + + // MARK: - Helpers + + private func makeServiceGroup( + services: [any Service], + configuration: ServiceGroupConfiguration + ) -> ServiceGroup { + var logger = Logger(label: "Tests") + logger.logLevel = .debug + + return .init( + services: services, + configuration: configuration, + logger: logger + ) + } +} diff --git a/Tests/ServiceLifecycleTests/XCTest+Async.swift b/Tests/ServiceLifecycleTests/XCTest+Async.swift new file mode 100644 index 0000000..5526b01 --- /dev/null +++ b/Tests/ServiceLifecycleTests/XCTest+Async.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest + +func XCTAsyncAssertEqual( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line +) async rethrows where T: Equatable { + let result1 = try await expression1() + let result2 = try await expression2() + XCTAssertEqual(result1, result2, message(), file: file, line: line) +} + +func XCTAsyncAssertThrowsError( + _ expression: @autoclosure () async throws -> some Any, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line, + _ errorHandler: (_ error: Error) -> Void = { _ in } +) async { + do { + _ = try await expression() + XCTFail(message(), file: file, line: line) + } catch { + errorHandler(error) + } +} + +func XCTAssertNoThrow( + _ expression: @autoclosure () async throws -> some Any, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + _ = try await expression() + } catch { + XCTFail("Threw error \(error)", file: file, line: line) + } +} diff --git a/Tests/UnixSignalsTests/UnixSignalTests.swift b/Tests/UnixSignalsTests/UnixSignalTests.swift new file mode 100644 index 0000000..616c337 --- /dev/null +++ b/Tests/UnixSignalsTests/UnixSignalTests.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the SwiftServiceLifecycle open source project +// +// Copyright (c) 2023 Apple Inc. and the SwiftServiceLifecycle project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import UnixSignals +import XCTest +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#endif + +final class UnixSignalTests: XCTestCase { + func testSingleSignal() async throws { + let signal = UnixSignal.sigalrm + let signals = await UnixSignalsSequence(trapping: signal) + let pid = getpid() + + var signalIterator = signals.makeAsyncIterator() + kill(pid, signal.rawValue) + let caught = await signalIterator.next() + XCTAssertEqual(caught, signal) + } + + func testCatchingMultipleSignals() async throws { + let signal = UnixSignal.sigalrm + let signals = await UnixSignalsSequence(trapping: signal) + let pid = getpid() + + var signalIterator = signals.makeAsyncIterator() + for _ in 0..<5 { + kill(pid, signal.rawValue) + + let caught = await signalIterator.next() + XCTAssertEqual(caught, signal) + } + } + + func testCancelOnSignal() async throws { + enum GroupResult { + case timedOut + case cancelled + case caughtSignal + } + + try await withThrowingTaskGroup(of: GroupResult.self) { group in + group.addTask { + do { + try await Task.sleep(nanoseconds: 5_000_000_000) + return .timedOut + } catch { + XCTAssert(error is CancellationError) + return .cancelled + } + } + + group.addTask { + for await _ in await UnixSignalsSequence(trapping: .sigalrm) { + return .caughtSignal + } + fatalError() + } + + // Allow 10ms for the tasks to start. + try await Task.sleep(nanoseconds: 10_000_000) + let pid = getpid() + kill(pid, UnixSignal.sigalrm.rawValue) + + let first = try await group.next() + XCTAssertEqual(first, .caughtSignal) + + // Caught the signal; cancel the remaining task. + group.cancelAll() + let second = try await group.next() + XCTAssertEqual(second, .cancelled) + } + } + + func testEmptySequence() async throws { + let signals = await UnixSignalsSequence(trapping: []) + for await _ in signals { + XCTFail("Unexpected siganl") + } + } + + func testCorrectSignalIsGiven() async throws { + let signals = await UnixSignalsSequence(trapping: .sigterm, .sigusr1, .sigusr2, .sighup, .sigint, .sigalrm) + var signalIterator = signals.makeAsyncIterator() + + let signal = UnixSignal.sigalrm + let pid = getpid() + + for _ in 0..<10 { + kill(pid, signal.rawValue) + let trapped = await signalIterator.next() + XCTAssertEqual(trapped, signal) + } + } + + func testSignalRawValue() { + func assert(_ signal: UnixSignal, rawValue: Int32) { + XCTAssertEqual(signal.rawValue, rawValue) + } + + assert(.sigalrm, rawValue: SIGALRM) + assert(.sigint, rawValue: SIGINT) + assert(.sighup, rawValue: SIGHUP) + assert(.sigusr1, rawValue: SIGUSR1) + assert(.sigusr2, rawValue: SIGUSR2) + assert(.sigterm, rawValue: SIGTERM) + } + + func testSignalCustomStringConvertible() { + func assert(_ signal: UnixSignal, description: String) { + XCTAssertEqual(String(describing: signal), description) + } + + assert(.sigalrm, description: "SIGALRM") + assert(.sigint, description: "SIGINT") + assert(.sighup, description: "SIGHUP") + assert(.sigusr1, description: "SIGUSR1") + assert(.sigusr2, description: "SIGUSR2") + assert(.sigterm, description: "SIGTERM") + } + + func testCancelledTask() async throws { + let task = Task { + try? await Task.sleep(nanoseconds: 1_000_000_000) + + let UnixSignalsSequence = await UnixSignalsSequence(trapping: .sigterm) + + for await _ in UnixSignalsSequence {} + } + + task.cancel() + + await task.value + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile index ff03aec..f4496e0 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,5 @@ -ARG swift_version=5.0 -ARG ubuntu_version=bionic +ARG swift_version=5.6 +ARG ubuntu_version=focal ARG base_image=swift:$swift_version-$ubuntu_version FROM $base_image # needed to do again after FROM due to docker limitation @@ -12,23 +12,13 @@ ENV LC_ALL en_US.UTF-8 ENV LANG en_US.UTF-8 ENV LANGUAGE en_US.UTF-8 -# dependencies -RUN apt-get update && apt-get install -y wget -RUN apt-get update && apt-get install -y lsof dnsutils netcat-openbsd net-tools curl jq # used by integration tests - -# ruby and jazzy for docs generation -RUN apt-get update && apt-get install -y ruby ruby-dev libsqlite3-dev build-essential -# jazzy no longer works on older version of ubuntu as ruby is too old. -RUN if [ "${ubuntu_version}" = "focal" ] ; then echo "gem: --no-document" > ~/.gemrc ; fi -RUN if [ "${ubuntu_version}" = "focal" ] ; then gem install jazzy ; fi - # tools RUN mkdir -p $HOME/.tools RUN echo 'export PATH="$HOME/.tools:$PATH"' >> $HOME/.profile # swiftformat (until part of the toolchain) -ARG swiftformat_version=0.44.6 +ARG swiftformat_version=0.51.7 RUN git clone --branch $swiftformat_version --depth 1 https://github.com/nicklockwood/SwiftFormat $HOME/.tools/swift-format RUN cd $HOME/.tools/swift-format && swift build -c release RUN ln -s $HOME/.tools/swift-format/.build/release/swiftformat $HOME/.tools/swiftformat diff --git a/scripts/generate_docs.sh b/scripts/generate_docs.sh deleted file mode 100755 index 1e755c6..0000000 --- a/scripts/generate_docs.sh +++ /dev/null @@ -1,118 +0,0 @@ -#!/bin/bash -##===----------------------------------------------------------------------===## -## -## This source file is part of the SwiftServiceLifecycle open source project -## -## Copyright (c) 2020 Apple Inc. and the SwiftServiceLifecycle project authors -## Licensed under Apache License v2.0 -## -## See LICENSE.txt for license information -## See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -## -## SPDX-License-Identifier: Apache-2.0 -## -##===----------------------------------------------------------------------===## - -set -e - -my_path="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -root_path="$my_path/.." -version=$(git describe --abbrev=0 --tags || echo "0.0.0") -modules=(Lifecycle LifecycleNIOCompat) - -if [[ "$(uname -s)" == "Linux" ]]; then - # build code if required - if [[ ! -d "$root_path/.build/x86_64-unknown-linux" ]]; then - swift build - fi - # setup source-kitten if required - mkdir -p "$root_path/.build/sourcekitten" - source_kitten_source_path="$root_path/.build/sourcekitten/source" - if [[ ! -d "$source_kitten_source_path" ]]; then - git clone https://github.com/jpsim/SourceKitten.git "$source_kitten_source_path" - fi - source_kitten_path="$source_kitten_source_path/.build/debug" - if [[ ! -d "$source_kitten_path" ]]; then - rm -rf "$source_kitten_source_path/.swift-version" - cd "$source_kitten_source_path" && swift build && cd "$root_path" - fi - # generate - for module in "${modules[@]}"; do - if [[ ! -f "$root_path/.build/sourcekitten/$module.json" ]]; then - "$source_kitten_path/sourcekitten" doc --spm --module-name $module > "$root_path/.build/sourcekitten/$module.json" - fi - done -fi - -jazzy_dir="$root_path/.build/jazzy" -rm -rf "$jazzy_dir" -mkdir -p "$jazzy_dir" - -# prep index -module_switcher="$jazzy_dir/README.md" -cat > "$module_switcher" <<"EOF" -# SwiftServiceLifecycle Docs - -SwiftServiceLifecycle provides a basic mechanism to cleanly start up and shut down the application, freeing resources in order before exiting. -It also provides a Signal based shutdown hook, to shutdown on signals like TERM or INT. - -SwiftServiceLifecycle is non-framework specific, designed to be integrated with any server framework or directly in an application. - -To get started with SwiftServiceLifecycle, [`import Lifecycle`](../Lifecycle/index.html). - -SwiftServiceLifecycle contains multiple modules: -EOF - -for module in "${modules[@]}"; do - echo " - [$module](../$module/index.html)" >> "$module_switcher" -done - -# run jazzy -if ! command -v jazzy > /dev/null; then - gem install jazzy --no-ri --no-rdoc -fi - -jazzy_args=(--clean - --author 'SwiftServiceLifecycle team' - --readme "$module_switcher" - --author_url https://github.com/swift-server/swift-service-lifecycle - --github_url https://github.com/swift-server/swift-service-lifecycle - --github-file-prefix https://github.com/swift-server/swift-service-lifecycle/tree/$version - --theme fullwidth - --swift-build-tool spm) - -for module in "${modules[@]}"; do - args=("${jazzy_args[@]}" --output "$jazzy_dir/docs/$version/$module" --docset-path "$jazzy_dir/docset/$version/$module" - --module "$module" --module-version $version - --root-url "https://swift-server.github.io/swift-service-lifecycle/docs/$version/$module/") - if [[ "$(uname -s)" == "Linux" ]]; then - args+=(--sourcekitten-sourcefile "$root_path/.build/sourcekitten/$module.json") - fi - jazzy "${args[@]}" -done - -# push to github pages -if [[ $PUSH == true ]]; then - BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) - GIT_AUTHOR=$(git --no-pager show -s --format='%an <%ae>' HEAD) - git fetch origin +gh-pages:gh-pages - git checkout gh-pages - rm -rf "docs/$version" - rm -rf "docs/current" - cp -r "$jazzy_dir/docs/$version" docs/ - cp -r "docs/$version" docs/current - git add --all docs - echo '' > index.html - git add index.html - touch .nojekyll - git add .nojekyll - changes=$(git diff-index --name-only HEAD) - if [[ -n "$changes" ]]; then - echo -e "changes detected\n$changes" - git commit --author="$GIT_AUTHOR" -m "publish $version docs" - git push origin gh-pages - else - echo "no changes detected" - fi - git checkout -f $BRANCH_NAME -fi diff --git a/scripts/generate_linux_tests.rb b/scripts/generate_linux_tests.rb deleted file mode 100755 index 956aa15..0000000 --- a/scripts/generate_linux_tests.rb +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env ruby - -# -# process_test_files.rb -# -# Copyright 2016 Tony Stone -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# Created by Tony Stone on 5/4/16. -# -require 'getoptlong' -require 'fileutils' -require 'pathname' - -include FileUtils - -# -# This ruby script will auto generate LinuxMain.swift and the +XCTest.swift extension files for Swift Package Manager on Linux platforms. -# -# See https://github.com/apple/swift-corelibs-xctest/blob/master/Documentation/Linux.md -# -def header(fileName) - string = <<-eos -//===----------------------------------------------------------------------===// -// -// This source file is part of the SwiftServiceLifecycle open source project -// -// Copyright (c) 2019-2020 Apple Inc. and the SwiftServiceLifecycle project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of SwiftServiceLifecycle project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// -// -// -// -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. -/// - eos - - string - .sub('', File.basename(fileName)) - .sub('', Time.now.to_s) -end - -def createExtensionFile(fileName, classes) - extensionFile = fileName.sub! '.swift', '+XCTest.swift' - print 'Creating file: ' + extensionFile + "\n" - - File.open(extensionFile, 'w') do |file| - file.write header(extensionFile) - file.write "\n" - - for classArray in classes - file.write 'extension ' + classArray[0] + " {\n" - file.write ' static var allTests: [(String, (' + classArray[0] + ") -> () throws -> Void)] {\n" - file.write " return [\n" - - for funcName in classArray[1] - file.write ' ("' + funcName + '", ' + funcName + "),\n" - end - - file.write " ]\n" - file.write " }\n" - file.write "}\n" - end - end -end - -def createLinuxMain(testsDirectory, allTestSubDirectories, files) - fileName = testsDirectory + '/LinuxMain.swift' - print 'Creating file: ' + fileName + "\n" - - File.open(fileName, 'w') do |file| - file.write header(fileName) - file.write "\n" - - file.write "#if os(Linux) || os(FreeBSD)\n" - for testSubDirectory in allTestSubDirectories.sort { |x, y| x <=> y } - file.write '@testable import ' + testSubDirectory + "\n" - end - file.write "\n" - file.write "XCTMain([\n" - - testCases = [] - for classes in files - for classArray in classes - testCases << classArray[0] - end - end - - for testCase in testCases.sort { |x, y| x <=> y } - file.write ' testCase(' + testCase + ".allTests),\n" - end - file.write "])\n" - file.write "#endif\n" - end -end - -def parseSourceFile(fileName) - puts 'Parsing file: ' + fileName + "\n" - - classes = [] - currentClass = nil - inIfLinux = false - inElse = false - ignore = false - - # - # Read the file line by line - # and parse to find the class - # names and func names - # - File.readlines(fileName).each do |line| - if inIfLinux - if /\#else/.match(line) - inElse = true - ignore = true - else - if /\#end/.match(line) - inElse = false - inIfLinux = false - ignore = false - end - end - else - if /\#if[ \t]+os\(Linux\)/.match(line) - inIfLinux = true - ignore = false - end - end - - next if ignore - # Match class or func - match = line[/class[ \t]+[a-zA-Z0-9_]*(?=[ \t]*:[ \t]*XCTestCase)|func[ \t]+test[a-zA-Z0-9_]*(?=[ \t]*\(\))/, 0] - if match - - if match[/class/, 0] == 'class' - className = match.sub(/^class[ \t]+/, '') - # - # Create a new class / func structure - # and add it to the classes array. - # - currentClass = [className, []] - classes << currentClass - else # Must be a func - funcName = match.sub(/^func[ \t]+/, '') - # - # Add each func name the the class / func - # structure created above. - # - currentClass[1] << funcName - end - end - end - classes -end - -# -# Main routine -# -# - -testsDirectory = 'Tests' - -options = GetoptLong.new(['--tests-dir', GetoptLong::OPTIONAL_ARGUMENT]) -options.quiet = true - -begin - options.each do |option, value| - case option - when '--tests-dir' - testsDirectory = value - end - end -rescue GetoptLong::InvalidOption -end - -allTestSubDirectories = [] -allFiles = [] - -Dir[testsDirectory + '/*'].each do |subDirectory| - next unless File.directory?(subDirectory) - directoryHasClasses = false - Dir[subDirectory + '/*Test{s,}.swift'].each do |fileName| - next unless File.file? fileName - fileClasses = parseSourceFile(fileName) - - # - # If there are classes in the - # test source file, create an extension - # file for it. - # - next unless fileClasses.count > 0 - createExtensionFile(fileName, fileClasses) - directoryHasClasses = true - allFiles << fileClasses - end - - if directoryHasClasses - allTestSubDirectories << Pathname.new(subDirectory).split.last.to_s - end -end - -# -# Last step is the create a LinuxMain.swift file that -# references all the classes and funcs in the source files. -# -if allFiles.count > 0 - createLinuxMain(testsDirectory, allTestSubDirectories, allFiles) -end -# eof diff --git a/scripts/soundness.sh b/scripts/soundness.sh index e096df8..c24424b 100755 --- a/scripts/soundness.sh +++ b/scripts/soundness.sh @@ -19,7 +19,7 @@ here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" function replace_acceptable_years() { # this needs to replace all acceptable forms with 'YEARS' - sed -e 's/20[12][78901]-202[012]/YEARS/' -e 's/2019/YEARS/' -e 's/202[012]/YEARS/' + sed -e 's/20[12][78901]-202[0123]/YEARS/' -e 's/2019/YEARS/' -e 's/202[0123]/YEARS/' } printf "=> Checking for unacceptable language... " @@ -38,18 +38,6 @@ if git grep --color=never -i "${unacceptable_terms[@]}" > /dev/null; then fi printf "\033[0;32mokay.\033[0m\n" -printf "=> Checking linux tests... " -FIRST_OUT="$(git status --porcelain)" -ruby "$here/../scripts/generate_linux_tests.rb" > /dev/null -SECOND_OUT="$(git status --porcelain)" -if [[ "$FIRST_OUT" != "$SECOND_OUT" ]]; then - printf "\033[0;31mmissing changes!\033[0m\n" - git --no-pager diff - exit 1 -else - printf "\033[0;32mokay.\033[0m\n" -fi - printf "=> Checking format... " FIRST_OUT="$(git status --porcelain)" swiftformat . > /dev/null 2>&1