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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user