Merge ios-completion: device builds unblocked, access token on both clients, P2 wave

Closes the six remediation items from the 2026-07-29 iOS completion audit plus the
whole P2 wave and Android access-token parity.

The audit's headline was that the client was code-complete but stuck at the device
door: no DEVELOPMENT_TEAM, no entitlements, so it had never run on real hardware
once, and it had fallen two months behind the server (Android had the git panel,
iOS had none) while neither native client could connect at all once WEBTERM_TOKEN
was set.

Package tests 310 -> 452, app bundle 296 -> 550 (iPhone and iPad, zero known
issues), integration 10 -> 32, Android 687 -> 691. ClientTLS went 55.76% -> 89.49%
and is now actually in the coverage gate, which it never was. Device build now
succeeds on the free personal team.

src/ and public/ are untouched — the git-panel endpoints already existed
server-side; iOS simply never consumed them.

# Conflicts:
#	android/.gitignore
#	android/README.md
#	android/api-client/src/main/kotlin/wang/yaojia/webterm/api/routes/Endpoints.kt
#	android/app/src/main/java/wang/yaojia/webterm/screens/PairingScreen.kt
#	android/app/src/main/java/wang/yaojia/webterm/viewmodels/PairingViewModel.kt
#	android/app/src/main/java/wang/yaojia/webterm/wiring/AppEnvironment.kt
#	android/transport-okhttp/src/main/kotlin/wang/yaojia/webterm/transport/OkHttpClientFactory.kt
#	docs/PROGRESS_LOG.md
This commit is contained in:
Yaojia Wang
2026-07-30 18:12:03 +02:00
174 changed files with 22184 additions and 666 deletions

View File

