RoomConformanceSuite
Reusable contract test suite for RoomFactory implementations.
Subclass and implement newHarness to bind any RoomFactory under test. Every Test encodes a required invariant of the Room lifecycle state machine.
Lives in commonMain of :kuilt-conformance (not a module's commonTest) so every RoomFactory adapter can subclass it from its own test source set.
Virtual time convention: all partition tests advance in 100 ms steps using fastHeartbeatConfig (interval=100ms, timeout=200ms, reconnectWindow=500ms):
4 × 100 ms → MembershipEvent.Partitioned fires.
9 × 100 ms → past reconnect window → PeerLost / MembershipEvent.HostLost.
Scope contract: newHarness receives the test's CoroutineScope (typically backgroundScope from runTest) so FaultyLoom and SeamRoomFactory are correctly structured under the test's virtual-time scheduler.
Fault injection: tests that require partition or teardown behaviour go through RoomHarness.faults, a two-armed FaultInjection fixture. A harness that cannot break its own links declares FaultInjection.Unsupported with a tracking URL — it cannot decline silently, because the arm has nowhere to put a refusal that is not also a declaration (#2306).
Every wait is bounded, and names what it saw. The population running this suite is by definition implementors whose fabric does not work yet, so no obligation here may be guarded by a suspending wait that simply never returns. Waits go through awaitRoster / awaitEvent / awaitFrame, each bounded by awaitBudget in virtual time and each failing with an AssertionError that prints the roster (or the events) actually observed — see #2284, where a fabric that never admitted burned the whole runTest ceiling and then reported UncompletedCoroutinesError, naming neither the room nor its roster.
No obligation here returns silently — #2306
There is no skip API in common kotlin-test, so an obligation that early-returns reports PASS — worse than a JVM-visible @Ignore, because nothing anywhere records that it did not run. This suite had four such returns and, until #2306, exactly one subclass: the reference. Every escape hatch had therefore fired zero times in its life, and nothing had ever checked that a harness taking one survives the suite at all.
They are closed by three different mechanisms, and which one applies is a judgement about whose limitation the missing state is:
resumeToken— a loud precondition. Room.resumeToken is documented non-null on an admitted joiner, so a null there is a contract violation by the room, not a limitation of the harness, and requireResumeToken fails naming the room. The early return was not even protecting anyone: joinerLearnsHostRoomIdOnAdmission already comparesresumeToken?.roomIdagainst a non-null host id, so the same room was already failing one test of this suite while silently skipping another. Two tests, one fabric, opposite verdicts.Fault injection — a two-armed sealed fixture (FaultInjection). Here the missing state genuinely belongs to the harness: a room over a fabric whose links the test cannot reach cannot be partitioned by anyone. A nullable hook was the wrong shape for it —
nullmoves the vacuity one level up, where it is a value nobody has to justify. The sealed arm makes declining representable only together with its declaration, so the pairing is the compiler's job rather than a meta-test's, and the arm that declines still ends in an assertion (injectorOrDeclaredGap) rather than in a barereturn.The refusal branch — a property that needed no hatch at all. aTokenMintedForAnotherRoomIsRefused was simply never written; see its KDoc for what the
Room.resumesurface can and cannot observe.
What the fixture still cannot detect, said plainly. A harness that could fault-inject and declares FaultInjection.Unsupported anyway is invisible here — there is no capability on RoomFactory to check the claim against, and inventing one would put a knob in the contract no consumer asked for. What the arm does buy is that the claim now exists, is attributable, and carries a URL somebody has to keep alive. The residual is narrower, not gone.
Mutation receipt
JVM, --rerun-tasks (27/27 EXECUTED). Subjects: InMemoryRoomConformanceTest (the reference, 14 tests) and RoomConformanceGapDeclarationTest (6). Real = a defect an implementation could plausibly ship; synthetic = a change made purely to reach an assertion no real defect reaches; rig = a mutation of this suite itself. The "before" column is the measurement of the hole — what the pre-#2306 suite did under the same mutation.
| # | Mutation | Kind | after | before |
|---|---|---|---|---|
| M1 | SeamRoom.resumeToken returns null — a room opting out of resume entirely | real | RED: resumeWithinWindowFiresResumed and aTokenMintedForAnotherRoomIsRefused on the loud precondition, naming role=Joiner … roster=1 member(s) | joinerLearnsHostRoomIdOnAdmission RED — but resumeWithinWindowFiresResumed green by absence |
| M2 | injectorOrDeclaredGap drops its assertion — i.e. the pre-#2306 silent ?: return@runTest, exactly | rig | RED: all four blankTrackingUrl* as measured — the set is seven since #2501 | all green; the four gated obligations passed under a gap declaring nothing |
| M3 | delete the token.roomId != roomId guard in DefaultJoinerReconnectController.tryResume | real | RED: aTokenMintedForAnotherRoomIsRefused, all 4 assertions — got Success, then a refusal for the genuine token | every pre-existing test of this suite green |
| M4 | the host drops a foreign token silently instead of refusing it | synthetic | RED: aTokenMintedForAnotherRoomIsRefused, 2 of 3 — Got TimedOut | all green |
M1 is the argument for requireResumeToken. The same fabric was already failing joinerLearnsHostRoomIdOnAdmission while resumeWithinWindowFiresResumed returned green without asserting anything — two tests of one suite, opposite verdicts on one room. The early return was not protecting a population; it was hiding a contradiction.
M4 is why the "verdict, not silence" assertion is not decoration. Under it the first assertion — "must not be ResumeResult.Success" — stays green, because TimedOut is not Success; only the second reds. A lone refusal check would have passed a host that never answered at all. (Its third assertion also reds under M4, but as blast radius: the window elapses during the resume timeout. Not an independent diagnosis, and not claimed as one.)
M3's claim is narrower than it looks, and the narrowing matters. It also reds two tests in :kuilt-session's own JoinerReconnectControllerTest, so the defect is not invisible to the tree — only to the contract. That is precisely the thing a second RoomFactory inherits nothing of: an implementation's private suite is not a conformance obligation.
One assertion has no red anywhere, and that is correct: RoomConformanceGapDeclarationTest.aDeclaredGapSkipsEveryGatedObligationCleanly. It does not describe behaviour under test — it is the survivability check, and its falsifying input is a future edit that moves work above the gate (a suspending wait, a links[0] access), not any defect present today. Stating it rather than hiding it: an all-red table would mean the table was measuring blast radius instead of diagnoses.
Mutation receipt — Room.leave's two obligations (#2501)
JVM, --rerun-tasks, subjects InMemoryRoomConformanceTest (19 tests) and RoomConformanceGapDeclarationTest (9). Same kinds as above. All five new properties are green against unmutated main, and that is correct — SeamRoom.leave calls seam.close(...) unguarded, so nothing mints today; these are live guards, not regression tests for a fix.
Every row below was re-measured after theTeardownFaultReallyFires moved its arms onto links no Room owns. One verdict changed, and it changed to a narrower one — see the T7 note.
| # | Mutation | Kind | verdict |
|---|---|---|---|
| T1 | SeamRoom.leave wraps its seam.close(...) in withTimeout(100.milliseconds) | real | RED — leaveDoesNotMintACancellationWhenTeardownIsSlow, 1 of its 2 assertions: Got: kotlinx.coroutines.TimeoutCancellationException: Timed out after 100ms of _virtual_ … time. Nothing else moves. |
| T2 | closed = true moves out of leave's opening lock.withLock to after seam.close(...) | real | RED — leaveIsIdempotentEvenWhenTeardownFails, 1 of 2, naming both calls' failures. leaveIsIdempotent stays GREEN. |
| T3 | leave's if (closed) return fast path deleted outright | real | RED — leaveIsIdempotentEvenWhenTeardownFails only. Both ungated properties GREEN. |
| T4 | TeardownFault.Fails delegates but does not throw | rig | RED — theTeardownFaultReallyFires, 1 of 4: Got: no exception — the close completed. leaveIsIdempotentEvenWhenTeardownFails goes GREEN BY ABSENCE. |
| T5 | TeardownFault.Slow does not delay | rig | RED — theTeardownFaultReallyFires, 1 of 4: Expected at least 1000 ms of virtual time to pass, got 0 ms. |
| T6 | T1 and T5 together | rig | RED — theTeardownFaultReallyFires only. leaveDoesNotMintACancellationWhenTeardownIsSlow is GREEN — T1's real defect has gone invisible. |
| T7 | leave throws IllegalStateException("already left") on a second call | synthetic | RED — leaveIsIdempotent, leaveDoesNotReportFailureAsCancellation, leaveIsIdempotentEvenWhenTeardownFails and aDeclaredGapDoesNotExcuseTheUngatedObligations, each on the raw throw. theTeardownFaultReallyFires is not among them — see below. |
T3 is the measurement of the gap this whole section closes, and it is the most damning row. Deleting leave's idempotency guard entirely is invisible to leaveIsIdempotent — because on a reference whose close is synchronous and infallible, re-running the whole teardown is simply harmless. Only the fault-gated companion sees it. That is the #2244 shape stated as a measurement rather than as a worry: a property whose reference cannot reach the failure does not test the failure, and the ungated half of an obligation is not a substitute for the gated half.
T2 says the same thing from the other side. A successful teardown still reaches the flag, so the happy-path property cannot distinguish "idempotent" from "idempotent only when the first call succeeded". The contrast between T2/T3 and T7 is why both halves are here.
T4 is exactly what theTeardownFaultReallyFires exists for, and it also shows what the rig-fired counter alone does not buy. Under T4 the counter still increments — the arm was selected — so leaveIsIdempotentEvenWhenTeardownFails's own precondition holds and its remaining assertion passes on a leave that never failed. The two mechanisms divide the labour cleanly: FaultySeam.teardownFaultsFired proves the fault reached this seam, and only this test proves the fault does anything. Neither subsumes the other.
T5 and T6 are why the Slow arm is measured at all — and T5 was GREEN in the first draft. This test originally measured only TeardownFault.Fails, and a no-op Slow reddened nothing anywhere in the tree: SeamRoom.leave bounds nothing today, so a zero-duration teardown mints no cancellation either. T6 is the proof that the omission mattered rather than a guess about it — with Slow silently no-op, T1's genuine defect stops being detectable at all. The fix for one vacuity had landed one level up inside itself, which is the recurrence this repo's conventions warn about, caught by asking of the fix what the fix was now unpinned on.
T7 is marked synthetic because no real implementation refuses a second leave on purpose — it is here to show the two ungated properties can red, since T1–T6 never touch them.
T7's blast radius is the receipt for theTeardownFaultReallyFires's bare links. As first measured, T7 also reddened that test: the fixture then armed its fault on a seam a room owned, so closing it made the room self-leave in reaction and the trailing explicit leave() was the second one. That was blast radius rather than a diagnosis — the row said four tests while the prose explained a fifth, which is a receipt disagreeing with itself. Re-measured after the fixture moved to links no Room owns, the fifth hit is gone: nothing self-leaves, because nothing owns the links being closed. The row is four, and it is four for a structural reason rather than because the number was edited to agree with a prediction.
Types
Whether this harness can break and heal the links under the rooms it builds — and, when it cannot, where that is written down.
A harness that bundles host and joiner RoomFactorys plus its FaultInjection fixture.
Properties
How long awaitRoster / awaitEvent / awaitFrame wait before failing with the state they observed — virtual time, null to wait unbounded.
Fast heartbeat config shared by all tests so virtual-time advancement is cheap. Advancing 4 × 100 ms triggers MembershipEvent.Partitioned; advancing 9 × 100 ms exhausts the reconnect window (PeerLost / HostLost).
How many unread frames the rooms under test hold per member for Room.incomingFrom — the depth the boundary and overflow obligations (4c)–(4e) measure against.
Functions
The negative half of joinerLearnsHostRoomIdOnAdmission. That test asserts a joiner's token names the host's room, and its own comment reasons about a room that would "refuse its own members' resumes" — but nothing ever presented a room with a token naming a different room and checked that it said no. Only ResumeResult.Success had a property in this suite; every refusal branch was covered by the reference implementation's private tests (RoomResumeTest, JoinerReconnectControllerTest), which a second RoomFactory inherits nothing from.
A host knows which room it is at construction — Room.roomId is non-null the moment RoomFactory.host returns, with no round trip and nothing to wait for.
The first collection is cancelled at the instant a frame reaches the member's inbox, and the next collection still receives that frame.
(4i) cancels the reader before it resumes, so it cannot see a room that suspends between taking a frame and handing it over — the second window in which a frame can leave an inbox unseen. Here the reader runs on a StepDispatcher. The frame is routed on the test scheduler, which queues exactly one task for the parked reader; the reader runs exactly that task and is cancelled wherever it left off. Across that collection and the next, both frames must arrive exactly once, in order: delivered by the cancelled collection, or left for the next one — never lost.
A peer with no current admission reads as an empty, completed stream — never a failure. From a consumer's side "there is no admission" and "the admission just ended" are the same fact, and a consumer claiming from a roster snapshot legitimately races both (a snapshot read just before an eviction; its own Room.leave, which closes inboxes while the roster still holds the member).
Two members, and each Room.incomingFrom holds only its own member's frames. The second member speaks first, so a room that put every frame in every inbox would hand the first member's reader the second member's frame at the head of its stream.
Frames an admitted member sends before anything collects Room.incomingFrom still reach the collector that starts afterwards, in arrival order.
A joiner has no identity until it is admitted, then reads the host's — one transition, to the value the host already held.
No cancellation may be minted when the transport's teardown SUSPENDS — the #2286 mechanism, reachable only with the injector.
UNGATED CORE. No leave() may report its failure as a cancellation (#1826).
UNGATED CORE. leave() twice must not throw — on a host and on a joiner, whose paths differ: a joiner's leave announces a Goodbye on the still-live seam first.
Idempotent even when the first call FAILED — the half leaveIsIdempotent cannot reach.
Provide a fresh RoomHarness for one test, using scope as the coroutine scope for background loops (SeamRoomFactory, FaultyLoom).
Faults only the host's FaultySeam (0) with FaultProfile.DropAll in both directions. The joiner's seam (1) stays Healthy, mirroring us.tractat.kuilt.session.PartitionRoleTest's proven partition/recovery pattern.
The precondition of leaveIsIdempotentEvenWhenTeardownFails and leaveDoesNotMintACancellationWhenTeardownIsSlow, asserted rather than assumed.