Contract 08
Services and app lifecycle
Effect's Layer, for apps. Every app has a handful of long-lived services —
a database, a network client, a sync engine, a model — that depend on each
other, must start in the right order, and must stop in the reverse order
when the app quits, the user signs out, or a test ends. By hand, startup is
a sequence in applicationDidFinishLaunching and teardown is a list in
applicationWillTerminate: unordered when it matters, unbounded when a
service hangs, synchronous where cleanup is async, and with every failure
swallowed.
Origin
- Effect's
Layerand ZIO'sZLayer. Services built from the services they need, constructed concurrently where independent, with resource-safe teardown. ZIO checks the graph at compile time with a macro; Effect at the type level. - Bluefin and Eio. Capabilities as values: two of the same service are two values, not a lookup by type.
- ServiceLifecycle. Reverse-order graceful shutdown, and an escalation ladder when it does not finish.
API
public struct ServiceKey<Value: Sendable>: Hashable { init(_ name: String); var any: AnyServiceKey }
public struct Provide {
init<Value>(_ key: ServiceKey<Value>, needs: [AnyServiceKey] = [], startTimeout: Duration = .seconds(30),
stopTimeout: Duration? = nil, start: @escaping @Sendable (Services) async throws -> Value,
stop: @escaping @Sendable (Value, Exit) async throws -> Void = { _, _ in })
}
public struct Services { subscript<Value>(key: ServiceKey<Value>) -> Value }
public struct ServiceGraph {
init(label: String, @ServicesBuilder _ services: () -> [Provide]) throws(ServiceGraphError)
var startOrder: [AnyServiceKey]
func run<R>(exitBudget: Duration = .seconds(30), stopTimeout: Duration = .seconds(10),
_ body: nonisolated(nonsending) (Services) async throws -> R) async throws -> R
}
public enum ServiceGraphError: Error { case duplicate, missing, cycle, startFailed }Semantics
Keys are values. A ServiceKey<Database>("cache") and a
ServiceKey<Database>("primary") are different services of the same type.
Validated when built. Duplicates, dependencies on absent services and
cycles are initialiser errors, with the cycle spelled out (x → y → z → x),
before anything starts.
Declared needs are enforced. A factory sees only the services it listed
in needs; reading any other is a programming error caught on first use, so
the declared graph — which decides start and stop order — cannot quietly
drift from what the code does.
Starting. A service starts as soon as everything it needs has started,
so independent services start concurrently. Each start runs under its own
cooperative startTimeout. If any start fails or times out, the other
starts are cancelled, every service that did start is stopped, and run
throws startFailed naming the service and its error.
Stopping. However the body ends, services stop in the reverse of the
order their starts completed — a service always stops before anything it
needs — through a scope's exit: shielded from cancellation, each bounded by
its stopTimeout, all within exitBudget, failures reported, and a hung
stop abandoned so the next one still runs. The Exit passed to each stop
says whether the app is ending normally.
App lifecycle bridges (StoicAppKit / StoicUIKit)
applicationWillTerminate is synchronous; async teardown run from it is
cut off when the process exits. The correct macOS pattern is to return
.terminateLater from applicationShouldTerminate, run the graph's
shutdown, and reply true when it finishes — or when a hard deadline
passes, so a hung service can never stop the app from quitting. On iOS the
equivalent is a background task whose expiration handler cancels the
shutdown scope with cause systemExpiring. Both bridges are small, and live
in integration products so the core stays free of UI frameworks.
Built: GracefulTermination (AppKit) and BackgroundWork.run(named:)
(UIKit). The iOS bridge is a thin shell over ExpiringWork, in the core so
it is tested on every platform: the work runs under a deadline of the time
granted, is cancelled by expiry (cause systemExpiring), by that deadline
and by its caller, and each run has its own cause. Expiry is final. Three
rules came from review:
- UIKit reports
backgroundTimeRemainingasDouble.greatestFiniteMagnitudein the foreground. It is finite, and converting it to a duration traps, so only a grant of up to an hour is treated as one. - Awaiting an unstructured task's value does not cancel it when the awaiting task is cancelled. The bridge passes cancellation on explicitly.
- The system requires the background task ended before the expiration handler returns. The handler ends it at once, without waiting for the work to unwind; the normal exit path ends it otherwise, exactly once.
Failure modes
| What happens if… | Behaviour |
|---|---|
| two services share a key | duplicate from the initialiser |
| a service needs one that is absent | missing from the initialiser |
| services need each other | cycle, with the path |
| a factory reads a service it did not declare | Programming error on first use |
| a start throws or times out | Other starts cancelled; started services stopped; startFailed |
| the body throws | Every service stopped (Exit .failure); the error passes through |
| a stop hangs | Abandoned after its timeout; the rest still stop |
| the app's task is cancelled | Every service stopped, shielded |
Testing
Start order and reverse stop order; two services of one type; concurrency of independent starts; every validation error and its message; start failure and start timeout with cleanup of what started; stops after a failing body. 200 repetitions are clean.
Planned
@Servicesmacro: a graph declared as a struct is checked at compile time — missing dependencies and cycles become compiler errors.- Supervised services: a service's long-running work as a supervisor child, with the graph as the supervisor's parent.
- ServiceLifecycle trait: run a graph as a
Service.