WorkerTester, the harness that fails a worker whose run() survives its cancellation. This is the fourth page of the nine-tutorial series.
What will you build?
The same app, with the mocks gone. Every port now has one implementation insrc-kmp/backend-local, a Kotlin module both apps consume: LocalAuth, LocalPurchases, LocalItems and LocalAccount, persisted through one KeyValueFile port as a JSON document under each app’s private storage. The auth port gains a sessions stream and the purchases port an entitlements stream; both are sticky, so a late subscriber sees the current value first. A session worker turns each session value into the root’s AuthChanged, an entitlement worker turns each entitlement value into EntitlementChanged, and the root keeps the entitlement as the one value the paid check reads. A guest session expires after thirty minutes and the expiry raises the gate from wherever the user is. Expect about two and a half hours.
You will have at the end:
- A
backend-localmodule with the four repositories, the file port with its two platform implementations, and five unit tests running the guest expiry and the purchase delay on virtual time. - No
Mock*service anywhere:grep -r 'class Mock' .prints nothing in the finished tree. - Two workers on each platform, adopted at the root mount, with
WorkerTestersuites on both sides and a pure transform pinned once in the root module. - Seven new recordings: two for the root, four for the home tab, one for the profile tab, 39 in all.
- Both apps showing the Insights card locked, the promo behind it, the card unlocking after the purchase, and the summary behind the unlocked card.
Where do you start?
Opentutorial4-start from the duet-tutorials repository. It is Tutorial 3’s finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0, duet-services 0.11.1 and the KSP mock processor 0.2.1 with Tutorial 3’s toolchain. Run the checks once before you edit anything:
Tutorial4ExerciseSessionExpiryTest; that is the exercise, and everything else is green. TUTORIAL_SKIP_STUBS=1 tools/duet verify leaves the stub out and ends with duet verify: PASS.
The steps
Add the two streams to the ports
StateFlow each.Entitlement is @Serializable with a canonical sum serializer, because it lives inside three features’ state and so inside the recordings; Session is not, because no feature state holds it. The default display name rule, the email’s local part or “Guest”, moves from the sign-in module to this one, because the backend needs it too. One more file re-exposes the two streams as functions:splashStateFlow(store:) exists. The Kotlin workers read the members directly.Write the on-device backend
src-kmp/backend-local holds one implementation of each port. They are product code, named Local*: the apps run on them, the composition roots construct them, and nothing replaces them later. The backend persists through a port of its own, two plain functions, so each app hands it a file it owns and a test hands it memory:JsonFile(path) implements it once per platform, in the module’s jvmMain and appleMain source sets, through java.io.File and NSString; MemoryFile implements it in common code for the test suites and the composition specs. One @Serializable record is the whole document, and LocalStorage reads it once and writes it whole on every update. The auth repository owns the session stream:KernelClock.sleep, in the scope the composition root hands the backend; the window restarts at each launch, because the seam carries a sleep and no wall-clock reading. The purchases repository does the same with a one-second delay, so the promo’s in-flight state is visible, and it writes the entitlement stream before it answers the call:jvmTest runs the repositories on a memory file. Under runTest, LiveClock sleeps on the scheduler’s virtual time, so the thirty-minute expiry is an advanceTimeBy:(cd src-kmp && ./gradlew :backend-local:jvmTest).Delete the mock services, one for one
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/services/ with its four files, and the FoyerServices target of the consumer package with its four files. In: the root Component on each side owns one LocalBackend and hands its members down as the ports it satisfied with mocks before. The root’s Dependency names, for the first time, something the platform supplies: the file.JsonFile under its private storage: the scene’s Documents directory on iOS, the Activity’s filesDir on Android. The host tests supply a MemoryFile. The main level’s Dependency gains purchases, because the home tab now buys through it; the home’s gains it too. Regenerate the Swift Components and doubles with tools/duet mocks, then confirm the deletion:LocalBackend over a MemoryFile and passes the members it needs into the generated Dependency double.Grow the root
entitlement changes to the root scenario, list root.entitlement-changes in the manifest and re-record the root with tools/duet record --feature root; the five existing leaves re-record too, because the state gained a field. The three chains through the root are unchanged, since a chain carries no state.Write the workers on Android
run() is its whole life. The host adopts it at mount, which starts run() in a coroutine on the host’s scope, and cancels that coroutine at teardown; collect returns on the cancellation, and there is no stop() to forget. A worker decides nothing. It reads a stream and sends.Relay, the shells package’s settable event funnel: the composition root creates it, hands it to the workers it builds, and points its sink at the store once the store exists. The two adoptions sit at the end of buildRoot, after the child slot and the projection are in place:AuthChanged(SignedOut) seed that buildRoot sent in Tutorial 3 is gone: the session stream is sticky, so the session worker’s first report is the current session, and a persisted one skips the gate after the splash.Test the workers with the harness
WorkerTester and no recordings. Parity is never gated at a worker: a worker that exists on both platforms is two native implementations, one over a Flow and one over an async sequence, sharing the pure transform from the step before, and the transform is where the cross-platform test concentrates. The harness is the adopt bracket, test-side: start on the test scope, drive the backend, read what reached the relay, finish.finish() cancels the worker’s coroutine, yields until run() returns, and fails the test if it never does. That is the leak class a stop() convention cannot catch, made a harness guarantee. A second test in the same file advances virtual time past the guest window and reads AuthChanged(SignedOut) off the relay. Add testImplementation(libs.duet.kernel.test) to the app module and run (cd src-kmp && ./gradlew :app:testDebugUnitTest).Write the workers on iOS
for await. It is @MainActor, so its state needs no lock and the report lands on the main-confined store; Working needs only a nonisolated run(), which a main-actor method witnesses. The stream is read through the ports module’s sessionsFlow, whose return type crossed the boundary as the async sequence.bind(), after the store and the projection, so the first report of each sticky stream lands on a store whose projection is already bound; the host cancels them first at teardown, in the reverse of adoption order.WorkersSpec in the root shell’s test target, drives the same three rows through WorkerTester from the DuetTesting product, which the test target now depends on. Its purchase row waits a real second, because the backend sleeps on the wall clock outside runTest. The composition spec asserts shell.host.liveWorkerCount == 2 while the tree is up: the host’s ledger of adopted workers whose run() has not returned.Project the entitlement down as a slice
StateFlow on the root mount and the projection is a StateTransitions:Projected, a slice the parent writes and the child observes; the observation is a HostedObservation, so the host retains it for the mount and cancels it at teardown.apply, before it reconciles the child, so a main level built in the same projection reads the current value. Each tab’s shell gains one intent for it:entitlement and its header gains a plan row reading “Free” or “Premium · Monthly”; the leaf profile.plan-row-follows-the-stream pins the write.Lock the Insights card
EntitlementChanged through the slice; the scenario’s branch pins that the card is unlocked before the purchase’s own answer arrives and that the answer writes no entitlement:purchase(plan, onOutcome), so the generated HomeEnvironmentMock gains purchaseHandler and purchaseArgs, and the test-store suite gains the round trip. Record the four new leaves with tools/duet record --feature home. The screens are the same on both platforms: a card with a lock and “Premium” while locked, the promo and the summary each replacing the tab’s content while presented names them.
The Insights card locked (top) and unlocked after the purchase (bottom), on an iPhone 17 simulator (left) and a Pixel 8 API 36 emulator (right); tutorial4-complete at Duet 0.7.0, duet-tools 0.24.0.
Run both apps
GUEST_SESSION_MINUTES to one, and watch the gate come up from the profile tab a minute later; the reducer rule from the root step is what moves the phase, and the worker only reported the value.
The Insights summary behind the unlocked card, presented the same way on both platforms; tutorial4-complete at Duet 0.7.0, duet-tools 0.24.0.
RootFlowTest walks the same path headless, including the expiry on virtual time and the relaunch over the same memory file; the Swift RootCompositionSpec does the same across the boundary on real time.What you now have
- A
backend-localmodule with one implementation per port, persisted as one JSON document behind a two-function file port, and no mock service on the tree. - Two streams on the ports, two workers per platform observing them for the root mount’s lifetime, and one relay carrying their reports into the root store.
WorkerTestersuites on both sides, a pure transform pinned once, and no recording for a worker.- The entitlement written by one action from one stream, projected down to two tabs as a slice, and read by a locked card that unlocks from the stream.
- Seven new recordings, 39 in all, and every check green:
tools/duet verify,tools/duet mocks --check, the backend’s tests, the Android unit tests and the Apple lane.
Exercise: record the session expiry
tutorial4-start carries Tutorial4ExerciseSessionExpiryTest, a failing placeholder in the root module. Delete it, add a branch session expiry returns to gate to the root scenario, list root.session-expiry-returns-to-gate in the manifest and in the root’s feature spec, and add its row to RootGoldenTest. The branch starts from a signed-in session, completes the splash into main, then sends AuthChanged(SignedOut) as the session worker would after the guest window, and pins the gate. Without the reducer rule from the root step the recording shows the phase staying on main, which is the point of writing the branch first. The finished reducer clause reads:
tools/duet record --feature root, then tools/duet verify; the Kotlin lane replays 39 fixtures and the run ends with duet verify: PASS. tutorial4-complete carries the finished branch.
Common questions
Why do workers carry no recordings?
Why do workers carry no recordings?
Why is each stream sticky?
Why is each stream sticky?
StateFlow on Kotlin, replays its current value to a late subscriber, so the first report is the current session or the current entitlement and the root needs no seed of its own. An event stream would deliver only what changes after the subscription, and a relaunch would start the app signed out with a plan it owns. The workers page in the framework repository names the two shapes: a sticky flag and an event tick.Why does the purchase's answer not write the entitlement?
Why does the purchase's answer not write the entitlement?
Why a JSON file and not the platform's preferences?
Why a JSON file and not the platform's preferences?
KeyValueFile is a small class in each app and nothing in the backend changes. Tutorial 5 relies on the document surviving process death for the restore demonstration.Why does the Swift worker call sessionsFlow(auth:) instead of reading auth.sessions?
Why does the Swift worker call sessionsFlow(auth:) instead of reading auth.sessions?
StateFlow member as the raw Kotlin flow type, which Swift cannot iterate with for await. A top-level function’s return type is exported through the Swift-friendly projection as the async sequence. sessionsFlow and entitlementsFlow are that projection for the two streams, two lines in the ports module, and the Kotlin workers read the members directly.Why does the guest window restart at launch?
Why does the guest window restart at launch?
now to its own port, outside the kernel’s seam.Sources and further reading
- The Duet framework repository —
docs/workers.md, the worker contract this page applies: one bracket, one life, no silent ingress, logical tests only;Working,StoreHost.adoptandRelayin the shells packages;WorkerTesterinDuetTestingandkernel-test;KernelClock, the clock seam the backend waits on. - The duet-tools repository —
contracts/manifest.mdand therecordverb, for the re-recorded features. - kotlinx.coroutines: StateFlow — the sticky stream both ports carry, and its conflation.
- SKIE: Flows — how a Kotlin flow becomes a Swift async sequence, and where the conversion applies.
- The duet-tutorials repository —
tutorial4-startandtutorial4-complete, and the checks CI runs on them. - The Duet glossary — worker, mount, host and golden recording.