Skip to content

Commit edd0d15

Browse files
committed
Propagate service error during graceful shutdown
# Motivation When a service threw during graceful shutdown but had its behaviour set to `gracefulShutdown` itself then we were not rethrowing the error. # Modification This PR makes sure that the first service that throws an error which doesn't have its `failureTerminationBehavior` to `.ignore` gets rethrown by the `ServiceGroup`.
1 parent 98a9396 commit edd0d15

File tree

2 files changed

+104
-8
lines changed

2 files changed

+104
-8
lines changed

Sources/ServiceLifecycle/ServiceGroup.swift

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,9 @@ public actor ServiceGroup: Sendable {
436436
fatalError("Unexpected state")
437437
}
438438

439+
// We are storing the first error of a service that threw here.
440+
var error: Error?
441+
439442
// We have to shutdown the services in reverse. To do this
440443
// we are going to signal each child task the graceful shutdown and then wait for
441444
// its exit.
@@ -487,25 +490,38 @@ public actor ServiceGroup: Sendable {
487490
throw ServiceGroupError.serviceFinishedUnexpectedly()
488491
}
489492

490-
case .serviceThrew(let service, _, let error):
493+
case .serviceThrew(let service, _, let serviceError):
491494
switch service.failureTerminationBehavior.behavior {
492495
case .cancelGroup:
493496
self.logger.debug(
494497
"Service threw error during graceful shutdown. Cancelling group.",
495498
metadata: [
496499
self.loggingConfiguration.keys.serviceKey: "\(service.service)",
497-
self.loggingConfiguration.keys.errorKey: "\(error)",
500+
self.loggingConfiguration.keys.errorKey: "\(serviceError)",
498501
]
499502
)
500503
group.cancelAll()
501-
throw error
504+
throw serviceError
505+
506+
case .gracefullyShutdownGroup:
507+
self.logger.debug(
508+
"Service threw error during graceful shutdown.",
509+
metadata: [
510+
self.loggingConfiguration.keys.serviceKey: "\(service.service)",
511+
self.loggingConfiguration.keys.errorKey: "\(serviceError)",
512+
]
513+
)
514+
515+
if error == nil {
516+
error = serviceError
517+
}
502518

503-
case .gracefullyShutdownGroup, .ignore:
519+
case .ignore:
504520
self.logger.debug(
505521
"Service threw error during graceful shutdown.",
506522
metadata: [
507523
self.loggingConfiguration.keys.serviceKey: "\(service.service)",
508-
self.loggingConfiguration.keys.errorKey: "\(error)",
524+
self.loggingConfiguration.keys.errorKey: "\(serviceError)",
509525
]
510526
)
511527

@@ -538,6 +554,12 @@ public actor ServiceGroup: Sendable {
538554
// are the tasks that listen to the various graceful shutdown signals. We
539555
// just have to cancel those
540556
group.cancelAll()
557+
558+
// If we saw an error during graceful shutdown from a service that triggers graceful
559+
// shutdown on error then we have to rethrow that error now
560+
if let error = error {
561+
throw error
562+
}
541563
}
542564
}
543565

Tests/ServiceLifecycleTests/ServiceGroupTests.swift

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -707,7 +707,7 @@ final class ServiceGroupTests: XCTestCase {
707707
gracefulShutdownSignals: [.sigalrm]
708708
)
709709

710-
await withThrowingTaskGroup(of: Void.self) { group in
710+
try await withThrowingTaskGroup(of: Void.self) { group in
711711
group.addTask {
712712
try await serviceGroup.run()
713713
}
@@ -748,13 +748,85 @@ final class ServiceGroupTests: XCTestCase {
748748
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
749749

750750
// Let's throw from the middle service
751-
await service2.resumeRunContinuation(with: .failure(CancellationError()))
751+
await service2.resumeRunContinuation(with: .failure(ExampleError()))
752752

753753
// The first service should now receive a cancellation
754754
await XCTAsyncAssertEqual(await eventIterator1.next(), .runCancelled)
755755

756756
// Let's exit from the first service
757757
await service1.resumeRunContinuation(with: .success(()))
758+
759+
try await XCTAsyncAssertThrowsError(await group.next()) {
760+
XCTAssertTrue($0 is ExampleError)
761+
}
762+
}
763+
}
764+
765+
func testGracefulShutdownOrdering_whenServiceThrows_andServiceGracefullyShutsdown() async throws {
766+
let service1 = MockService(description: "Service1")
767+
let service2 = MockService(description: "Service2")
768+
let service3 = MockService(description: "Service3")
769+
let serviceGroup = self.makeServiceGroup(
770+
services: [
771+
.init(service: service1),
772+
.init(service: service2, failureTerminationBehavior: .gracefullyShutdownGroup),
773+
.init(service: service3),
774+
],
775+
gracefulShutdownSignals: [.sigalrm]
776+
)
777+
778+
try await withThrowingTaskGroup(of: Void.self) { group in
779+
group.addTask {
780+
try await serviceGroup.run()
781+
}
782+
783+
var eventIterator1 = service1.events.makeAsyncIterator()
784+
await XCTAsyncAssertEqual(await eventIterator1.next(), .run)
785+
786+
var eventIterator2 = service2.events.makeAsyncIterator()
787+
await XCTAsyncAssertEqual(await eventIterator2.next(), .run)
788+
789+
var eventIterator3 = service3.events.makeAsyncIterator()
790+
await XCTAsyncAssertEqual(await eventIterator3.next(), .run)
791+
792+
let pid = getpid()
793+
kill(pid, UnixSignal.sigalrm.rawValue)
794+
795+
// The last service should receive the shutdown signal first
796+
await XCTAsyncAssertEqual(await eventIterator3.next(), .shutdownGracefully)
797+
798+
// Waiting to see that all three are still running
799+
service1.sendPing()
800+
service2.sendPing()
801+
service3.sendPing()
802+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
803+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
804+
await XCTAsyncAssertEqual(await eventIterator3.next(), .runPing)
805+
806+
// Let's exit from the last service
807+
await service3.resumeRunContinuation(with: .success(()))
808+
809+
// The middle service should now receive the signal
810+
await XCTAsyncAssertEqual(await eventIterator2.next(), .shutdownGracefully)
811+
812+
// Waiting to see that the two remaining are still running
813+
service1.sendPing()
814+
service2.sendPing()
815+
await XCTAsyncAssertEqual(await eventIterator1.next(), .runPing)
816+
await XCTAsyncAssertEqual(await eventIterator2.next(), .runPing)
817+
818+
// Let's throw from the middle service
819+
await service2.resumeRunContinuation(with: .failure(ExampleError()))
820+
821+
// The first service should now receive a cancellation
822+
await XCTAsyncAssertEqual(await eventIterator1.next(), .shutdownGracefully)
823+
824+
// Let's exit from the first service
825+
await service1.resumeRunContinuation(with: .success(()))
826+
827+
try await XCTAsyncAssertThrowsError(await group.next()) {
828+
XCTAssertTrue($0 is ExampleError)
829+
}
758830
}
759831
}
760832

@@ -881,7 +953,9 @@ final class ServiceGroupTests: XCTestCase {
881953
// Let's throw from the first service
882954
await service1.resumeRunContinuation(with: .failure(ExampleError()))
883955

884-
await XCTAsyncAssertNoThrow(try await group.next())
956+
try await XCTAsyncAssertThrowsError(await group.next()) {
957+
XCTAssertTrue($0 is ExampleError)
958+
}
885959
}
886960
}
887961

0 commit comments

Comments
 (0)