ScopeLifecycleTests.kt (8959B)
1 package tera.smoke 2 3 import java.nio.file.Files 4 import kotlin.test.Test 5 import kotlin.test.assertEquals 6 import kotlin.test.assertFailsWith 7 import kotlin.test.assertFalse 8 import kotlin.test.assertNotEquals 9 import kotlin.test.assertNull 10 import kotlin.test.assertTrue 11 import kotlinx.coroutines.CoroutineStart 12 import kotlinx.coroutines.async 13 import kotlinx.coroutines.cancelAndJoin 14 import kotlinx.coroutines.delay 15 import kotlinx.coroutines.runBlocking 16 import kotlinx.coroutines.withTimeout 17 import uniffi.tera_core.FfiInvalidationRevision 18 import uniffi.tera_core.FfiLocalNetworkRecord 19 import uniffi.tera_core.FfiMediaOperation 20 import uniffi.tera_core.FfiRecoveryDisposition 21 import uniffi.tera_core.FfiRetryDisposition 22 import uniffi.tera_core.FfiRuntimeChangeKind 23 import uniffi.tera_core.FfiTodayProjectionUpdate 24 import uniffi.tera_core.ProtectedDataAvailability 25 import uniffi.tera_core.TeraAppException 26 import uniffi.tera_core.classifyErrorRecovery 27 28 class ScopeLifecycleTests { 29 @Test 30 fun scopeAndUnsignedContextsCrossRustWithoutSignedNarrowing(): Unit = runBlocking { 31 SmokeFixture().use { fixture -> 32 val runtime = fixture.runtime() 33 var firstEpoch = "" 34 try { 35 val observer = SmokeObserver() 36 runtime.subscribeChanges(observer).use { subscription -> 37 val initial = observer.next() 38 assertEquals(FfiRuntimeChangeKind.INITIAL, initial.kind) 39 assertEquals(PUBLIC_KEY, initial.scope.publicKey) 40 assertEquals(GENERATION, initial.scope.sourceGeneration) 41 assertNull(initial.scope.context) 42 assertEquals(32, initial.epoch.length) 43 firstEpoch = initial.epoch 44 for ((index, generation) in listOf(0uL, 1uL, Long.MAX_VALUE.toULong() + 1uL, ULong.MAX_VALUE).withIndex()) { 45 val context = FfiLocalNetworkRecord(1u, "nearby", "Nearby", listOf("wss://relay.example"), null, emptyList(), generation) 46 runtime.phase1RefreshToday(context, UNIX_S, FfiTodayProjectionUpdate.INCREMENTAL) 47 val changed = observer.next() 48 assertEquals(context, changed.scope.context) 49 assertEquals(initial.epoch, changed.epoch) 50 assertEquals(FfiInvalidationRevision.Current((index + 1).toULong()), changed.revision) 51 } 52 val valid = FfiLocalNetworkRecord(1u, "nearby", "Nearby", listOf("wss://relay.example"), null, emptyList(), 1uL) 53 for ((invalid, code) in listOf( 54 valid.copy(id = "") to "invalid_local_network", 55 valid.copy(schemaVersion = UShort.MAX_VALUE) to "unsupported_schema_version", 56 )) { 57 val failure = assertFailsWith<TeraAppException.Failure> { 58 runtime.phase1RefreshToday(invalid, UNIX_S, FfiTodayProjectionUpdate.INCREMENTAL) 59 } 60 assertEquals(code, failure.report.code) 61 } 62 runtime.configurePublicRelays(listOf("wss://write.example")) 63 assertEquals(FfiRuntimeChangeKind.RELAY, observer.next().kind) 64 assertTrue(subscription.isActive()) 65 subscription.unsubscribe() 66 subscription.unsubscribe() 67 assertFalse(subscription.isActive()) 68 } 69 } finally { 70 runtime.shutdown() 71 runtime.close() 72 } 73 val reopened = fixture.runtime() 74 try { 75 assertEquals(PUBLIC_KEY, reopened.identityStatus().publicKey) 76 assertTrue(reopened.identityStatus().hostSignerConfigured) 77 val observer = SmokeObserver() 78 reopened.subscribeChanges(observer).use { 79 assertNotEquals(firstEpoch, observer.next().epoch) 80 assertTrue(it.isActive()) 81 } 82 } finally { 83 reopened.shutdown() 84 reopened.close() 85 } 86 } 87 } 88 89 @Test 90 fun cancelledCloseWaitRetainsNativeCallbackDrainAndClosedAdmission(): Unit = runBlocking { 91 SmokeFixture().use { fixture -> 92 val runtime = fixture.runtime() 93 val observer = SmokeObserver(paused = true) 94 val subscription = runtime.subscribeChanges(observer) 95 try { 96 observer.next() 97 val closing = async(start = CoroutineStart.UNDISPATCHED) { runtime.shutdown() } 98 withTimeout(5_000) { while (!runtime.info().sdkClosed) delay(1) } 99 assertFalse(closing.isCompleted) 100 closing.cancelAndJoin() 101 assertFalse(subscription.isActive()) 102 val failure = assertFailsWith<TeraAppException.Failure> { runtime.sdkStorageStatus() } 103 assertEquals("client_closed", failure.report.code) 104 val repeated = async(start = CoroutineStart.UNDISPATCHED) { runtime.shutdown() } 105 assertFalse(repeated.isCompleted) 106 observer.released.countDown() 107 val receipt = withTimeout(5_000) { repeated.await() } 108 assertEquals("closed", receipt.state) 109 assertTrue(receipt.alreadyClosed) 110 assertTrue(runtime.shutdown().alreadyClosed) 111 } finally { 112 observer.released.countDown() 113 subscription.close() 114 runtime.shutdown() 115 runtime.close() 116 } 117 } 118 } 119 120 @Test 121 fun independentSubscriptionDisposalStopsOnlyItsObserver(): Unit = runBlocking { 122 SmokeFixture().use { fixture -> 123 val runtime = fixture.runtime() 124 val first = SmokeObserver() 125 val second = SmokeObserver() 126 val a = runtime.subscribeChanges(first) 127 val b = runtime.subscribeChanges(second) 128 try { 129 assertEquals(first.next(), second.next()) 130 // Closing the live generated handle must dispose its Rust subscription. 131 a.close() 132 a.close() 133 runtime.configurePublicRelays(listOf("wss://write.example")) 134 assertEquals(FfiRuntimeChangeKind.RELAY, second.next().kind) 135 assertEquals(0, first.pending()) 136 assertTrue(b.isActive()) 137 runtime.shutdown() 138 assertEquals(FfiRuntimeChangeKind.LIFECYCLE, second.next().kind) 139 assertFalse(b.isActive()) 140 } finally { 141 a.close() 142 b.close() 143 runtime.shutdown() 144 runtime.close() 145 } 146 } 147 } 148 149 @Test 150 fun errorRecoveryAndProtectedDataFailureRemainTyped(): Unit = runBlocking { 151 for ((code, disposition, retry) in listOf( 152 Triple("draft_revision_conflict", FfiRecoveryDisposition.STALE_REVISION, FfiRetryDisposition.AFTER_RECOVERY), 153 Triple("ios.runtime.cancelled", FfiRecoveryDisposition.OUTCOME_UNKNOWN, FfiRetryDisposition.RECONCILE_EXISTING_OPERATION), 154 Triple("deadline_exceeded", FfiRecoveryDisposition.OUTCOME_UNKNOWN, FfiRetryDisposition.RECONCILE_EXISTING_OPERATION), 155 Triple("unknown_future_retryable_network", FfiRecoveryDisposition.UNKNOWN, FfiRetryDisposition.NOT_ALLOWED), 156 )) { 157 val decision = classifyErrorRecovery(1u, code) 158 assertEquals(disposition, decision.disposition) 159 assertEquals(retry, decision.retry) 160 } 161 assertEquals(FfiRecoveryDisposition.UNSUPPORTED_VERSION, classifyErrorRecovery(UShort.MAX_VALUE, "client_closed").disposition) 162 SmokeFixture().use { fixture -> 163 val failure = assertFailsWith<TeraAppException.Failure> { fixture.runtime(ProtectedDataAvailability.UNAVAILABLE) } 164 assertEquals("protected_data_unavailable", failure.report.code) 165 assertEquals(1u.toUShort(), failure.report.schemaVersion) 166 assertFalse(Files.exists(fixture.owner.resolve("runtime.sqlite"))) 167 assertFalse(failure.report.safeMessage.contains(fixture.root.toString())) 168 } 169 } 170 171 @Test 172 fun cancellationHandleKeepsIdentityAndDisposesIdempotently() { 173 val operation = FfiMediaOperation() 174 val identity = operation.operationId() 175 assertEquals(32, identity.length) 176 assertFalse(operation.isCancelled()) 177 operation.cancel() 178 operation.cancel() 179 assertTrue(operation.isCancelled()) 180 assertEquals(identity, operation.operationId()) 181 FfiMediaOperation().use { assertNotEquals(identity, it.operationId()) } 182 operation.close() 183 operation.close() 184 assertFailsWith<IllegalStateException> { operation.isCancelled() } 185 } 186 }