Adopting
Adopting Stoic in an existing app
Stoic is designed to be adopted one call site at a time. Nothing requires rewriting an app around it; each primitive replaces one hand-rolled pattern and pays for itself on its own.
1. Prerequisites
- Swift 6.4 tools (
// swift-tools-version: 6.4) and a deployment target of macOS, iOS, tvOS, watchOS or visionOS 27. Stoic's cleanup guarantee rests on runtime features (cancellation shields,awaitindefer) that ship with the 27 releases. - Swift 6 language mode is recommended but not required to use Stoic.
2. Add the package, with the lint plugin
dependencies: [
.package(url: "https://github.com/Vaccone-Software/stoic", from: "0.1.0", traits: ["Lint"]),
],
targets: [
.target(
name: "App",
dependencies: [
.product(name: "Stoic", package: "stoic"),
.product(name: "StoicApple", package: "stoic"), // platform errors classify themselves
],
plugins: [.plugin(name: "StoicLint", package: "stoic")]
),
.testTarget(name: "AppTests", dependencies: ["App", .product(name: "StoicTesting", package: "stoic")]),
]3. Take a baseline, then ratchet
An existing codebase will have findings. Record them once, so the build passes today and fails on anything new:
swift run --traits Lint stoic-lint --write-baseline .stoic-lint-baseline.json SourcesBaselined findings appear as warnings; new ones are errors. When a finding is fixed, the baseline reports it as stale; regenerate it, and the count only ever falls.
4. Warnings as errors, by group
Turning on .treatAllWarnings(as: .error) in one step usually fails the
build. Phase it in with SE-0480's per-group controls: make the groups you
have already fixed errors first, then widen.
swiftSettings: [
.treatWarning("DeprecatedDeclaration", as: .error),
.treatWarning("StrictMemorySafety", as: .error),
// … then, once clean:
.treatAllWarnings(as: .error),
]5. Replace patterns in order of payoff
- Timeouts built by hand →
withTimeout, orabandoningAfterfor blocking calls that cannot be cancelled. These are where hangs come from. - Retry loops →
retry(.exponential(base:).attempts(n)). State the budget once; derive any outer watchdog fromworstCase(attemptTimeout:). - Generation counters and stale callbacks →
TaskSlot. - Lifecycle booleans →
StateMachine, withwhileInfor the work each state owns. Add aStateGraph.exploretest. - Watchdog timers and restart loops →
Supervisorwith heartbeats. - Startup and shutdown lists →
ServiceGraph, and on macOSGracefulTerminationfromapplicationShouldTerminate. - Config validation →
StoicSchema: one declaration per type. With theMacrostrait the declaration is the type itself (@Schema struct Settings { … }); without it,Schema.object. try?and stringified errors → handle them, orwithContext.- Settings written with
Data.write→SettingsStore.open(at:schema:): validated updates, crash-safe writes, migrations, and an older build that cannot destroy what a newer one saved. - Handles closed under a wedged call → when a scope's work may be
abandoned, declare the handle's release
ifWorkAbandoned: .quarantine.
6. Make tests deterministic
Wrap time-sensitive tests in withTestAmbient(clock: TestClock()), drive
time with waitForSleepers(count:) and advance(by:), inject failures with
FaultInjector, and end with #expect(await stoicTasksStillRunning().isEmpty)
so leaked work fails the test by name.
For anything concurrent — a cache, a sync engine, shared state across tasks — add a simulation test. It explores hundreds of interleavings and fault timings deterministically, and a failure names the seed that replays it:
@Test func syncSurvivesEveryInterleaving() async throws {
try await simulate(seeds: 0..<500) { sim in
let engine = SyncEngine(store: FakeStore())
try await engine.syncAll()
try #require(engine.pending.isEmpty) // #require: it must throw to fail the seed
}
}Where the path matters as much as the result, pin the decisions with a
golden trace: expectTrace(events, """…""").
7. Watch the decisions
At launch, install a diagnostics ring and the device's conditions:
let ring = Diagnostics.install()
try await withAmbient({
$0.instrumentation = CompositeInstrumentation([$0.instrumentation, ring])
$0.conditions = DeviceConditions.shared
}) {
try await app.run()
}Diagnostics.snapshot() then gives a bug report the last decisions Stoic
made, every Stoic task still running, and any abandoned work.
To know what the previous run was doing when it died, add a flight recorder, and check it at launch:
let recorder = try FlightRecorder(path: supportDirectory + "/flight.rec")
if let last = recorder.previousRun, !last.endedCleanly {
report("previous run ended abruptly", last.events.suffix(50))
}
// …add `recorder` to the CompositeInstrumentation above, and on the way out:
recorder.markCleanExit()