@@ -20,6 +20,7 @@ import wang.yaojia.webterm.wire.TermTransport
import wang.yaojia.webterm.wire.TimelineEvent
import wang.yaojia.webterm.wire.TransportConnection
import wang.yaojia.webterm.wire.Tunables
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
/**
* The session lifecycle state machine (A14, plan §5 / §6.6). The Android analogue of the iOS
@@ -51,6 +52,9 @@ import wang.yaojia.webterm.wire.Tunables
* frame would be dropped rather than emitted onto a newer generation.
* - **oversized replay is terminal:** a frame past [maxFrameBytes] is the non-retryable
* [FailureReason.REPLAY_TOO_LARGE] — distinct from a retryable disconnect (never fed to backoff).
* - **a 401 upgrade is terminal:** an [UnauthorizedUpgradeException] out of the dial (missing/invalid
* access token, or a foreign Origin) is the non-retryable [FailureReason.UNAUTHORIZED] — it is
* permanent until the user fixes configuration, so it NEVER enters the back-off ladder.
*/
public class SessionEngine(
private val transport: TermTransport,
@@ -76,6 +80,19 @@ public class SessionEngine(
/** One live connection plus its optional ping capability (null = plain [TermTransport]). */
private class OpenConn(val connection: TransportConnection, val pinger: ConnectionPinger?)
/**
* Outcome of one dial. A dial failure is retryable ([Retryable]) EXCEPT an upgrade 401
* ([Unauthorized]), which is permanent until the user fixes the access token / Origin allow-list —
* so it must never be fed to the back-off ladder (see [FailureReason.UNAUTHORIZED]).
*/
private sealed interface DialResult {
class Open(val conn: OpenConn) : DialResult
data object Retryable : DialResult
data object Unauthorized : DialResult
}
private val eventsChannel = Channel<SessionEvent>(Channel.UNLIMITED)
/** Engine→UI event stream (single consumer; the App's EventBus fans out per R10). */
@@ -157,7 +174,14 @@ public class SessionEngine(
}
private suspend fun runOneConnection(gen: Int): EndCause {
val open = dialOrNull() ?: return EndCause.Disconnected
val open = when (val dial = dial()) {
DialResult.Retryable -> return EndCause.Disconnected
DialResult.Unauthorized -> {
emit(Connection(ConnectionState.Failed(FailureReason.UNAUTHORIZED)))
return EndCause.Terminal // no back-off: a 401 is permanent until reconfigured
}
is DialResult.Open -> dial.conn
}
if (closed) return abandon(open) // close() landed during dial → detach, never publish
if (!attachFirst(open)) return EndCause.Disconnected
if (closed) return abandon(open) // close() landed during attach → detach, never publish
@@ -184,20 +208,37 @@ public class SessionEngine(
return EndCause.Terminal
}
private suspend fun dialOrNull(): OpenConn? =
private suspend fun dial(): DialResult =
try {
if (transport is PingableTermTransport) {
val pingable = transport.connectPingable(endpoint)
OpenConn(pingable.connection, pingable.pinger)
DialResult.Open(OpenConn(pingable.connection, pingable.pinger))
} else {
OpenConn(transport.connect(endpoint), null)
DialResult.Open(OpenConn(transport.connect(endpoint), null))
}
} catch (e: CancellationException) {
throw e
} catch (_: Throwable) {
null // connect-time failure → retryable
} catch (error: Throwable) {
// A 401 upgrade is the ONE non-retryable dial failure; everything else backs off.
if (isUnauthorized(error)) DialResult.Unauthorized else DialResult.Retryable
}
/**
* True iff [error] (or a bounded, cycle-guarded walk of its causes) is the typed 401-upgrade
* rejection. The cause walk matters because a transport may wrap it in its own `IOException`.
*/
private fun isUnauthorized(error: Throwable): Boolean {
var current: Throwable? = error
var depth = 0
while (current != null && depth < MAX_CAUSE_DEPTH) {
if (current is UnauthorizedUpgradeException) return true
if (current === current.cause) return false // defensive: never loop on a self-cause
current = current.cause
depth++
}
return false
}
/** attach-first (+ replay [lastDims]) BEFORE publishing the connection. */
private suspend fun attachFirst(open: OpenConn): Boolean =
try {
@@ -412,5 +453,8 @@ public class SessionEngine(
public companion object {
/** Max [AwayDigest.recent] entries carried in the once-per-reconnect digest. */
public const val DEFAULT_DIGEST_LIMIT: Int = 20
/** Bound on the `cause` walk in [isUnauthorized] (never trust an error graph not to cycle). */
private const val MAX_CAUSE_DEPTH: Int = 8
}
}

View File

@@ -68,4 +68,14 @@ public enum class FailureReason {
* back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap.
*/
REPLAY_TOO_LARGE,
/**
* The server answered the WS upgrade with **HTTP 401**
* ([wang.yaojia.webterm.wire.UnauthorizedUpgradeException]) — the host's access token
* (`WEBTERM_TOKEN`) is missing/wrong, or our `Origin` is not on its allow-list. Both are
* permanent until the user fixes configuration, so — exactly like [REPLAY_TOO_LARGE] — the
* engine goes terminal and NEVER enters the back-off ladder (a retry storm against a 401 is
* pointless and looks like an attack). UI copy: go fix / re-enter the access token.
*/
UNAUTHORIZED,
}

View File

@@ -0,0 +1,110 @@
package wang.yaojia.webterm.session
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeTermTransport
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.UnauthorizedUpgradeException
import java.io.IOException
import kotlin.time.Duration.Companion.seconds
/**
* B5 · a **401 on the WS upgrade is TERMINAL** (ios-completion §1.1): the engine emits
* `Failed(UNAUTHORIZED)` and stops — it must NEVER enter the reconnect back-off ladder, because a
* missing/invalid access token (or a foreign Origin) 401s deterministically forever. Same treatment as
* `REPLAY_TOO_LARGE`.
*
* Driven with the frozen [FakeTermTransport] double under `runTest` virtual time (`runCurrent` /
* `advanceTimeBy`, matching `SessionEngineTest`) — no OkHttp, no host, no wall-clock waits.
*/
class SessionEngineUnauthorizedTest {
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://10.0.0.5:3000"))
private fun TestScope.newEngine(transport: FakeTermTransport): SessionEngine =
SessionEngine(transport = transport, endpoint = endpoint(), scope = backgroundScope)
/** Live-updating sink of everything the engine emits (single consumer, as in SessionEngineTest). */
private fun TestScope.collectEvents(engine: SessionEngine): List<SessionEvent> {
val out = mutableListOf<SessionEvent>()
backgroundScope.launch { engine.events.collect { out += it } }
return out
}
private fun List<SessionEvent>.connectionStates(): List<ConnectionState> =
filterIsInstance<Connection>().map { it.state }
@Test
fun `a 401 upgrade ends the engine terminally with UNAUTHORIZED and never dials again`() = runTest {
// Arrange: every dial would be rejected with the typed 401 (the server 401s until a token is stored).
val transport = FakeTermTransport()
repeat(3) { transport.scriptConnectFailure(UnauthorizedUpgradeException()) }
val engine = newEngine(transport)
val events = collectEvents(engine)
// Act
engine.start()
testScheduler.runCurrent()
testScheduler.advanceTimeBy(60.seconds) // a back-off ladder, if entered, would have fired by now
testScheduler.runCurrent()
// Assert: exactly ONE dial, a terminal Failed(UNAUTHORIZED), and no Reconnecting event at all.
assertEquals(1, transport.connectAttempts.size, "a 401 must not be retried")
assertEquals(
listOf(ConnectionState.Connecting, ConnectionState.Failed(FailureReason.UNAUTHORIZED)),
events.connectionStates(),
"a terminal 401 must never enter the back-off ladder",
)
}
@Test
fun `an ordinary transport failure still reconnects with back-off`() = runTest {
// Regression lock: only the typed 401 is terminal — a plain IOException stays retryable.
val transport = FakeTermTransport()
transport.scriptConnectFailure(IOException("connection refused"))
val engine = newEngine(transport)
val events = collectEvents(engine)
engine.start()
testScheduler.runCurrent()
testScheduler.advanceTimeBy(1.seconds) // first rung of the ladder
testScheduler.runCurrent()
assertEquals(2, transport.connectAttempts.size, "a retryable failure must redial")
assertTrue(
events.connectionStates().any { it is ConnectionState.Reconnecting },
"a retryable failure must announce the back-off",
)
assertTrue(
events.connectionStates().none { it is ConnectionState.Failed },
"a retryable failure must not be reported as a terminal failure",
)
engine.close()
testScheduler.runCurrent()
}
@Test
fun `a 401 wrapped as a cause of the transport error is still terminal`() = runTest {
// OkHttp hands back its own IOException; the transport wraps the 401 so the CAUSE carries it.
val transport = FakeTermTransport()
transport.scriptConnectFailure(IOException("upgrade failed", UnauthorizedUpgradeException()))
val engine = newEngine(transport)
val events = collectEvents(engine)
engine.start()
testScheduler.runCurrent()
testScheduler.advanceTimeBy(60.seconds)
testScheduler.runCurrent()
assertEquals(1, transport.connectAttempts.size)
assertEquals(
FailureReason.UNAUTHORIZED,
events.connectionStates().filterIsInstance<ConnectionState.Failed>().single().reason,
)
}
}