July 15, 2020·5 min read·engineeringswiftserverslifecycleresources

Swift Service Lifecycle makes shutdown order explicit

The new server package coordinates ordered startup and reverse-order shutdown through synchronous handlers, callbacks and SwiftNIO futures.

A server can stop accepting work before all of its resources are safe to destroy. An asynchronous operation may still need its event loop, and a subsystem may still depend on another component while it releases its own state. Swift Service Lifecycle, announced on July 15, supplies a common place to express startup and shutdown ordering. It is a package for applications and frameworks, with no requirement to adopt a particular server framework.

I appreciate Swift's prompt reclamation of memory through reference counting, but process-level resource management needs a broader contract. Destroying an individual object does not describe the order in which an application should stop its components. A lifecycle coordinator gives that order an explicit representation that can include asynchronous cleanup.

Registration describes dependency order

The launch-day design documentation defines ServiceLifecycle as the top-level container. Registered start handlers run in registration order. Shutdown handlers run in reverse registration order. If a later component depends on an earlier component, that ordering lets the later component release its resources while the earlier dependency still exists.

Consider a networking runtime and a component using that runtime. Registering runtime cleanup first means it runs last during shutdown. The dependent component can finish its own shutdown before the event loops disappear. The package's announcement illustrates this arrangement with a SwiftNIO event-loop group and a database migrator.

The container does not infer these relationships by inspecting objects. Registration is the application's declaration of the intended sequence. That keeps the mechanism straightforward, while making composition order part of the application design. Two components that appear independent at construction time can still need a deliberate order during shutdown.

A shutdown-only registration covers resources that already exist before lifecycle startup. The event-loop example uses that form because the group is constructed directly. Resources with explicit initialization work can register both start and shutdown handlers. This distinction avoids inventing a meaningless start operation simply to participate in cleanup.

Callbacks are the asynchronous contract

The launch documentation describes a synchronous handler as () throws -> Void. An asynchronous handler instead takes an escaping completion callback carrying an optional error. The sync and async helpers wrap those forms for registration. The asynchronous spelling here describes callbacks, without implying any language-level asynchronous function syntax.

A small illustrative registration can use synchronous handlers. This example uses the launch source's label argument:

import Lifecycle

let lifecycle = ServiceLifecycle()
lifecycle.register(
    label: "worker",
    start: .sync { print("worker started") },
    shutdown: .sync { print("worker stopped") }
)
lifecycle.startAndWait()

startAndWait combines starting the lifecycle with waiting for shutdown to complete. The example prints messages to make the ordering visible. A real handler performs the component's initialization or cleanup in place of those messages, and a throwing failure follows the coordinator's error-handling rules.

The announcement's example uses name where the launch-day README uses label. That is a reason to check examples against the source revision being consumed. The example above follows the dated repository documentation. Copying an example from a description of a package is less precise than establishing the API expected by the selected revision.

An asynchronous completion callback also defines responsibility. The coordinator needs a component to report when its operation is complete. Returning from the function that began asynchronous work does not establish that completion. The wrapper can adapt an existing callback API, but the component still has to supply an accurate completion signal.

Startup and shutdown treat failure differently

Startup halts on the first error and reports it to the start completion handler. That behavior prevents the coordinator from continuing a sequence whose prerequisite failed. A service that cannot initialize required state should not treat later components as though the intended startup completed.

Shutdown continues after an error and attempts to stop the remaining registered items that started. The error is logged, but it does not prevent unrelated cleanup from running. The difference follows from the operations' purposes. During startup, a failed prerequisite can make later work invalid. During shutdown, abandoning remaining cleanup can leave additional resources active.

These policies are useful when composing components because they establish behavior outside the successful path. A list of start and stop functions alone leaves unanswered whether one exception prevents all subsequent cleanup. The lifecycle contract states that policy centrally, so every component does not need to coordinate it privately with its neighbors.

A failed start still needs careful component semantics. An initialization operation can acquire some state before it reports an error. The component's own API must define responsibility for that partial state. A lifecycle sequence establishes ordering and completion, while the component remains responsible for the meaning of its operations.

Signals connect the process to the sequence

ServiceLifecycle can trap INT and TERM by default and begin shutdown when it receives one of them. The configuration can select shutdown signals, and a program can disable signal trapping and request shutdown itself. Signal handling is therefore an entry point into the lifecycle rather than the lifecycle's only possible control mechanism.

The wait operation blocks until the shutdown sequence finishes. That gives a conventional server entry point a place to remain active while its resources run. The documentation also permits handling the asynchronous start result before waiting. Applications can distinguish a startup error from the normal interval between successful startup and a later shutdown request.

Configuration includes a callback queue and optional backtrace installation. The callback queue defaults to a global Dispatch queue. These settings concern how the coordinator executes handlers and integrates with the process. They do not change the application's declared ordering dependencies.

Subsystems can carry their own lifecycle

ComponentLifecycle provides the state-machine behavior for a subsystem. It conforms to LifecycleTask, so a top-level service can register it as one item. A component can itself contain other component lifecycles, forming a tree of startup and shutdown sequences.

This gives a library a way to package resource management with its subsystem. The application can place the subsystem relative to other dependencies without enumerating every internal resource. I prefer that boundary because it lets the component keep the details needed to shut itself down while exposing a clear place in the larger service sequence.

SwiftNIO integration follows the same pattern through LifecycleNIOCompat. Importing it adds an eventLoopFuture handler helper for operations that return NIO futures. The component can retain its existing completion representation, while the lifecycle adapter uses it to determine when to advance to the next registered operation.