fix(android): the ThumbnailPipeline bugs its skipped review would have caught

This file's cross-review was explicitly skipped when it was written —
PROGRESS_ANDROID.md says so — and it had no production call sites until this
session, so nothing had ever reviewed OR executed it. Wiring it to the home
screen finally made an adversarial concurrency review worthwhile. It confirmed
the hard parts are right (the check-then-insert dedup critical section,
withPermit's exactly-once release with the fallback outside it, the
NonCancellable finally, and rethrowing CancellationException) and then found
five real defects. Every premise was re-verified from the termux v0.118.0
bytecode before fixing.

1. Thumbnails drew DUPLICATED ROWS. TerminalBuffer.externalToInternalRow is
   `(mScreenFirstRow + row) % mTotalRows` and mTotalRows is the constructor's
   transcriptRows, so passing TERMINAL_TRANSCRIPT_ROWS_MIN (100) while rows
   went up to 200 aliased external rows 100..199 onto 0..99 — and left the
   emulator's scroll state invalid during append(). A pure ThumbnailGrid now
   derives transcriptRows = max(rows, MIN).

2. A legitimately-sized session was LMK bait. cols/rows are server-supplied
   and the protocol validates resize only to [1,1000], so a 1000x1000 session
   forced 2800x2800 ARGB_8888 = 31.4 MB, twice over for two permits, every
   poll tick. Rather than clamp the grid — which would CROP a real screen —
   the cell size is now derived from the target, shrinking the font's own cell
   by an exact integer fraction with the aspect preserved. Any geometry in
   1..1000 now yields at most 480x480 = 900 KiB; a real 161x50 goes from
   3.1 MB to 322 KiB.

3. Two stalled fetches wedged the feature PERMANENTLY. There is no OkHttp
   callTimeout (the per-socket read timeout is reset by every trickled byte)
   and the render is deliberately uncancellable by any caller, so two
   slow-trickle previews held both permits until close() and no thumbnail
   rendered again. A withTimeout now sits inside withPermit, budgeting the
   work rather than the permit wait. The subtlety that makes this correct:
   TimeoutCancellationException IS a CancellationException, so it is caught
   BEFORE that branch and routed to the placeholder — otherwise a slow host
   would still kill the pipeline instead of degrading.

4. The in-flight entry was leakable, contradicting a comment that claimed it
   never was. `scope.async` on an already-cancelled scope never runs its body,
   so the finally never ran: a post-close() request left a dead Deferred that
   every later request awaited forever, and inFlight grew unbounded. Now
   guarded inside the mutex, and the comment names the one path that genuinely
   cannot release (where the map dies with the pipeline anyway).

5. Obsolete renders did guaranteed wasted work. lastOutputAt advances on every
   PTY byte and the list polls every 5s, so an active session mints a new key
   each tick; the row cancelled the old caller but the old render ran a full
   GET plus a full raster whose bitmap was cached under a key nothing would
   request again — occupying both permits ahead of the key actually on screen,
   and growing without bound once render latency exceeded arrival rate. A
   newer key now cancels the older in-flight render for that session.

Also made two false statements true rather than leaving them: retainOnly now
prunes under the same monitor publish() takes, so a render landing after the
prune cannot resurrect a killed session's bitmap; and PreviewCap's KDoc no
longer claims to prevent an oversized-body OOM (the body is already buffered
and parsed by then) nor to "mirror the server's cap" (the server defaults to
24 KiB, not 256 KiB, and an operator can raise it unbounded).

RED was observed for all four behavioural fixes before implementing.

Verified: ./gradlew test :app:testDebugUnitTest :app:assembleDebug koverVerify
-> BUILD SUCCESSFUL, 901 JVM tests, 0 failures (893 -> 901). The agent
distrusted a FROM-CACHE green, forced a real execution, and confirmed the code
reached the packaged APK by scanning its dex.
This commit is contained in:
Yaojia Wang
2026-07-30 12:09:25 +02:00
parent 980dbaa928
commit b09b90e5bb
5 changed files with 640 additions and 77 deletions

3
android/.gitignore vendored
View File

@@ -40,3 +40,6 @@ service-account*.json
# Kover / test reports
**/kover/
# Kotlin compiler session/error scratch (KGP 2.x writes this at the project root)
.kotlin/

View File

