Skip to content

Commit a7356e1

Browse files
authored
add semantics for stopping tasks that have no start handler (#77)
motivation: address shutdown for tasks which allocate resources on construction. before this change, only tasks that have been started are shutdown, this creates an issue when the resources are allocated on construction since the item may not get started at all (for example due to error in earlier task) and thus will never be shutdown and the resources will leak. changes: add sematics to request shutdown even if not started, and assume this sematics when registering with start: .none or with registerShutdown
1 parent a793200 commit a7356e1

File tree

4 files changed

+193
-21
lines changed

4 files changed

+193
-21
lines changed

Sources/Lifecycle/Lifecycle.swift

Lines changed: 43 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,29 +26,39 @@ import Logging
2626
/// Represents an item that can be started and shut down
2727
public protocol LifecycleTask {
2828
var label: String { get }
29+
var shutdownIfNotStarted: Bool { get }
2930
func start(_ callback: @escaping (Error?) -> Void)
3031
func shutdown(_ callback: @escaping (Error?) -> Void)
3132
}
3233

34+
extension LifecycleTask {
35+
public var shutdownIfNotStarted: Bool {
36+
return false
37+
}
38+
}
39+
3340
// MARK: - LifecycleHandler
3441

3542
/// Supported startup and shutdown method styles
3643
public struct LifecycleHandler {
37-
private let body: (@escaping (Error?) -> Void) -> Void
44+
public typealias Callback = (@escaping (Error?) -> Void) -> Void
45+
46+
private let body: Callback?
3847

3948
/// Initialize a `LifecycleHandler` based on a completion handler.
4049
///
4150
/// - parameters:
4251
/// - callback: the underlying completion handler
43-
public init(_ callback: @escaping (@escaping (Error?) -> Void) -> Void) {
52+
/// - noop: the underlying completion handler is a no-op
53+
public init(_ callback: Callback?) {
4454
self.body = callback
4555
}
4656

4757
/// Asynchronous `LifecycleHandler` based on a completion handler.
4858
///
4959
/// - parameters:
5060
/// - callback: the underlying completion handler
51-
public static func async(_ callback: @escaping (@escaping (Error?) -> Void) -> Void) -> LifecycleHandler {
61+
public static func async(_ callback: @escaping Callback) -> LifecycleHandler {
5262
return LifecycleHandler(callback)
5363
}
5464

@@ -69,13 +79,18 @@ public struct LifecycleHandler {
6979

7080
/// Noop `LifecycleHandler`.
7181
public static var none: LifecycleHandler {
72-
return LifecycleHandler { callback in
82+
return LifecycleHandler(nil)
83+
}
84+
85+
internal func run(_ callback: @escaping (Error?) -> Void) {
86+
let body = self.body ?? { callback in
7387
callback(nil)
7488
}
89+
body(callback)
7590
}
7691

77-
internal func run(_ callback: @escaping (Error?) -> Void) {
78-
self.body(callback)
92+
internal var noop: Bool {
93+
return self.body == nil
7994
}
8095
}
8196

@@ -242,12 +257,9 @@ public class ComponentLifecycle: LifecycleTask {
242257
private let logger: Logger
243258
internal let shutdownGroup = DispatchGroup()
244259

245-
private var state = State.idle
260+
private var state = State.idle([])
246261
private let stateLock = Lock()
247262

248-
private var tasks = [LifecycleTask]()
249-
private let tasksLock = Lock()
250-
251263
/// Creates a `ComponentLifecycle` instance.
252264
///
253265
/// - parameters:
@@ -275,7 +287,9 @@ public class ComponentLifecycle: LifecycleTask {
275287
/// - on: `DispatchQueue` to run the handlers callback on
276288
/// - callback: The handler which is called after the start operation completes. The parameter will be `nil` on success and contain the `Error` otherwise.
277289
public func start(on queue: DispatchQueue, _ callback: @escaping (Error?) -> Void) {
278-
let tasks = self.tasksLock.withLock { self.tasks }
290+
guard case .idle(let tasks) = (self.stateLock.withLock { self.state }) else {
291+
preconditionFailure("invalid state, \(self.state)")
292+
}
279293
self._start(on: queue, tasks: tasks, callback: callback)
280294
}
281295

@@ -328,7 +342,6 @@ public class ComponentLifecycle: LifecycleTask {
328342
self.stateLock.unlock()
329343
setupShutdownListener(queue)
330344
case .started(let queue, let tasks):
331-
self.state = .shuttingDown(queue)
332345
self.stateLock.unlock()
333346
setupShutdownListener(queue)
334347
self._shutdown(on: queue, tasks: tasks, callback: self.shutdownGroup.leave)
@@ -362,7 +375,12 @@ public class ComponentLifecycle: LifecycleTask {
362375
case .shuttingDown:
363376
self.stateLock.unlock()
364377
// shutdown was called while starting, or start failed, shutdown what we can
365-
let stoppable = started < tasks.count ? Array(tasks.prefix(started + 1)) : tasks
378+
let stoppable: [LifecycleTask]
379+
if started < tasks.count {
380+
stoppable = tasks.enumerated().filter { $0.offset <= started || $0.element.shutdownIfNotStarted }.map { $0.element }
381+
} else {
382+
stoppable = tasks
383+
}
366384
self._shutdown(on: queue, tasks: stoppable) {
367385
callback(error)
368386
self.shutdownGroup.leave()
@@ -443,7 +461,7 @@ public class ComponentLifecycle: LifecycleTask {
443461
}
444462

445463
private enum State {
446-
case idle
464+
case idle([LifecycleTask])
447465
case starting(DispatchQueue)
448466
case started(DispatchQueue, [LifecycleTask])
449467
case shuttingDown(DispatchQueue)
@@ -454,12 +472,10 @@ public class ComponentLifecycle: LifecycleTask {
454472
extension ComponentLifecycle: LifecycleTasksContainer {
455473
public func register(_ tasks: [LifecycleTask]) {
456474
self.stateLock.withLock {
457-
guard case .idle = self.state else {
475+
guard case .idle(let existing) = self.state else {
458476
preconditionFailure("invalid state, \(self.state)")
459477
}
460-
}
461-
self.tasksLock.withLock {
462-
self.tasks.append(contentsOf: tasks)
478+
self.state = .idle(existing + tasks)
463479
}
464480
}
465481
}
@@ -489,7 +505,7 @@ extension LifecycleTasksContainer {
489505
/// - start: `Handler` to perform the startup.
490506
/// - shutdown: `Handler` to perform the shutdown.
491507
public func register(label: String, start: LifecycleHandler, shutdown: LifecycleHandler) {
492-
self.register(_LifecycleTask(label: label, start: start, shutdown: shutdown))
508+
self.register(_LifecycleTask(label: label, shutdownIfNotStarted: nil, start: start, shutdown: shutdown))
493509
}
494510

495511
/// Adds a `LifecycleTask` to a `LifecycleTasks` collection.
@@ -504,9 +520,17 @@ extension LifecycleTasksContainer {
504520

505521
internal struct _LifecycleTask: LifecycleTask {
506522
let label: String
523+
let shutdownIfNotStarted: Bool
507524
let start: LifecycleHandler
508525
let shutdown: LifecycleHandler
509526

527+
init(label: String, shutdownIfNotStarted: Bool? = nil, start: LifecycleHandler, shutdown: LifecycleHandler) {
528+
self.label = label
529+
self.shutdownIfNotStarted = shutdownIfNotStarted ?? start.noop
530+
self.start = start
531+
self.shutdown = shutdown
532+
}
533+
510534
func start(_ callback: @escaping (Error?) -> Void) {
511535
self.start.run(callback)
512536
}

Sources/LifecycleNIOCompat/Bridge.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@ extension LifecycleHandler {
4747
}
4848
}
4949

50-
extension ServiceLifecycle {
50+
extension ComponentLifecycle {
5151
/// Starts the provided `LifecycleItem` array.
5252
/// Startup is performed in the order of items provided.
5353
///
5454
/// - parameters:
5555
/// - eventLoop: The `eventLoop` which is used to generate the `EventLoopFuture` that is returned. After the start the future is fulfilled:
56-
func start(on eventLoop: EventLoop) -> EventLoopFuture<Void> {
56+
public func start(on eventLoop: EventLoop) -> EventLoopFuture<Void> {
5757
let promise = eventLoop.makePromise(of: Void.self)
5858
self.start { error in
5959
if let error = error {

Tests/LifecycleTests/ComponentLifecycleTests+XCTest.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ extension ComponentLifecycleTests {
5454
("testNIOFailure", testNIOFailure),
5555
("testInternalState", testInternalState),
5656
("testExternalState", testExternalState),
57+
("testNOOPHandlers", testNOOPHandlers),
58+
("testShutdownOnlyStarted", testShutdownOnlyStarted),
59+
("testShutdownWhenStartFailedIfAsked", testShutdownWhenStartFailedIfAsked),
5760
]
5861
}
5962
}

Tests/LifecycleTests/ComponentLifecycleTests.swift

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ final class ComponentLifecycleTests: XCTestCase {
8282
let items = (1 ... Int.random(in: 10 ... 20)).map { index -> LifecycleTask in
8383
let id = "item-\(index)"
8484
return _LifecycleTask(label: id,
85+
shutdownIfNotStarted: false,
8586
start: .sync {
8687
dispatchPrecondition(condition: .onQueue(testQueue))
8788
startCalls.append(id)
@@ -839,4 +840,148 @@ final class ComponentLifecycleTests: XCTestCase {
839840
lifecycle.wait()
840841
XCTAssertEqual(state, .shutdown, "expected item to be shutdown, but \(state)")
841842
}
843+
844+
func testNOOPHandlers() {
845+
let none = LifecycleHandler.none
846+
XCTAssertEqual(none.noop, true)
847+
848+
let sync = LifecycleHandler.sync {}
849+
XCTAssertEqual(sync.noop, false)
850+
851+
let async = LifecycleHandler.async { _ in }
852+
XCTAssertEqual(async.noop, false)
853+
854+
let custom = LifecycleHandler { _ in }
855+
XCTAssertEqual(custom.noop, false)
856+
}
857+
858+
func testShutdownOnlyStarted() {
859+
class Item {
860+
let label: String
861+
let sempahore: DispatchSemaphore
862+
let failStart: Bool
863+
let exptectedState: State
864+
var state = State.idle
865+
866+
deinit {
867+
XCTAssertEqual(self.state, self.exptectedState, "\"\(self.label)\" should be \(self.exptectedState)")
868+
self.sempahore.signal()
869+
}
870+
871+
init(label: String, failStart: Bool, exptectedState: State, sempahore: DispatchSemaphore) {
872+
self.label = label
873+
self.failStart = failStart
874+
self.exptectedState = exptectedState
875+
self.sempahore = sempahore
876+
}
877+
878+
func start() throws {
879+
self.state = .started
880+
if self.failStart {
881+
self.state = .error
882+
throw InitError()
883+
}
884+
}
885+
886+
func shutdown() throws {
887+
self.state = .shutdown
888+
}
889+
890+
enum State {
891+
case idle
892+
case started
893+
case shutdown
894+
case error
895+
}
896+
897+
struct InitError: Error {}
898+
}
899+
900+
let count = Int.random(in: 10 ..< 20)
901+
let sempahore = DispatchSemaphore(value: count)
902+
let lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: nil))
903+
904+
for index in 0 ..< count {
905+
let item = Item(label: "\(index)", failStart: index == count / 2, exptectedState: index <= count / 2 ? .shutdown : .idle, sempahore: sempahore)
906+
lifecycle.register(label: item.label, start: .sync(item.start), shutdown: .sync(item.shutdown))
907+
}
908+
909+
lifecycle.start { error in
910+
XCTAssertNotNil(error, "expecting error")
911+
lifecycle.shutdown()
912+
}
913+
lifecycle.wait()
914+
915+
XCTAssertEqual(.success, sempahore.wait(timeout: .now() + 1))
916+
}
917+
918+
func testShutdownWhenStartFailedIfAsked() {
919+
class DestructionSensitive {
920+
let label: String
921+
let failStart: Bool
922+
let sempahore: DispatchSemaphore
923+
var state = State.idle
924+
925+
deinit {
926+
XCTAssertEqual(self.state, .shutdown, "\"\(self.label)\" should be shutdown")
927+
self.sempahore.signal()
928+
}
929+
930+
init(label: String, failStart: Bool = false, sempahore: DispatchSemaphore) {
931+
self.label = label
932+
self.failStart = failStart
933+
self.sempahore = sempahore
934+
}
935+
936+
func start() throws {
937+
self.state = .started
938+
if self.failStart {
939+
self.state = .error
940+
throw InitError()
941+
}
942+
}
943+
944+
func shutdown() throws {
945+
self.state = .shutdown
946+
}
947+
948+
enum State {
949+
case idle
950+
case started
951+
case shutdown
952+
case error
953+
}
954+
955+
struct InitError: Error {}
956+
}
957+
958+
let sempahore = DispatchSemaphore(value: 6)
959+
let lifecycle = ServiceLifecycle(configuration: .init(shutdownSignal: nil))
960+
961+
let item1 = DestructionSensitive(label: "1", sempahore: sempahore)
962+
lifecycle.register(label: item1.label, start: .sync(item1.start), shutdown: .sync(item1.shutdown))
963+
964+
let item2 = DestructionSensitive(label: "2", sempahore: sempahore)
965+
lifecycle.registerShutdown(label: item2.label, .sync(item2.shutdown))
966+
967+
let item3 = DestructionSensitive(label: "3", failStart: true, sempahore: sempahore)
968+
lifecycle.register(label: item3.label, start: .sync(item3.start), shutdown: .sync(item3.shutdown))
969+
970+
let item4 = DestructionSensitive(label: "4", sempahore: sempahore)
971+
lifecycle.registerShutdown(label: item4.label, .sync(item4.shutdown))
972+
973+
let item5 = DestructionSensitive(label: "5", sempahore: sempahore)
974+
lifecycle.register(label: item5.label, start: .none, shutdown: .sync(item5.shutdown))
975+
976+
let item6 = DestructionSensitive(label: "6", sempahore: sempahore)
977+
lifecycle.register(_LifecycleTask(label: item6.label, shutdownIfNotStarted: true, start: .sync(item6.start), shutdown: .sync(item6.shutdown)))
978+
979+
lifecycle.start { error in
980+
XCTAssertNotNil(error, "expecting error")
981+
lifecycle.shutdown()
982+
}
983+
lifecycle.wait()
984+
985+
XCTAssertEqual(.success, sempahore.wait(timeout: .now() + 1))
986+
}
842987
}

0 commit comments

Comments
 (0)