Skip to content

Commit af5fb96

Browse files
authored
provide programatic access to shutdown errors (#17)
motivation: in some cases users may want access to the shutdown errors changes: add optoinal callback to shutdown providing access to shutdown errors
1 parent 541cf1b commit af5fb96

File tree

3 files changed

+130
-64
lines changed

3 files changed

+130
-64
lines changed

Sources/ServiceLauncher/Lifecycle.swift

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -75,24 +75,39 @@ public class Lifecycle {
7575

7676
/// Shuts down the `LifecycleItem` array provided in `start` or `startAndWait`.
7777
/// Shutdown is performed in reverse order of items provided.
78-
public func shutdown() {
78+
public func shutdown(callback: @escaping ([String: Error]?) -> Void = { _ in }) {
79+
let setupShutdownListener = { (queue: DispatchQueue) in
80+
self.shutdownGroup.notify(queue: queue) {
81+
guard case .shutdown(let errors) = self.state else {
82+
preconditionFailure("invalid state, \(self.state)")
83+
}
84+
callback(errors)
85+
}
86+
}
87+
7988
self.stateLock.lock()
8089
switch self.state {
8190
case .idle:
91+
self.state = .shutdown(nil)
92+
self.stateLock.unlock()
93+
defer { self.shutdownGroup.leave() }
94+
callback(nil)
95+
case .shutdown:
8296
self.stateLock.unlock()
83-
self.shutdownGroup.leave()
84-
case .starting:
85-
self.state = .shuttingDown
97+
self.logger.warning("already shutdown")
98+
callback(nil)
99+
case .starting(let queue):
100+
self.state = .shuttingDown(queue)
86101
self.stateLock.unlock()
87-
case .shuttingDown, .shutdown:
102+
setupShutdownListener(queue)
103+
case .shuttingDown(let queue):
88104
self.stateLock.unlock()
89-
return
105+
setupShutdownListener(queue)
90106
case .started(let queue, let items):
91-
self.state = .shuttingDown
107+
self.state = .shuttingDown(queue)
92108
self.stateLock.unlock()
93-
self._shutdown(on: queue, items: items) {
94-
self.shutdownGroup.leave()
95-
}
109+
setupShutdownListener(queue)
110+
self._shutdown(on: queue, items: items, callback: self.shutdownGroup.leave)
96111
}
97112
}
98113

@@ -114,12 +129,12 @@ public class Lifecycle {
114129
self.logger.info("installing backtrace")
115130
Backtrace.install()
116131
}
117-
self.state = .starting
132+
self.state = .starting(configuration.callbackQueue)
118133
}
119134
self._start(on: configuration.callbackQueue, items: items, index: 0) { started, error in
120135
self.stateLock.lock()
121136
if error != nil {
122-
self.state = .shuttingDown
137+
self.state = .shuttingDown(configuration.callbackQueue)
123138
}
124139
switch self.state {
125140
case .shuttingDown:
@@ -161,7 +176,7 @@ public class Lifecycle {
161176
self.logger.info("starting item [\(items[index].label)]")
162177
start { error in
163178
if let error = error {
164-
self.logger.info("failed to start [\(items[index].label)]: \(error)")
179+
self.logger.error("failed to start [\(items[index].label)]: \(error)")
165180
return callback(index, error)
166181
}
167182
// shutdown called while starting
@@ -175,43 +190,48 @@ public class Lifecycle {
175190
private func _shutdown(on queue: DispatchQueue, items: [LifecycleItem], callback: @escaping () -> Void) {
176191
self.stateLock.withLock {
177192
self.logger.info("shutting down lifecycle")
178-
self.state = .shuttingDown
193+
self.state = .shuttingDown(queue)
179194
}
180-
self._shutdown(on: queue, items: items.reversed(), index: 0) {
195+
self._shutdown(on: queue, items: items.reversed(), index: 0, errors: nil) { errors in
181196
self.stateLock.withLock {
182197
guard case .shuttingDown = self.state else {
183198
preconditionFailure("invalid state, \(self.state)")
184199
}
185-
self.state = .shutdown
200+
self.state = .shutdown(errors)
186201
}
187202
self.logger.info("bye")
188203
callback()
189204
}
190205
}
191206

192-
private func _shutdown(on queue: DispatchQueue, items: [LifecycleItem], index: Int, callback: @escaping () -> Void) {
207+
private func _shutdown(on queue: DispatchQueue, items: [LifecycleItem], index: Int, errors: [String: Error]?, callback: @escaping ([String: Error]?) -> Void) {
193208
// async barrier
194209
let shutdown = { (callback) -> Void in queue.async { items[index].shutdown(callback: callback) } }
195-
let callback = { () -> Void in queue.async { callback() } }
210+
let callback = { (errors) -> Void in queue.async { callback(errors) } }
196211

197212
if index >= items.count {
198-
return callback()
213+
return callback(errors)
199214
}
200215
self.logger.info("stopping item [\(items[index].label)]")
201216
shutdown { error in
217+
var errors = errors
202218
if let error = error {
203-
self.logger.info("failed to stop [\(items[index].label)]: \(error)")
219+
if errors == nil {
220+
errors = [:]
221+
}
222+
errors![items[index].label] = error
223+
self.logger.error("failed to stop [\(items[index].label)]: \(error)")
204224
}
205-
self._shutdown(on: queue, items: items, index: index + 1, callback: callback)
225+
self._shutdown(on: queue, items: items, index: index + 1, errors: errors, callback: callback)
206226
}
207227
}
208228

209229
private enum State {
210230
case idle
211-
case starting
231+
case starting(DispatchQueue)
212232
case started(DispatchQueue, [LifecycleItem])
213-
case shuttingDown
214-
case shutdown
233+
case shuttingDown(DispatchQueue)
234+
case shutdown([String: Error]?)
215235
}
216236
}
217237

Tests/ServiceLauncherTests/LifecycleTests+XCTest.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ extension Tests {
2828
("testStartThenShutdown", testStartThenShutdown),
2929
("testDispatchQueues", testDispatchQueues),
3030
("testShutdownWhileStarting", testShutdownWhileStarting),
31+
("testShutdownWhenIdle", testShutdownWhenIdle),
32+
("testShutdownWhenShutdown", testShutdownWhenShutdown),
3133
("testShutdownDuringHangingStart", testShutdownDuringHangingStart),
32-
("testBadStartup", testBadStartup),
33-
("testBadShutdown", testBadShutdown),
34+
("testShutdownErrors", testShutdownErrors),
35+
("testStartupErrors", testStartupErrors),
3436
("testStartAndWait", testStartAndWait),
3537
("testBadStartAndWait", testBadStartAndWait),
3638
("testShutdownInOrder", testShutdownInOrder),

Tests/ServiceLauncherTests/LifecycleTests.swift

Lines changed: 82 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ final class Tests: XCTestCase {
2222
let items = (0 ... Int.random(in: 10 ... 20)).map { _ in GoodItem() }
2323
let lifecycle = Lifecycle()
2424
lifecycle.register(items)
25-
lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in
26-
XCTAssertNil(error, "not expecting error")
27-
lifecycle.shutdown()
25+
lifecycle.start(configuration: .init(shutdownSignal: nil)) { startError in
26+
XCTAssertNil(startError, "not expecting error")
27+
lifecycle.shutdown { shutdownErrors in
28+
XCTAssertNil(shutdownErrors, "not expecting error")
29+
}
2830
}
2931
lifecycle.wait()
3032
items.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") }
@@ -118,104 +120,146 @@ final class Tests: XCTestCase {
118120
items.suffix(started + 1).forEach { XCTAssertEqual($0.state, .idle, "expected item to be idle, but \($0.state)") }
119121
}
120122

123+
func testShutdownWhenIdle() {
124+
let lifecycle = Lifecycle()
125+
lifecycle.register(GoodItem())
126+
127+
let sempahpore1 = DispatchSemaphore(value: 0)
128+
lifecycle.shutdown { errors in
129+
XCTAssertNil(errors)
130+
sempahpore1.signal()
131+
}
132+
lifecycle.wait()
133+
XCTAssertEqual(.success, sempahpore1.wait(timeout: .now() + 1))
134+
135+
let sempahpore2 = DispatchSemaphore(value: 0)
136+
lifecycle.shutdown { errors in
137+
XCTAssertNil(errors)
138+
sempahpore2.signal()
139+
}
140+
lifecycle.wait()
141+
XCTAssertEqual(.success, sempahpore2.wait(timeout: .now() + 1))
142+
}
143+
144+
func testShutdownWhenShutdown() {
145+
let lifecycle = Lifecycle()
146+
lifecycle.register(GoodItem())
147+
let sempahpore1 = DispatchSemaphore(value: 0)
148+
lifecycle.start { _ in
149+
lifecycle.shutdown { errors in
150+
XCTAssertNil(errors)
151+
sempahpore1.signal()
152+
}
153+
}
154+
lifecycle.wait()
155+
XCTAssertEqual(.success, sempahpore1.wait(timeout: .now() + 1))
156+
157+
let sempahpore2 = DispatchSemaphore(value: 0)
158+
lifecycle.shutdown { errors in
159+
XCTAssertNil(errors)
160+
sempahpore2.signal()
161+
}
162+
lifecycle.wait()
163+
XCTAssertEqual(.success, sempahpore2.wait(timeout: .now() + 1))
164+
}
165+
121166
func testShutdownDuringHangingStart() {
122167
let lifecycle = Lifecycle()
123-
let testQueue = DispatchQueue(label: UUID().uuidString)
124-
let blockStartGroup = DispatchGroup()
125-
let waitForShutdownGroup = DispatchGroup()
126-
let shutdownCompleteGroup = DispatchGroup()
127-
blockStartGroup.enter()
128-
waitForShutdownGroup.enter()
129-
shutdownCompleteGroup.enter()
168+
let blockStartSemaphore = DispatchSemaphore(value: 0)
130169
var startCalls = [String]()
131170
var stopCalls = [String]()
132171

133172
do {
134173
let id = UUID().uuidString
135174
lifecycle.register(label: id,
136175
start: {
137-
dispatchPrecondition(condition: DispatchPredicate.onQueue(testQueue))
138176
startCalls.append(id)
139-
blockStartGroup.wait()
177+
blockStartSemaphore.wait()
140178
},
141179
shutdown: {
142-
dispatchPrecondition(condition: DispatchPredicate.onQueue(testQueue))
143180
XCTAssertTrue(startCalls.contains(id))
144181
stopCalls.append(id)
145-
waitForShutdownGroup.leave()
146-
})
182+
})
147183
}
148184
do {
149185
let id = UUID().uuidString
150186
lifecycle.register(label: id,
151187
start: {
152-
dispatchPrecondition(condition: DispatchPredicate.onQueue(testQueue))
153188
startCalls.append(id)
154189
},
155190
shutdown: {
156-
dispatchPrecondition(condition: DispatchPredicate.onQueue(testQueue))
157191
XCTAssertTrue(startCalls.contains(id))
158192
stopCalls.append(id)
159-
})
193+
})
160194
}
161-
lifecycle.start(configuration: .init(callbackQueue: testQueue, shutdownSignal: nil)) { error in
195+
lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in
162196
XCTAssertNil(error)
163197
}
164198
lifecycle.shutdown()
165-
blockStartGroup.leave()
166-
waitForShutdownGroup.wait()
199+
blockStartSemaphore.signal()
167200
lifecycle.wait()
168-
XCTAssertEqual(startCalls.count, stopCalls.count)
169201
XCTAssertEqual(startCalls.count, 1)
202+
XCTAssertEqual(stopCalls.count, 1)
170203
}
171204

172-
func testBadStartup() {
205+
func testShutdownErrors() {
173206
class BadItem: LifecycleItem {
174-
let label: String = UUID().uuidString
207+
let label = UUID().uuidString
175208

176209
func start(callback: (Error?) -> Void) {
177-
callback(TestError())
210+
callback(nil)
178211
}
179212

180213
func shutdown(callback: (Error?) -> Void) {
181-
callback(nil)
214+
callback(TestError())
182215
}
183216
}
184217

185-
let items: [LifecycleItem] = [GoodItem(), GoodItem(), BadItem(), GoodItem()]
218+
var shutdownErrors: [String: Error]?
219+
let shutdownSemaphore = DispatchSemaphore(value: 0)
220+
let items: [LifecycleItem] = [GoodItem(), BadItem(), BadItem(), GoodItem(), BadItem()]
186221
let lifecycle = Lifecycle()
187222
lifecycle.register(items)
188223
lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in
189-
XCTAssert(error is TestError, "expected error to match")
224+
XCTAssertNil(error, "not expecting error")
225+
lifecycle.shutdown { errors in
226+
shutdownErrors = errors
227+
shutdownSemaphore.signal()
228+
}
190229
}
191230
lifecycle.wait()
192-
let badItemIndex = items.firstIndex { $0 as? BadItem != nil }!
193-
items.prefix(badItemIndex).compactMap { $0 as? GoodItem }.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") }
194-
items.suffix(from: badItemIndex + 1).compactMap { $0 as? GoodItem }.forEach { XCTAssertEqual($0.state, .idle, "expected item to be idle, but \($0.state)") }
231+
XCTAssertEqual(.success, shutdownSemaphore.wait(timeout: .now() + 1))
232+
233+
let goodItems = items.compactMap { $0 as? GoodItem }
234+
goodItems.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") }
235+
let badItems = items.compactMap { $0 as? BadItem }
236+
XCTAssertEqual(shutdownErrors?.count, badItems.count, "expected shutdown errors")
237+
badItems.forEach { XCTAssert(shutdownErrors?[$0.label] is TestError, "expected error to match") }
195238
}
196239

197-
func testBadShutdown() {
240+
func testStartupErrors() {
198241
class BadItem: LifecycleItem {
199242
let label: String = UUID().uuidString
200243

201244
func start(callback: (Error?) -> Void) {
202-
callback(nil)
245+
callback(TestError())
203246
}
204247

205248
func shutdown(callback: (Error?) -> Void) {
206-
callback(TestError())
249+
callback(nil)
207250
}
208251
}
209252

210-
let items: [LifecycleItem] = [GoodItem(), BadItem(), GoodItem()]
253+
let items: [LifecycleItem] = [GoodItem(), GoodItem(), BadItem(), GoodItem()]
211254
let lifecycle = Lifecycle()
212255
lifecycle.register(items)
213256
lifecycle.start(configuration: .init(shutdownSignal: nil)) { error in
214-
XCTAssertNil(error, "not expecting error")
215-
lifecycle.shutdown()
257+
XCTAssert(error is TestError, "expected error to match")
216258
}
217259
lifecycle.wait()
218-
items.compactMap { $0 as? GoodItem }.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") }
260+
let badItemIndex = items.firstIndex { $0 as? BadItem != nil }!
261+
items.prefix(badItemIndex).compactMap { $0 as? GoodItem }.forEach { XCTAssertEqual($0.state, .shutdown, "expected item to be shutdown, but \($0.state)") }
262+
items.suffix(from: badItemIndex + 1).compactMap { $0 as? GoodItem }.forEach { XCTAssertEqual($0.state, .idle, "expected item to be idle, but \($0.state)") }
219263
}
220264

221265
func testStartAndWait() {

0 commit comments

Comments
 (0)