@@ -118,8 +118,10 @@ public fun SessionsHome(
val thumbnails: SessionThumbnails? = remember(pipeline) {
pipeline?.let { live -> SessionThumbnails { session -> live.thumbnail(session) } }
}
// Release bitmaps for sessions that are no longer live (killed/exited) and superseded lastOutputAt
// keys — a thumbnail must never outlive the session it depicts.
// Release bitmaps for sessions that are no longer live (killed/exited) and superseded lastOutputAt keys,
// so the cache only ever holds screens this list can still show. A render already in flight for a
// just-killed session cannot re-insert its bitmap behind this prune (ThumbnailPipeline.retainOnly closes
// its publish gate); a bitmap outlives its session only for as long as a row keeps asking for it.
LaunchedEffect(pipeline, state.rows) {
pipeline?.retainOnly(state.rows.map { it.info })
}

View File

@@ -24,16 +24,30 @@ import kotlin.math.ceil
*
* `TerminalRenderer`'s own `mFontWidth`/`mFontLineSpacing` are package-private, so this computes its own
* cell metrics from the [Paint] (`ceil(measureText("X"))` and the ascent-to-descent span), exactly as
* `TerminalRenderer` derives them.
* `TerminalRenderer` derives them — as a TEMPLATE that [ThumbnailBudget.cellMetrics] then shrinks to the
* thumbnail budget, so the raster is born at roughly the size the card displays.
*
* ### Two bounds, both server-input driven (both are pure and JVM-tested)
* - [ThumbnailGrid] clamps `cols`/`rows` (SERVER-supplied; `src/protocol.ts` validates resize only to
* 1..1000) and — load-bearing — derives the emulator's `transcriptRows` so it is never below the screen
* height. Termux's `TerminalBuffer.externalToInternalRow` is `(mScreenFirstRow + row) % mTotalRows`, so
* a `transcriptRows < rows` buffer ALIASES external rows onto each other and the thumbnail draws
* duplicated rows (this was the bug: a fixed `TERMINAL_TRANSCRIPT_ROWS_MIN` = 100 against `rows` ≤ 200).
* - [ThumbnailBudget.cellMetrics] shrinks the CELL so the full-resolution intermediate can never exceed
* [ThumbnailBudget.MAX_WIDTH_PX] × [ThumbnailBudget.MAX_HEIGHT_PX] (≤ 900 KiB ARGB_8888) — previously a
* legitimately-sized 1000×1000 session forced a 31 MB allocation per render, ×2 concurrent permits, on
* every 5 s poll tick, which degrades safely (OOM is caught) but invites an LMK kill of the whole app.
*
* android.graphics is stubbed in JVM unit tests, so this class is exercised by device QA (plan §7,
* `:terminal-view` instrumented "thumbnail rasterisation non-null"); the pipeline policy around it is
* JVM-tested via the [ThumbnailRasterizer] seam.
* JVM-tested via the [ThumbnailRasterizer] seam, and its two size bounds via [ThumbnailGrid] /
* [ThumbnailBudget] (pure) plus a real off-screen [TerminalEmulator] (pure Java — no android.graphics).
*/
public class CanvasThumbnailRasterizer(
fontSizePx: Float = DEFAULT_FONT_SIZE_PX,
private val fontSizePx: Float = DEFAULT_FONT_SIZE_PX,
typeface: Typeface = Typeface.MONOSPACE,
private val maxWidthPx: Int = ThumbnailBudget.MAX_WIDTH_PX,
private val maxHeightPx: Int = ThumbnailBudget.MAX_HEIGHT_PX,
) : ThumbnailRasterizer {
/**
@@ -46,65 +60,93 @@ public class CanvasThumbnailRasterizer(
this.typeface = typeface
textSize = fontSizePx
}
private val cellWidth: Int = ceil(metricsPaint.measureText("X").toDouble()).toInt().coerceAtLeast(1)
private val cellHeight: Int =
/** The UNSCALED cell size at [fontSizePx]; [ThumbnailBudget.cellMetrics] shrinks it per render. */
private val templateCellWidth: Int = ceil(metricsPaint.measureText("X").toDouble()).toInt().coerceAtLeast(1)
private val templateCellHeight: Int =
(metricsPaint.fontMetricsInt.descent - metricsPaint.fontMetricsInt.ascent).coerceAtLeast(1)
private val baselineOffset: Int = -metricsPaint.fontMetricsInt.ascent
private val templateBaseline: Int = -metricsPaint.fontMetricsInt.ascent
override fun rasterize(preview: SessionPreview): Bitmap {
val cols = preview.cols.coerceIn(1, MAX_COLS)
val rows = preview.rows.coerceIn(1, MAX_ROWS)
val emulator = TerminalEmulator(NoOpOutput, cols, rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN, NoOpClient)
val grid = ThumbnailGrid.of(preview.cols, preview.rows)
val cell = ThumbnailBudget.cellMetrics(
cols = grid.cols,
rows = grid.rows,
templateWidth = templateCellWidth,
templateHeight = templateCellHeight,
maxWidth = maxWidthPx,
maxHeight = maxHeightPx,
)
val emulator = TerminalEmulator(NoOpOutput, grid.cols, grid.rows, grid.transcriptRows, NoOpClient)
val bytes = preview.data.toByteArray(Charsets.UTF_8)
emulator.append(bytes, bytes.size)
val paint = Paint(metricsPaint) // per-render Paint see [metricsPaint]
val full = Bitmap.createBitmap(cols * cellWidth, rows * cellHeight, Bitmap.Config.ARGB_8888)
// Per-render Paint (see [metricsPaint]); its text size follows the shrunken cell, so a glyph never
// smears across the neighbouring cells of a downsized grid.
val paint = Paint(metricsPaint).apply { textSize = fontSizePx * cell.textScale }
val geometry = RenderGeometry(
cols = grid.cols,
cellWidth = cell.width,
cellHeight = cell.height,
baseline = (templateBaseline * cell.textScale).toInt().coerceIn(1, cell.height),
)
val full = Bitmap.createBitmap(
grid.cols * cell.width,
grid.rows * cell.height,
Bitmap.Config.ARGB_8888,
)
val canvas = Canvas(full)
val palette = emulator.mColors.mCurrentColors
val defaultBg = palette[TextStyle.COLOR_INDEX_BACKGROUND]
canvas.drawColor(defaultBg)
for (row in 0 until rows) {
drawRow(canvas, paint, emulator, row, cols, palette, defaultBg)
for (row in 0 until grid.rows) {
drawRow(canvas, paint, emulator, row, geometry, palette, defaultBg)
}
return downscaleToBudget(full)
}
override fun placeholder(): Bitmap {
val bitmap = Bitmap.createBitmap(cellWidth, cellHeight, Bitmap.Config.ARGB_8888)
val bitmap = Bitmap.createBitmap(templateCellWidth, templateCellHeight, Bitmap.Config.ARGB_8888)
bitmap.eraseColor(PLACEHOLDER_COLOR)
return bitmap
}
/**
* Shrink a full-resolution raster to the thumbnail budget. A 161×50 session rasterises to ~1127×700 =
* **3.1 MiB** ARGB_8888 — three of those alone exceed the pipeline's cache budget and would thrash the
* LRU on a list of sessions, re-rendering on every scroll. The card is ~96dp wide, so the full raster
* is downscaled once, here, and only the small bitmap is ever cached; the oversized source is recycled
* immediately.
* Safety net over [ThumbnailBudget.cellMetrics]: the cell shrink already keeps the raster inside the
* budget, so this is normally a no-op (`scaledSize` returns `null` ⇒ no second allocation). It stays
* because it is the ONE place that bounds a bitmap that arrived larger than the budget by any route,
* and it recycles the oversized source immediately rather than caching multiple MiB per session.
*/
private fun downscaleToBudget(full: Bitmap): Bitmap {
val target = ThumbnailBudget.scaledSize(full.width, full.height, maxWidthPx) ?: return full
val target = ThumbnailBudget.scaledSize(full.width, full.height, maxWidthPx, maxHeightPx) ?: return full
val scaled = Bitmap.createScaledBitmap(full, target.width, target.height, true)
if (scaled !== full) full.recycle()
return scaled
}
/** Immutable per-render draw geometry (the grid width plus the px size of one cell). */
private data class RenderGeometry(
val cols: Int,
val cellWidth: Int,
val cellHeight: Int,
val baseline: Int,
)
private fun drawRow(
canvas: Canvas,
paint: Paint,
emulator: TerminalEmulator,
row: Int,
cols: Int,
geometry: RenderGeometry,
palette: IntArray,
defaultBg: Int,
) {
val screen = emulator.screen
val line = screen.allocateFullLineIfNecessary(screen.externalToInternalRow(row))
val text = line.mText
val top = (row * cellHeight).toFloat()
val baseline = (row * cellHeight + baselineOffset).toFloat()
for (col in 0 until cols) {
val top = (row * geometry.cellHeight).toFloat()
val baseline = (row * geometry.cellHeight + geometry.baseline).toFloat()
for (col in 0 until geometry.cols) {
val style = line.getStyle(col)
val effect = TextStyle.decodeEffect(style)
val bold = (effect and (TextStyle.CHARACTER_ATTRIBUTE_BOLD or TextStyle.CHARACTER_ATTRIBUTE_BLINK)) != 0
@@ -114,11 +156,11 @@ public class CanvasThumbnailRasterizer(
var bg = resolveColor(TextStyle.decodeBackColor(style), palette, boldBright = false)
if (inverse) { val swap = fg; fg = bg; bg = swap }
val left = (col * cellWidth).toFloat()
val left = (col * geometry.cellWidth).toFloat()
if (bg != defaultBg) {
paint.color = bg
paint.style = Paint.Style.FILL
canvas.drawRect(left, top, left + cellWidth, top + cellHeight, paint)
canvas.drawRect(left, top, left + geometry.cellWidth, top + geometry.cellHeight, paint)
}
val start = line.findStartOfColumn(col)
val end = line.findStartOfColumn(col + 1)
@@ -142,13 +184,9 @@ public class CanvasThumbnailRasterizer(
}
public companion object {
/** Default glyph size for a thumbnail (px). Small — the card downscales anyway. */
/** Default glyph size for a thumbnail (px). Small — the cell shrink and the card scale it anyway. */
public const val DEFAULT_FONT_SIZE_PX: Float = 12f
/** Clamp the off-screen grid so a rogue preview geometry can't allocate an enormous bitmap. */
public const val MAX_COLS: Int = 400
public const val MAX_ROWS: Int = 200
/** Opaque dark-grey placeholder for the fetch/render-failure fallback. */
public const val PLACEHOLDER_COLOR: Int = -0xdfdfe0 // 0xFF202020
@@ -158,29 +196,144 @@ public class CanvasThumbnailRasterizer(
}
/**
* The pure raster-size budget for a preview thumbnail: how large a rasterised terminal screen may be kept.
* Split out of [CanvasThumbnailRasterizer] so the arithmetic is JVM-unit-testable (android.graphics is
* stubbed off-device), while the `Bitmap`/`Canvas` work stays device-QA.
* The off-screen emulator's GRID, derived from a preview's SERVER-supplied geometry. Pure, so the two
* things that are easy to get catastrophically wrong are JVM-testable (android.graphics is stubbed
* off-device).
*
* `cols`/`rows` come off the wire: `src/protocol.ts` validates a resize only to 1..1000, so a preview may
* legitimately claim 1000×1000 and the grid must be clamped before anything is allocated from it.
*/
public object ThumbnailGrid {
/**
* Grid clamp, tied to the raster budget rather than picked by feel: one cell can never be narrower or
* shorter than 1 px, so a grid wider than [ThumbnailBudget.MAX_WIDTH_PX] (or taller than
* [ThumbnailBudget.MAX_HEIGHT_PX]) could not fit the budget at ANY cell size. Everything a real
* terminal reports — 80×24 up to a 4K-monitor 400×120 — is far inside this, so the clamp only ever
* crops geometry no terminal actually has.
*/
public const val MAX_COLS: Int = ThumbnailBudget.MAX_WIDTH_PX
public const val MAX_ROWS: Int = ThumbnailBudget.MAX_HEIGHT_PX
/** The clamped screen grid plus the emulator's scrollback depth. [transcriptRows] is always ≥ [rows]. */
public data class Grid(val cols: Int, val rows: Int, val transcriptRows: Int)
/**
* Clamp [cols]/[rows] into the budget and pick the emulator's `transcriptRows`.
*
* **`transcriptRows` MUST be ≥ `rows`.** Termux's `TerminalBuffer` keeps `mTotalRows = transcriptRows`
* and maps `externalToInternalRow(row) = (mScreenFirstRow + row) % mTotalRows`, so a buffer with fewer
* total rows than screen rows silently aliases external row *n* onto *n mTotalRows* — the thumbnail
* would draw duplicated rows and the emulator would scroll against invalid state during `append`.
* The floor is `TERMINAL_TRANSCRIPT_ROWS_MIN` because `TerminalEmulator` itself rejects anything below
* it (falling back to its 2000-row default), which is more scrollback than a preview ever needs.
*/
public fun of(cols: Int, rows: Int): Grid {
val clampedRows = rows.coerceIn(1, MAX_ROWS)
return Grid(
cols = cols.coerceIn(1, MAX_COLS),
rows = clampedRows,
transcriptRows = clampedRows.coerceAtLeast(TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN),
)
}
}
/**
* The pure raster-size budget for a preview thumbnail: how large a rasterised terminal screen may be, both
* as drawn ([cellMetrics]) and as kept ([scaledSize]). Split out of [CanvasThumbnailRasterizer] so the
* arithmetic is JVM-unit-testable (android.graphics is stubbed off-device), while the `Bitmap`/`Canvas`
* work stays device-QA.
*
* All of it is integer arithmetic on purpose: a float `floor` could round a cell size *up* past the bound
* it is supposed to enforce, and the bound is the thing standing between a rogue geometry and an LMK kill.
*/
public object ThumbnailBudget {
/**
* Max cached thumbnail width (px). The row tile is ~96dp (≈288px at 3× density) and drawn with
* `ContentScale.Crop`, so 480px keeps it crisp on any density while capping one cached bitmap at a few
* hundred KiB instead of the multi-MiB full-resolution raster.
* Max thumbnail width (px). The row tile is ~96dp (≈288px at 3× density) and drawn with
* `ContentScale.Crop`, so 480px keeps it crisp on any density.
*/
public const val MAX_WIDTH_PX: Int = 480
/**
* Max thumbnail height (px). A terminal screen is wider than tall (80×24 lands near 480×288), so this
* only binds a tall/narrow session — but it MUST exist: with a width-only bound a 20×200 session was
* unbounded in height (it rasterised to 140×2800 = 1.5 MiB and was cached at full size, because its
* width was already inside the budget).
*/
public const val MAX_HEIGHT_PX: Int = 480
/** A raster target size in px. */
public data class RasterSize(val width: Int, val height: Int)
/** The px size of ONE cell, plus the text-size factor that keeps glyphs inside it. */
public data class CellMetrics(val width: Int, val height: Int, val textScale: Float)
/**
* The size [width]×[height] should be scaled to, or `null` when it already fits [maxWidth] (draw as
* rendered — no extra allocation). Aspect ratio is preserved and the height never collapses to 0.
* The cell size to draw [cols]×[rows] at, shrunk from the font's own [templateWidth]×[templateHeight]
* so the whole raster fits [maxWidth]×[maxHeight] — the screen is never cropped to fit, the cells are.
*
* Guarantees, for `cols ≤ maxWidth` and `rows ≤ maxHeight` (which [ThumbnailGrid] enforces):
* - `cols * width ≤ maxWidth` and `rows * height ≤ maxHeight` — so the ARGB_8888 intermediate is at
* most `maxWidth * maxHeight * 4` bytes (900 KiB at the defaults) whatever the server reports;
* - the cell aspect ratio is preserved (both axes take the SAME scale factor), so text keeps its
* shape instead of being squeezed on one axis;
* - never upscales (the factor is capped at 1) and never returns a 0-px cell.
*/
public fun scaledSize(width: Int, height: Int, maxWidth: Int = MAX_WIDTH_PX): RasterSize? {
if (width <= 0 || height <= 0 || width <= maxWidth) return null
val scaledHeight = (height.toLong() * maxWidth / width).toInt().coerceAtLeast(1)
return RasterSize(width = maxWidth, height = scaledHeight)
public fun cellMetrics(
cols: Int,
rows: Int,
templateWidth: Int,
templateHeight: Int,
maxWidth: Int = MAX_WIDTH_PX,
maxHeight: Int = MAX_HEIGHT_PX,
): CellMetrics {
val cellWidth = templateWidth.coerceAtLeast(1)
val cellHeight = templateHeight.coerceAtLeast(1)
val scale = fitScale(
width = cols.coerceAtLeast(1).toLong() * cellWidth,
height = rows.coerceAtLeast(1).toLong() * cellHeight,
maxWidth = maxWidth,
maxHeight = maxHeight,
)
return CellMetrics(
width = scale.scaled(cellWidth),
height = scale.scaled(cellHeight),
textScale = scale.factor,
)
}
/**
* The size [width]×[height] should be scaled to, or `null` when it already fits [maxWidth]×[maxHeight]
* (draw as rendered — no extra allocation). Aspect ratio is preserved (the tighter axis wins) and
* neither side ever collapses to 0.
*/
public fun scaledSize(
width: Int,
height: Int,
maxWidth: Int = MAX_WIDTH_PX,
maxHeight: Int = MAX_HEIGHT_PX,
): RasterSize? {
if (width <= 0 || height <= 0) return null
if (width <= maxWidth && height <= maxHeight) return null
val scale = fitScale(width.toLong(), height.toLong(), maxWidth, maxHeight)
return RasterSize(width = scale.scaled(width), height = scale.scaled(height))
}
/**
* The largest factor ≤ 1 that fits [width]×[height] inside [maxWidth]×[maxHeight], as an EXACT fraction
* (see the class note on integer arithmetic — a float would round a size up past the bound it enforces).
*/
private fun fitScale(width: Long, height: Long, maxWidth: Int, maxHeight: Int): Scale = when {
width <= maxWidth && height <= maxHeight -> Scale(1L, 1L)
maxWidth.toLong() * height <= maxHeight.toLong() * width -> Scale(maxWidth.toLong(), width)
else -> Scale(maxHeight.toLong(), height)
}
/** A shrink factor `num/den` (≤ 1), applied to px sizes by exact integer division. */
private data class Scale(private val num: Long, private val den: Long) {
/** [px] shrunk by this factor, floored, never below 1 px. */
fun scaled(px: Int): Int = (px * num / den).toInt().coerceAtLeast(1)
val factor: Float get() = num.toFloat() / den.toFloat()
}
}

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.currentCoroutineContext
@@ -17,10 +18,12 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.api.models.SessionPreview
import wang.yaojia.webterm.api.routes.ApiClient
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/**
* Off-screen terminal-preview rasterisation for the session-list / manage-page thumbnails (plan §6.7).
@@ -32,7 +35,7 @@ import java.util.UUID
* [ThumbnailRasterizer] seam so this policy (cache, concurrency, dedup, failure fallback) stays
* JVM-unit-testable while the pixel raster is verified on-device (android.graphics is stubbed off-device).
*
* ### The four policies (plan §6.7)
* ### The policies (plan §6.7)
* - **Cache** — an [LruCache] keyed on `(sessionId, lastOutputAt)`. `lastOutputAt` is the server's
* last-PTY-output timestamp, so an UNCHANGED `lastOutputAt` means the screen is byte-identical and we
* return the cached bitmap WITHOUT re-fetching or re-rendering. Keying goes through the [BitmapCache]
@@ -41,8 +44,16 @@ import java.util.UUID
* grid of many cards never spawns dozens of concurrent emulators / large-bitmap allocations.
* - **In-flight dedup** — a `Map<Key, Deferred<Bitmap>>` under a [Mutex]: a second request for a key
* already rendering AWAITS the first's [Deferred] instead of starting a duplicate fetch/render.
* - **Failure fallback** — any fetch/render failure caches the rasterizer's PLACEHOLDER bitmap under the
* same key, so a broken/oversized session is not retried in a hot scroll loop.
* - **Supersede (coalesce per session)** — `lastOutputAt` advances on every PTY output and the list polls
* every 5 s, so an ACTIVE session mints a new key on every tick. Only the newest key per session id may
* be in flight: a newer key CANCELS the older render, which both frees its permit for the key the row is
* actually showing (otherwise obsolete work queues ahead of it and the tile visibly lags) and stops the
* unbounded growth of `inFlight` once per-render latency exceeds the 5 s arrival rate on a slow link.
* - **Timeout** — the fetch+render of one thumbnail is bounded (see [DEFAULT_RENDER_TIMEOUT_MS]). Without
* it two slow-trickle previews hold both permits forever (OkHttp sets no `callTimeout`, and its
* per-socket read timeout is reset by every trickled byte) and NO thumbnail renders again until [close].
* - **Failure fallback** — any fetch/render failure, timeout included, caches the rasterizer's PLACEHOLDER
* bitmap under the same key, so a broken/oversized session is not retried in a hot scroll loop.
*
* The render coroutine runs in this pipeline's own [scope] (not the caller's), so a caller that scrolls
* a card off-screen and cancels its collect never cancels a shared render other cards still await.
@@ -53,6 +64,7 @@ public class ThumbnailPipeline(
private val cache: BitmapCache = LruBitmapCache(DEFAULT_CACHE_BYTES),
dispatcher: kotlinx.coroutines.CoroutineDispatcher = Dispatchers.Default,
maxConcurrent: Int = MAX_CONCURRENT_RENDERS,
private val renderTimeoutMs: Long = DEFAULT_RENDER_TIMEOUT_MS,
) {
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
@@ -63,16 +75,31 @@ public class ThumbnailPipeline(
private val inFlight = HashMap<ThumbnailKey, Deferred<Bitmap?>>()
private val inFlightMutex = Mutex()
/**
* The newest key requested per session id — the supersede rule's state, and the cache-publish gate.
* Concurrent (not [inFlightMutex]-guarded) because [retainOnly] is a plain, non-suspending call from a
* `LaunchedEffect` and must be able to drop a killed session's entry without a coroutine.
*/
private val latestKeyBySession = ConcurrentHashMap<UUID, ThumbnailKey>()
/**
* Orders a cache PUBLISH against a concurrent [retainOnly] prune, so a render that finishes after its
* session was pruned cannot re-insert a bitmap the prune just dropped. Always the INNERMOST lock (never
* held across a suspension), so it cannot participate in a cycle with [inFlightMutex].
*/
private val publishLock = Any()
/**
* The cached (or freshly rendered) thumbnail for [session], or `null` when no bitmap at all could be
* produced. Returns the cached bitmap immediately when `(id, lastOutputAt)` is unchanged; otherwise
* fetches the preview over mTLS, rasterises off-screen, caches, and returns it.
*
* **Never throws for a fetch/render failure** — a fetch/raster failure caches the rasterizer's
* placeholder (no retry storm), and the residual case where even the placeholder cannot be allocated
* (a `Bitmap` OOM) degrades to `null` rather than an exception: the caller is a row's `LaunchedEffect`,
* so a throw would take down the composition. Only cancellation of the CALLER propagates; a render
* killed by [close] also surfaces as `null` (the caller is still alive — it just has no thumbnail).
* **Never throws for a fetch/render failure** — a fetch/raster failure or a timeout caches the
* rasterizer's placeholder (no retry storm), and the residual case where even the placeholder cannot be
* allocated (a `Bitmap` OOM) degrades to `null` rather than an exception: the caller is a row's
* `LaunchedEffect`, so a throw would take down the composition. Only cancellation of the CALLER
* propagates; a render killed by [close] or superseded by a newer key for the same session also
* surfaces as `null` (the caller is still alive — it just has no thumbnail for this key).
*/
public suspend fun thumbnail(session: LiveSessionInfo): Bitmap? {
val key = ThumbnailKey(session.id, session.lastOutputAt)
@@ -83,12 +110,17 @@ public class ThumbnailPipeline(
// Re-check under the lock: a render that finished between the fast path and here has already
// populated the cache and removed its in-flight entry.
cache.get(key)?.let { return it }
// A CLOSED pipeline must not insert: `scope.async` on a cancelled scope never runs the body, so
// the finally that frees the slot never runs and the key would keep a dead Deferred forever
// (every later request awaiting it for null, and `inFlight` growing by every distinct key).
if (!scope.isActive) return null
supersedeOlderRenders(key)
inFlight[key] ?: startRender(key).also { inFlight[key] = it }
}
return try {
deferred.await()
} catch (cancel: CancellationException) {
// Distinguish "the pipeline was closed under us" (→ no thumbnail) from "OUR caller was
// Distinguish "the render was closed/superseded under us" (→ no thumbnail) from "OUR caller was
// cancelled" (→ propagate, so the collector tears down as structured concurrency requires).
if (currentCoroutineContext().isActive) null else throw cancel
}
@@ -99,9 +131,19 @@ public class ThumbnailPipeline(
* superseded `lastOutputAt` keys — so the cache holds only what the chooser can still display. The
* [LruCache] byte budget already bounds worst-case memory; this returns the memory promptly instead of
* waiting for eviction pressure, and keeps dead sessions from occupying the budget at all.
*
* A render still in flight for a session that just disappeared can no longer re-insert its bitmap after
* this prune: dropping the session's [latestKeyBySession] entry closes the publish gate, and both sides
* run under [publishLock] so the check cannot straddle the prune. (A caller that KEEPS asking for a
* pruned session is a different thing and still caches — it asked.)
*/
public fun retainOnly(live: Collection<LiveSessionInfo>) {
cache.retainOnly(live.mapTo(HashSet()) { ThumbnailKey(it.id, it.lastOutputAt) })
val liveIds = live.mapTo(HashSet()) { it.id }
val liveKeys = live.mapTo(HashSet()) { ThumbnailKey(it.id, it.lastOutputAt) }
synchronized(publishLock) {
latestKeyBySession.keys.retainAll(liveIds)
cache.retainOnly(liveKeys)
}
}
/** Cancel all in-flight renders and release the pipeline scope (call from the owner's teardown). */
@@ -109,48 +151,111 @@ public class ThumbnailPipeline(
scope.cancel()
}
/** Test-only: live in-flight entries. A non-zero count after [close] would be a leaked key. */
internal suspend fun inFlightCount(): Int = inFlightMutex.withLock { inFlight.size }
/**
* Make [key] the session's newest key and abandon any older in-flight render for the SAME session — the
* supersede rule. Caller MUST hold [inFlightMutex]; `Deferred.cancel` does not suspend, so cancelling
* from inside the critical section is safe (the render's own `finally` takes the mutex afterwards).
*
* An out-of-order request for an OLDER screen than one already in flight is left alone: it still renders
* and returns a bitmap to its caller, but it does not become the newest key, so the publish gate keeps
* its result out of the cache instead of overwriting fresher work.
*/
private fun supersedeOlderRenders(key: ThumbnailKey) {
val previous = latestKeyBySession[key.sessionId]
if (previous == key) return
if (previous != null && depictsOlderScreen(key, previous)) return
latestKeyBySession[key.sessionId] = key
previous?.let { inFlight.remove(it)?.cancel() }
}
private fun startRender(key: ThumbnailKey): Deferred<Bitmap?> = scope.async {
var rendered: Bitmap? = null
try {
rendered = renderGuarded(key.sessionId)
rendered
} finally {
// ALWAYS release the in-flight slot — including on cancellation (scope closed) and on an
// unexpected throw. A retained entry would poison the key: every later request for it would
// await a dead Deferred forever. NonCancellable so the cleanup still runs while cancelling.
// Release the in-flight slot on EVERY path the body can leave by — normal return, cancellation
// (closed/superseded) and an unexpected throw — because a retained entry would poison the key:
// every later request for it would await a dead Deferred forever. NonCancellable so the cleanup
// still runs while cancelling. (The one path that cannot run this is a scope cancelled between
// the `isActive` check in [thumbnail] and `scope.async` itself, where the body never starts —
// the pipeline is closed by then and this whole map is discarded with it.)
withContext(NonCancellable) {
inFlightMutex.withLock {
// Publish under the same lock, so a concurrent [thumbnail] re-check sees the result the
// instant the key stops being in-flight. Success AND placeholder cache identically
// (§6.7 no-retry-storm); an unproducible bitmap caches nothing and may be retried.
rendered?.let { cache.put(key, it) }
publish(key, rendered)
inFlight.remove(key)
}
}
}
}
/**
* Cache [bitmap] under [key] unless the key is no longer the one the UI wants: superseded by newer
* output for the same session, or pruned by [retainOnly] because the session is gone. Caching either
* would spend the LRU budget on a bitmap nothing will ever request again.
*/
private fun publish(key: ThumbnailKey, bitmap: Bitmap?) {
if (bitmap == null) return
synchronized(publishLock) {
if (latestKeyBySession[key.sessionId] != key) return
cache.put(key, bitmap)
}
}
private suspend fun renderGuarded(sessionId: UUID): Bitmap? =
try {
renderGate.withPermit {
val preview = previewSource.fetch(sessionId)
rasterizer.rasterize(preview)
// The timeout is INSIDE the permit so it budgets the work, not the wait for a permit, and
// the catch/fallback is OUTSIDE it so no placeholder is ever allocated holding a permit.
withTimeout(renderTimeoutMs) {
val preview = previewSource.fetch(sessionId)
rasterizer.rasterize(preview)
}
}
} catch (timeout: TimeoutCancellationException) {
// MUST precede the CancellationException branch — a timeout IS one. Rethrowing it would turn a
// slow host into a permanently dead feature: the two permits are the only ones there are, and a
// render is deliberately not cancellable by any caller.
placeholderOrNull()
} catch (e: CancellationException) {
throw e // never swallow cooperative cancellation
throw e // never swallow cooperative cancellation (closed pipeline / superseded key)
} catch (t: Throwable) {
// Any fetch/render failure (network, oversized body, malformed escape) → cache a placeholder
// so the broken session isn't retried on every scroll frame (§6.7). If even the placeholder
// cannot be allocated, give up with null — never throw at a UI collector.
runCatching { rasterizer.placeholder() }.getOrNull()
placeholderOrNull()
}
/**
* The failure placeholder, or `null` when even that cannot be allocated (a `Bitmap` OOM). The failure
* itself is deliberately NOT logged: a preview failure can carry attacker-influenced text, and logcat
* must never see preview bytes (plan §8).
*/
private fun placeholderOrNull(): Bitmap? = runCatching { rasterizer.placeholder() }.getOrNull()
/** `true` when [key] depicts an older screen than [other] (a smaller server `lastOutputAt`). */
private fun depictsOlderScreen(key: ThumbnailKey, other: ThumbnailKey): Boolean =
(key.lastOutputAt ?: 0L) < (other.lastOutputAt ?: 0L)
public companion object {
/** Plan §6.7: the render/fetch fan-out is capped at 2 concurrent renders. */
public const val MAX_CONCURRENT_RENDERS: Int = 2
/** Default LRU budget for cached thumbnails (bytes). Small — thumbnails are transient UI cache. */
public const val DEFAULT_CACHE_BYTES: Int = 8 * 1024 * 1024
/**
* Budget for ONE fetch+render once it holds a permit. Generous next to a small preview GET over the
* shared client (OkHttp's own defaults are 10 s connect / 10 s per-read) — this is the liveness
* backstop for the trickle case those per-socket timeouts cannot catch, not a latency target.
*/
public const val DEFAULT_RENDER_TIMEOUT_MS: Long = 15_000L
}
}
@@ -238,8 +343,8 @@ public class LruBitmapCache(maxBytes: Int) : BitmapCache {
/**
* Production [PreviewSource] over an [ApiClient] (which sends through the shared mTLS `HttpTransport`, so
* tunnel-host previews traverse nginx). `GET /live-sessions/:id/preview` is a READ-ONLY route: it does NOT
* attach, register a client, or touch the session's idle clock. Re-caps the body at 256 KiB client-side —
* defence-in-depth over the server's own cap (plan §6.7 / §4.2).
* attach, register a client, or touch the session's idle clock. Bounds the emulator's input with
* [PreviewCap] (plan §6.7 / §4.2 — read its note on what that bound can and cannot do).
*
* [api] is a PROVIDER, not a client: resolving it resolves the shared `HttpTransport`, which builds the one
* `OkHttpClient` and first-touches the AndroidKeyStore/Tink mTLS material. That must never happen on the
@@ -253,15 +358,30 @@ public class ApiPreviewSource(api: () -> ApiClient) : PreviewSource {
PreviewCap.cap(apiClient.preview(sessionId))
}
/** Client-side preview-size guard (plan §6.7: cap data at 256 KiB). Pure — JVM-testable. */
/**
* Client-side bound on how much preview text reaches the off-screen emulator. Pure — JVM-testable.
*
* **What this is NOT:** it is not a defence against an oversized RESPONSE BODY. By the time it runs, the
* transport has already buffered the whole body into a `ByteArray` (`OkHttpHttpTransport` calls
* `body.bytes()`) and `ApiClient.preview` has decoded that into JSON and a `String` — so a rogue multi-MB
* body has already been allocated two or three times over, and capping here allocates one more copy. A real
* body bound has to live in the transport (an OkHttp response-body byte limit), which this class cannot
* reach; until then, the cap here only bounds the *downstream* cost: the VT parse in
* [TerminalEmulator][com.termux.terminal.TerminalEmulator]`.append` and the retained tail string.
*/
public object PreviewCap {
/** 256 KiB — mirrors the server's `GET /live-sessions/:id/preview` data cap. */
/**
* 256 KiB. This is the CLIENT's own ceiling, not a mirror of the server's: `src/config.ts` defaults
* `PREVIEW_BYTES` to **24 KiB** and lets an operator raise it with no upper bound, so this sits ~10× the
* default — big enough never to clip a normally-configured host's tail, small enough to keep the VT
* parse per poll tick bounded whatever a host is configured to (or lies about) sending.
*/
public const val MAX_PREVIEW_BYTES: Int = 256 * 1024
/**
* Cap [preview]'s UTF-8 byte length at [MAX_PREVIEW_BYTES], keeping the TAIL (the most recent screen)
* so a rogue/huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at
* worst mangles one leading glyph — harmless for a thumbnail.
* so a huge body can't drive an unbounded emulator append. A tail cut on a UTF-8 boundary at worst
* mangles one leading glyph — harmless for a thumbnail.
*/
public fun cap(preview: SessionPreview): SessionPreview {
val bytes = preview.data.toByteArray(Charsets.UTF_8)

View File

@@ -1,13 +1,18 @@
package wang.yaojia.webterm.wiring
import android.graphics.Bitmap
import com.termux.terminal.TerminalEmulator
import com.termux.terminal.TerminalOutput
import com.termux.terminal.TerminalSessionClient
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
@@ -26,15 +31,26 @@ import java.util.UUID
/**
* The [ThumbnailPipeline] policy (plan §6.7), driven at JVM speed with fakes so the cache/concurrency/
* dedup/failure logic is verified without android.graphics (the pixel raster lives behind the
* [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd).
* dedup/supersede/timeout/failure logic is verified without android.graphics (the pixel raster lives behind
* the [ThumbnailRasterizer] seam — [CanvasThumbnailRasterizer] — and is device-QA'd). The rasterizer's two
* SIZE decisions are pure ([ThumbnailGrid] / [ThumbnailBudget]) and are tested here too, one of them against
* a real off-screen [TerminalEmulator] (pure Java — the emulator needs no android.graphics).
*
* Proves:
* - an unchanged `(sessionId, lastOutputAt)` ⇒ cache hit, NO re-render; a changed `lastOutputAt` ⇒ render;
* - the `Semaphore(2)` caps concurrent fetch+render at 2;
* - two concurrent same-key requests SHARE one render (one fetch, one raster, one bitmap);
* - a NEWER key for a session supersedes the older in-flight render and frees its permit;
* - a stalled fetch times out, releases its permit and falls back to the placeholder (liveness);
* - a request after `close()` returns null and leaks no in-flight entry;
* - a render landing after its session was pruned does not re-enter the cache;
* - a fetch failure caches the placeholder and is NOT retried on the next request (no hot-loop storm);
* - [PreviewCap] caps the body at 256 KiB, keeping the tail.
* - the off-screen grid never aliases rows, and no server geometry can blow the raster budget;
* - [PreviewCap] caps the emulator's input at 256 KiB, keeping the tail.
*
* **Virtual-time note:** a render is now bounded by [ThumbnailPipeline.DEFAULT_RENDER_TIMEOUT_MS], so
* `advanceUntilIdle()` would advance past that timeout and cancel a deliberately-parked fetch. Tests that
* observe IN-FLIGHT state therefore use `runCurrent()` (run what is ready, advance no virtual time).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class ThumbnailPipelineTest {
@@ -103,6 +119,32 @@ class ThumbnailPipelineTest {
lastOutputAt = lastOutputAt,
)
/**
* A bare off-screen [TerminalEmulator] — the same construction [CanvasThumbnailRasterizer] uses, minus
* the `Canvas`. Termux's emulator/buffer are pure Java (no android.graphics), so the row-mapping
* geometry is verifiable at JVM speed; only the pixel draw is device-QA.
*/
private fun offScreenEmulator(cols: Int, rows: Int, transcriptRows: Int): TerminalEmulator =
TerminalEmulator(
mockk<TerminalOutput>(relaxed = true),
cols,
rows,
transcriptRows,
mockk<TerminalSessionClient>(relaxed = true),
)
private companion object {
/** Short virtual-time render budget so the timeout test doesn't encode the production value. */
const val TEST_RENDER_TIMEOUT_MS = 1_000L
/** Bytes per pixel of `Bitmap.Config.ARGB_8888` (the config the rasterizer allocates). */
const val ARGB_8888_BYTES = 4
/** A representative monospace cell at the rasterizer's 12px default (measured on-device). */
const val TEMPLATE_CELL_WIDTH_PX = 7
const val TEMPLATE_CELL_HEIGHT_PX = 14
}
// ── Tests ─────────────────────────────────────────────────────────────────────────────────
@Test
@@ -148,7 +190,7 @@ class ThumbnailPipelineTest {
)
val jobs = (0 until 5).map { launch { pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L)) } }
advanceUntilIdle()
runCurrent() // no virtual time passes — the parked fetches must not hit the render timeout
// Only 2 permits → only 2 fetches in flight; the other 3 are parked on the semaphore.
assertEquals(2, source.active)
@@ -178,7 +220,7 @@ class ThumbnailPipelineTest {
val a = async { pipeline.thumbnail(same) }
val b = async { pipeline.thumbnail(same) }
advanceUntilIdle()
runCurrent()
assertEquals(1, source.fetchStarts) // deduped: a single in-flight fetch
gate.complete(Unit)
@@ -262,12 +304,12 @@ class ThumbnailPipelineTest {
val s = session(UUID.randomUUID(), lastOutputAt = 3L)
val scrolledAway = launch { pipeline.thumbnail(s) }
advanceUntilIdle()
runCurrent()
scrolledAway.cancel()
advanceUntilIdle()
runCurrent()
val stillVisible = async { pipeline.thumbnail(s) }
advanceUntilIdle()
runCurrent()
gate.complete(Unit)
advanceUntilIdle()
@@ -316,6 +358,237 @@ class ThumbnailPipelineTest {
assertEquals(3, raster.rasterizeCount)
}
/**
* **Supersede (head-of-line blocking).** `lastOutputAt` advances on every PTY output and the list polls
* every 5 s, so an ACTIVE session mints a new key on every tick. The obsolete render must be abandoned:
* left alone it holds a permit while the row waits for the screen it is actually showing, and its bitmap
* would land in the cache under a key nothing will ever ask for again.
*/
@Test
fun `a newer key supersedes the in-flight render for the same session and frees its permit`() = runTest {
val stall = CompletableDeferred<Unit>()
val source = FakePreviewSource(block = stall)
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
maxConcurrent = 1, // ONE permit: whether the obsolete render blocks the fresh one is the point
)
val id = UUID.randomUUID()
val stale = async { pipeline.thumbnail(session(id, lastOutputAt = 100L)) }
runCurrent()
assertEquals(1, source.fetchStarts)
assertEquals(1, source.active) // holds the only permit
val fresh = async { pipeline.thumbnail(session(id, lastOutputAt = 200L)) }
runCurrent()
assertEquals(2, source.fetchStarts) // the freed permit let the NEWEST key start
assertEquals(1, source.active) // and only one render is alive — the obsolete one was abandoned
stall.complete(Unit)
advanceUntilIdle()
assertNull(stale.await()) // the superseded caller gets no bitmap; its screen no longer exists
assertNotNull(fresh.await())
assertEquals(1, raster.rasterizeCount) // only the newest key was ever rasterised
}
/**
* **Liveness.** The shared REST client sets no `callTimeout` and its per-socket read timeout is reset by
* every trickled byte, and a render is deliberately not cancellable by any caller — so without a bound
* inside the render, two slow-trickle previews hold both permits forever and NO thumbnail renders again
* until `close()`. The timeout raises `TimeoutCancellationException`, which IS a `CancellationException`:
* it must land in the PLACEHOLDER branch, never the rethrow branch.
*/
@Test
fun `a stalled fetch times out, frees its permit and falls back to the placeholder`() = runTest {
val stall = CompletableDeferred<Unit>() // never completed: a host that trickles and never finishes
val source = FakePreviewSource(block = stall)
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
renderTimeoutMs = TEST_RENDER_TIMEOUT_MS,
)
val stalled = (0 until 2).map { async { pipeline.thumbnail(session(UUID.randomUUID(), 1L)) } }
runCurrent()
assertEquals(2, source.active) // both permits held by fetches that will never finish
val queued = async { pipeline.thumbnail(session(UUID.randomUUID(), 1L)) }
runCurrent()
assertEquals(2, source.fetchStarts) // parked on the semaphore behind the stalled pair
advanceTimeBy(TEST_RENDER_TIMEOUT_MS + 1)
runCurrent()
// Both stalled renders gave up with the placeholder (cached ⇒ no retry storm) instead of dying or
// wedging, and — the whole point — the queued key got a permit and ran.
stalled.forEach { assertSame(raster.placeholderBitmap, it.await()) }
assertEquals(3, source.fetchStarts)
advanceTimeBy(TEST_RENDER_TIMEOUT_MS + 1)
runCurrent()
assertSame(raster.placeholderBitmap, queued.await())
assertEquals(0, source.active)
pipeline.close()
}
/**
* `close()` is public and unguarded, and `scope.async` on a cancelled scope never runs the body — so the
* body's `finally` (which frees the in-flight slot) never runs either. Inserting after close would leave
* a dead `Deferred` under that key forever: every later request for it awaits a corpse and gets null,
* and `inFlight` grows by every distinct post-close key.
*/
@Test
fun `a request after close returns null and leaks no in-flight entry`() = runTest {
val source = FakePreviewSource()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = FakeRasterizer(),
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
pipeline.close()
val afterClose = pipeline.thumbnail(session(UUID.randomUUID(), lastOutputAt = 1L))
advanceUntilIdle()
assertNull(afterClose)
assertEquals(0, source.fetchStarts) // nothing was fetched on a dead pipeline …
assertEquals(0, pipeline.inFlightCount()) // … and no key was left holding a dead Deferred
}
/**
* The prune is defeatable by a render that finishes AFTER it: re-inserting a killed session's bitmap
* leaves it cached until the row set changes again. The caller that asked still gets its bitmap (it
* asked), but nothing outlives the session in the cache.
*/
@Test
fun `a render landing after its session was pruned is not put back in the cache`() = runTest {
val stall = CompletableDeferred<Unit>()
val source = FakePreviewSource(block = stall)
val raster = FakeRasterizer()
val pipeline = ThumbnailPipeline(
previewSource = source,
rasterizer = raster,
cache = FakeBitmapCache(),
dispatcher = StandardTestDispatcher(testScheduler),
)
val killed = session(UUID.randomUUID(), lastOutputAt = 4L)
val landing = async { pipeline.thumbnail(killed) }
runCurrent()
assertEquals(1, source.fetchStarts)
pipeline.retainOnly(emptyList()) // the session was killed while its render was in flight
stall.complete(Unit)
advanceUntilIdle()
assertNotNull(landing.await())
// Nothing was cached for the dead session, so asking again re-fetches and re-renders.
val again = async { pipeline.thumbnail(killed) }
advanceUntilIdle()
again.await()
assertEquals(2, source.fetchStarts)
assertEquals(2, raster.rasterizeCount)
}
// ── Off-screen grid + raster budget (pure; the real emulator is pure Java) ─────────────────
/**
* **The thumbnail must not draw duplicated rows.** Termux's `TerminalBuffer` keeps
* `mTotalRows = transcriptRows` and maps `externalToInternalRow(row) = (mScreenFirstRow + row) %
* mTotalRows`, so a transcript SHORTER than the screen aliases external row *n* onto *n mTotalRows*.
* The off-screen emulator used a fixed `TERMINAL_TRANSCRIPT_ROWS_MIN` (100) against rows up to 200,
* so rows 100..199 aliased onto 0..99 — asserted below both ways: the fixed grid is injective, and the
* old geometry provably is not (so this regression cannot quietly come back as "no longer reproducible").
*/
@Test
fun `the off-screen grid never aliases screen rows onto each other`() {
val grid = ThumbnailGrid.of(cols = 120, rows = 200)
assertEquals(120, grid.cols)
assertEquals(200, grid.rows)
assertTrue(
grid.transcriptRows >= grid.rows,
"transcriptRows=${grid.transcriptRows} must be >= rows=${grid.rows}",
)
val fixed = offScreenEmulator(grid.cols, grid.rows, grid.transcriptRows)
val fixedRows = (0 until grid.rows).map { fixed.screen.externalToInternalRow(it) }
assertEquals(grid.rows, fixedRows.toSet().size, "external rows must map 1:1 onto internal rows")
val aliased = offScreenEmulator(grid.cols, grid.rows, TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN)
val aliasedRows = (0 until grid.rows).map { aliased.screen.externalToInternalRow(it) }
assertTrue(
aliasedRows.toSet().size < grid.rows,
"the aliasing this guards against is no longer reproducible — re-derive the guard",
)
}
@Test
fun `the off-screen grid clamps a rogue server geometry and never degenerates`() {
// cols/rows are SERVER-supplied: src/protocol.ts validates a resize only to 1..1000.
val rogue = ThumbnailGrid.of(cols = 1000, rows = 1000)
assertEquals(ThumbnailGrid.MAX_COLS, rogue.cols)
assertEquals(ThumbnailGrid.MAX_ROWS, rogue.rows)
assertTrue(rogue.transcriptRows >= rogue.rows)
val degenerate = ThumbnailGrid.of(cols = 0, rows = -5)
assertEquals(1, degenerate.cols)
assertEquals(1, degenerate.rows)
assertTrue(degenerate.transcriptRows >= TerminalEmulator.TERMINAL_TRANSCRIPT_ROWS_MIN)
}
/**
* **No server-reported geometry may drive a huge allocation.** A 1000×1000 session (which the protocol
* permits) at a 12px monospace cell was a 2800×2800 ARGB_8888 = 31.4 MB bitmap — ×2 concurrent permits,
* re-run every 5 s poll tick. It degraded safely (an OOM is caught) but invited an LMK kill of the app.
*/
@Test
fun `no server geometry can make the off-screen raster exceed the budget`() {
val budgetBytes = ThumbnailBudget.MAX_WIDTH_PX.toLong() *
ThumbnailBudget.MAX_HEIGHT_PX * ARGB_8888_BYTES
for (cols in intArrayOf(1, 20, 80, 161, 400, 1000)) {
for (rows in intArrayOf(1, 24, 50, 200, 1000)) {
val grid = ThumbnailGrid.of(cols, rows)
val cell = ThumbnailBudget.cellMetrics(
cols = grid.cols,
rows = grid.rows,
templateWidth = TEMPLATE_CELL_WIDTH_PX,
templateHeight = TEMPLATE_CELL_HEIGHT_PX,
)
val width = grid.cols * cell.width
val height = grid.rows * cell.height
val where = "$cols×$rows${width}×$height px (cell ${cell.width}×${cell.height})"
assertTrue(cell.width >= 1 && cell.height >= 1, "zero-sized cell: $where")
assertTrue(width <= ThumbnailBudget.MAX_WIDTH_PX, "width over budget: $where")
assertTrue(height <= ThumbnailBudget.MAX_HEIGHT_PX, "height over budget: $where")
assertTrue(width.toLong() * height * ARGB_8888_BYTES <= budgetBytes, "bytes over budget: $where")
}
}
}
@Test
fun `the cell shrink preserves the cell aspect and never upscales a small screen`() {
// 80×24 at a 7×14 cell is 560×336 — just over the 480px budget, so both axes take the SAME factor
// (480/560) and the glyph cell keeps its shape instead of being squeezed on one axis.
val shrunk = ThumbnailBudget.cellMetrics(cols = 80, rows = 24, templateWidth = 7, templateHeight = 14)
assertEquals(6, shrunk.width)
assertEquals(12, shrunk.height)
// Already inside the budget → draw at the font's own cell size, no shrink at all.
val asRendered = ThumbnailBudget.cellMetrics(cols = 40, rows = 10, templateWidth = 7, templateHeight = 14)
assertEquals(7, asRendered.width)
assertEquals(14, asRendered.height)
assertEquals(1f, asRendered.textScale)
}
@Test
fun `raster budget downscales an oversized terminal raster and leaves small ones alone`() {
// A 161x50 session at 12px monospace rasterises to ~1127x700 = 3.1 MiB ARGB_8888 — three of those
@@ -332,6 +605,13 @@ class ThumbnailPipelineTest {
val sliver = ThumbnailBudget.scaledSize(width = 4000, height = 3, maxWidth = 480)
assertNotNull(sliver)
assertEquals(1, sliver!!.height)
// HEIGHT is bounded too: a tall/narrow raster is already inside the width bound, so a width-only
// budget left it at full size (140×2800 = 1.5 MiB cached for a 96×60dp tile).
val tall = ThumbnailBudget.scaledSize(width = 140, height = 2800, maxWidth = 480, maxHeight = 480)
assertNotNull(tall)
assertEquals(480, tall!!.height)
assertEquals(24, tall.width) // aspect preserved on the tighter axis
}
/**
@@ -366,6 +646,11 @@ class ThumbnailPipelineTest {
assertNull(request.headers["Origin"], "the RO preview route must not carry Origin: ${request.headers}")
}
/**
* [PreviewCap] bounds what reaches the EMULATOR, not the response body (which the transport has already
* buffered and parsed by then — see its KDoc). 256 KiB is the client's own ceiling: the server's
* `PREVIEW_BYTES` default is 24 KiB and an operator may raise it with no upper bound.
*/
@Test
fun `preview cap keeps the tail when over 256 KiB and is a no-op under the cap`() {
val big = "A".repeat(PreviewCap.MAX_PREVIEW_BYTES + 100) + "TAIL"