|

swift-service-lifecycle: Making Service Lifetime Part of Swift Structured Concurrency

Daily (almost) Swift Open Source Overview hero for swift-service-lifecycle

A server rarely consists of only one async operation. A process may own an HTTP server, a queue consumer, a metrics service, a scheduled worker, or another loop that should live until shutdown.

Creating top-level tasks for all of them is easy. The harder questions are about ownership: what keeps them alive, what happens when one service throws, how shutdown reaches the services, and when the process is actually safe to exit.

swift-service-lifecycle provides a common lifecycle for that long-running work instead of requiring every component to invent its own start/stop layer.

A Small API for Long-Running Work

The core Service contract models a service as one asynchronous throwing operation: run() async throws. ServiceGroup accepts services and coordinates running them. The package also includes ClosureService for cases where a separate service type would add little value.

The interesting part is not simply catching SIGTERM. The application gets one place that owns its long-running services, runs them concurrently, requests graceful shutdown, and waits for them to finish.

Example 1: A Queue Worker as a Service

This example keeps the application-specific queue code explicit. JobQueue is not part of ServiceLifecycle.

import ServiceLifecycle

struct Job {
  let id: Int
}

actor JobQueue {
  private var jobs = [Job(id: 1), Job(id: 2)]

  func next() -> Job? {
    jobs.isEmpty ? nil : jobs.removeFirst()
  }
}

struct JobConsumer: Service {
  let queue: JobQueue

  func run() async throws {
    try await cancelWhenGracefulShutdown {
      while !Task.isCancelled {
        if let job = await queue.next() {
          print("Processing job \(job.id)")
        } else {
          try await Task.sleep(for: .milliseconds(250))
        }
      }
    }
  }
}

The library-specific part is intentionally small: JobConsumer conforms to Service and expresses its lifetime through run() async throws. The queue itself remains normal application code.

Example 2: One Owner for Several Services

The abstraction becomes more useful when the process owns several long-running components.

import Logging
import ServiceLifecycle

struct HeartbeatService: Service {
  func run() async throws {
    try await cancelWhenGracefulShutdown {
      while !Task.isCancelled {
        print("heartbeat")
        try await Task.sleep(for: .seconds(5))
      }
    }
  }
}

@main
struct Application {
  static func main() async throws {
    let logger = Logger(label: "Example")

    let services: [any Service] = [
      HeartbeatService(),
      JobConsumer(queue: JobQueue()),
    ]

    let group = ServiceGroup(
      services: services,
      gracefulShutdownSignals: [.sigterm, .sigint],
      logger: logger
    )

    try await group.run()
  }
}

Now the application has one explicit owner for both services instead of several unrelated top-level tasks.

The group configuration separates graceful-shutdown signals from cancellation signals. In these examples, cancelWhenGracefulShutdown turns the graceful-shutdown request into cancellation of the wrapped operation, so the polling and heartbeat loops can stop without treating the whole service group as immediately cancelled.

Example 3: ClosureService for Smaller Components

A dedicated type is not always useful. ClosureService can represent a small long-running operation directly.

import ServiceLifecycle

let heartbeat = ClosureService {
  try await cancelWhenGracefulShutdown {
    while !Task.isCancelled {
      print("heartbeat")
      try await Task.sleep(for: .seconds(5))
    }
  }
}

try await heartbeat.run()

ClosureService executes the supplied async throwing closure from its run() implementation. It can also be placed in a ServiceGroup alongside other services.

What ServiceGroup Does Internally

The most interesting code is in ServiceGroup.

Its implementation creates a throwing task group and runs each service as a child task. When it adds a service task, it installs that service’s graceful-shutdown manager in task-local state before calling run().

That implementation makes the package fit naturally with structured concurrency. The services are not merely unrelated detached tasks. Their execution is coordinated by the group that owns them.

Signal handling is another layer of the lifecycle. The public API lets the application configure Unix signals that request graceful shutdown and signals that cancel the group.

The current Package.swift uses Swift tools 6.1 and declares direct package dependencies on swift-log and swift-async-algorithms. The ServiceLifecycle target also depends on the repository’s UnixSignals and ConcurrencyHelpers targets.

Who Should Look at It

The package becomes interesting when a Swift process owns several independently implemented long-running components: server processes, queue consumers, workers, infrastructure services, or reusable components that should not depend on one web framework’s lifecycle.

A short-lived CLI that performs one async operation and exits probably does not need a service group. Likewise, if a small application already has one framework that completely owns its only long-running component, another lifecycle abstraction may not solve a real problem.

What Is Worth Reading in the Source

Service.swift is tiny and shows the core contract. ServiceGroup.swift contains the more interesting parts: task ownership, service execution, signal handling, and termination. ClosureService.swift shows how little code is needed to adapt an async closure into the same lifecycle.

The tests are also worth reading because lifecycle code is mostly about behavior under shutdown, cancellation, and failure rather than the happy path.

Project Health

The repository source headers identify the project as Apache-2.0 licensed. The current package manifest does not declare a platforms list, so I would not turn third-party build results into a manifest-level platform claim.

Windows also needs a separate qualification. Issue #196, “Support for service lifecycle on Windows,” is still open, so this article does not claim Windows lifecycle support.

The Useful Idea

The useful idea in swift-service-lifecycle is bigger than signal handling. A long-running component can be modeled as async work with an explicit owner, and several such components can share one application lifecycle. That makes Service and ServiceGroup worth reading even if a small application does not need the package itself. They are a compact example of applying Swift’s concurrency model to process lifetime.

Sources