diff --git a/android/.gitignore b/android/.gitignore index 2c52a7b..96325e0 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -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/ diff --git a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt index ba4b69e..5f8a290 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/nav/SessionsHome.kt @@ -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 }) } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt index c8f5bfd..9b874f0 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/TerminalThumbnailRasterizer.kt @@ -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() } } diff --git a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt index c6ed37f..363bb3f 100644 --- a/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt +++ b/android/app/src/main/java/wang/yaojia/webterm/wiring/ThumbnailPipeline.kt @@ -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>` 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>() 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() + + /** + * 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) { - 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 = 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) diff --git a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt index 59eaaea..1782fbd 100644 --- a/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt +++ b/android/app/src/test/java/wang/yaojia/webterm/wiring/ThumbnailPipelineTest.kt @@ -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(relaxed = true), + cols, + rows, + transcriptRows, + mockk(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() + 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() // 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() + 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"