feat(android): access-token support — same frozen contract as iOS

Android could not connect at all once the server set WEBTERM_TOKEN. Hand-written
Cookie header on every request and on the WS upgrade (no CookieJar, matching the
frozen decision), POST /auth pairing probe, Keystore-backed storage, and a 401
upgrade as a terminal state with no reconnect loop.
This commit is contained in:
Yaojia Wang
2026-07-30 12:45:27 +02:00
parent a5fa843f00
commit 9114630c3a
42 changed files with 2419 additions and 51 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 {
@@ -411,5 +452,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,
)
}
}