Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 102 additions & 5 deletions compose/api/compose.klib.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package wang.harlon.tinyui

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import wang.harlon.quickjs.JsEngine
import wang.harlon.quickjs.JsEngineConfig
import wang.harlon.quickjs.JsValue
import wang.harlon.tinyui.components.registerBuiltins
import wang.harlon.tinyui.schema.ComponentRegistry
import java.io.File
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/** Loads what `tinyui build` produced for the sample through the real engine; skips when the sample was not built. */
class BundleSmokeTest {
Expand All @@ -24,4 +32,42 @@ class BundleSmokeTest {
engine.runBytecode(out.resolve("pages/counter.bin").readBytes()).let { if (it is AutoCloseable) it.close() }
}
}

/** A rejected `await` in the todos page's `loadMore` is E7; its frames map back to the `.tsx` through the built map. */
@Test
fun stacksFromABuiltPageMapBackToTheSource() = runBlocking {
if (!out.isDirectory) return@runBlocking
val manifest = BuildManifest.parse(out.resolve("manifest.json").readText())
val maps = SourceMaps(mapOf("pages/todos" to out.resolve("pages/todos.js.map").readText(), "@tiny-ui/core" to out.resolve("runtime/core.js.map").readText()))
val errors = mutableListOf<PageError>()
val sink = object : PageSink {
override fun error(error: PageError) { errors += error }
override fun log(line: String) {}
}
val http = object : HttpClient {
override suspend fun request(request: HttpRequest): HttpResponse =
if (request.url.endsWith("page=1")) HttpResponse(200, """{"items":[{"id":1,"title":"a","done":false}],"next":2}""")
else throw HostException("E_HTTP", "500")
}
val runtime = RuntimeBundle(out.resolve("runtime/core.bin").readBytes(), out.resolve("runtime/native.bin").readBytes())
val page = PageModule("pages/todos", out.resolve("pages/todos.bin").readBytes(), manifest.buildId("pages/todos"))
val host = PageHost(runtime, page, ComponentRegistry().registerBuiltins(), sink, HostServices(http = http), sourceMaps = maps)
host.start()
try {
// the list only exists once the first page has loaded; node ids are dense, so scan them
fun lazyColumn(): Int? = (1..host.tree.size * 2).firstOrNull { host.tree.node(it)?.type == "LazyColumn" }
withContext(Dispatchers.Default) { withTimeout(10_000) {
while (lazyColumn() == null) delay(20)
host.dispatch(lazyColumn()!!, "onReachEnd", "{}")
while (errors.none { it.kind == "E7" }) delay(20)
} }
val e = errors.single { it.kind == "E7" }
assertEquals(manifest.buildId("pages/todos"), e.buildId)
val own = e.frames.firstOrNull { it.mapped && it.file == "src/pages/todos.tsx" } ?: error("no mapped frame in ${e.frames}; stack=${e.jsStack}")
val source = File("../sample/js/src/pages/todos.tsx").readLines()
assertTrue("await http.get" in source[own.line - 1], "frame $own points at the await; stack=${e.jsStack}")
} finally {
host.close()
}
}
}
20 changes: 20 additions & 0 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/BuildManifest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package wang.harlon.tinyui

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

/** `manifest.json` as `tinyui build` writes it: module names and the build id of each. */
class BuildManifest(val runtime: List<String>, val pages: List<String>, val buildIds: Map<String, String>) {
fun buildId(module: String): String = buildIds[module] ?: ""

companion object {
fun parse(json: String): BuildManifest {
val root = Json.parseToJsonElement(json).jsonObject
val names = { key: String -> root[key]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList() }
val ids = root["buildIds"]?.jsonObject?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap()
return BuildManifest(names("runtime"), names("pages"), ids)
}
}
}
38 changes: 38 additions & 0 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/PageError.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package wang.harlon.tinyui

/** One line of a JS stack. With a source map the location is the `.tsx` one and [mapped] is true; otherwise it is what the engine printed. */
class StackFrame(val function: String, val file: String, val line: Int, val column: Int?, val mapped: Boolean) {
override fun toString(): String = "at $function ($file:$line${column?.let { ":$it" } ?: ""})"
}

/**
* Every failure of a page in one shape (docs/adr-002 §3.5): [kind] is E1–E7. [frames] is [jsStack] parsed and,
* where a source map was available, mapped back to the source; [buildId] pairs [jsStack] with the map offline.
*/
class PageError(
val kind: String,
val page: String,
val buildId: String,
val message: String,
/** E1: the K entry that was running (`dispatch`, `resolve`, `emit`…). */
val entry: String? = null,
val jsStack: String? = null,
val frames: List<StackFrame> = emptyList(),
/** E5: the op that was skipped. */
val op: String? = null,
) {
override fun toString(): String = buildString {
append(kind).append(' ').append(page)
if (buildId.isNotEmpty()) append('@').append(buildId)
entry?.let { append(" [").append(it).append(']') }
append(": ").append(message)
op?.let { append(" op=").append(it) }
for (f in frames) append("\n ").append(f)
}
}

interface PageSink {
fun error(error: PageError)
/** `console.*` and engine log lines. */
fun log(line: String)
}
45 changes: 27 additions & 18 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ import wang.harlon.quickjs.JsRuntime
import wang.harlon.quickjs.JsValue
import wang.harlon.quickjs.ObjectTransport
import wang.harlon.tinyui.node.NodeTree
import wang.harlon.tinyui.node.PatchProblem
import wang.harlon.tinyui.node.UINode
import wang.harlon.tinyui.schema.ComponentRegistry
import wang.harlon.tinyui.schema.NodeScope
Expand All @@ -59,16 +58,16 @@ import wang.harlon.tinyui.schema.SizeValue
/** Bytecode of the runtime modules every page imports, as `tinyui build` writes them under `runtime/`. */
class RuntimeBundle(val core: ByteArray, val native: ByteArray)

/** One page's bytecode; [name] is the module name (`pages/todos`) and [buildId] comes from `manifest.json`. */
class PageModule(val name: String, val bytecode: ByteArray, val buildId: String = "")

/** Everything the host learns about a page's health, in one place (docs/adr-002 §3.5). */
interface PageSink {
fun patchProblem(problem: PatchProblem)
fun businessError(entry: String, message: String, stack: String?)
fun log(line: String)
/** The page's failure (E2 / E6): engine gone, error page to be rendered by the host. */
class PageFailure(val error: PageError) {
val kind: String get() = error.kind
val message: String get() = error.message
}

/** The page's failure: engine gone, error page to be rendered by the host. */
class PageFailure(val kind: String, val message: String)

/**
* One page: its engine, coroutine scope and node tree (docs/adr-002 §3.4). Every K entry is
* `entry` + `flush` under one [JsRuntime.withEngine] (docs/js-runtime.html §2). JS runs on a thread
Expand All @@ -77,15 +76,16 @@ class PageFailure(val kind: String, val message: String)
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
class PageHost(
private val runtimeBundle: RuntimeBundle,
private val page: ByteArray,
private val page: PageModule,
val registry: ComponentRegistry,
private val sink: PageSink,
private val services: HostServices = HostServices.Default,
private val propsJson: String = "{}",
/** K entries longer than this are interrupted and fail the page (docs/native-api.md §6). */
private val entryTimeoutMs: Long = 5_000,
private val sourceMaps: SourceMaps = SourceMaps.EMPTY,
) {
val tree = NodeTree(registry, sink::patchProblem)
val tree = NodeTree(registry) { report("E5", it.reason, op = it.op) }
var failure: PageFailure? by mutableStateOf(null)
private set

Expand All @@ -94,7 +94,10 @@ class PageHost(
private val subscriptions = HashMap<String, AutoCloseable>()
private val storeVersions = HashMap<String, Long>()
private val jsThread = newSingleThreadContext("tinyui-page")
private val runtime = JsRuntime(JsEngineConfig(moduleScheme = MODULE_SCHEME, logger = sink::log), jsThread)
private val runtime = JsRuntime(
JsEngineConfig(moduleScheme = MODULE_SCHEME, logger = sink::log, onUnhandledRejection = { report("E7", it.message ?: "unhandled rejection", stack = it.jsStack) }),
jsThread,
)
private var entries: Entries? = null

private class Entries(val self: JsRef, val fns: Map<String, JsRef>) {
Expand All @@ -112,7 +115,7 @@ class PageHost(
step("registering @tiny-ui/native") { registerModule(runtimeBundle.native) }
// a registered module only runs on its first import; the runtime must be up before the page
evaluateModule("import \"@tiny-ui/core\"; import \"@tiny-ui/native\";").close()
val namespace = runBytecode(page, ObjectTransport.REF) as JsRef
val namespace = runBytecode(page.bytecode, ObjectTransport.REF) as JsRef
if (namespace.isPromise) { namespace.close(); error("page module is still pending after microtasks were drained") }
val self = evaluate("__tinyui", objects = ObjectTransport.REF) as JsRef
val fns = ENTRY_NAMES.associateWith { self.get(it, ObjectTransport.REF) as JsRef }
Expand Down Expand Up @@ -186,9 +189,14 @@ class PageHost(
try { block() } catch (t: Throwable) { throw IllegalStateException("$what: ${t.message}", t) }

private fun fail(kind: String, t: Throwable) {
val message = (t as? JsException)?.let { "${it.message}\n${it.jsStack ?: ""}" } ?: t.message ?: t.toString()
sink.log("page failed ($kind): $message")
failure = PageFailure(kind, message)
val error = report(kind, t.message ?: t.toString(), stack = (t as? JsException)?.jsStack)
failure = PageFailure(error)
}

private fun report(kind: String, message: String, entry: String? = null, stack: String? = null, op: String? = null): PageError {
val error = PageError(kind, page.name, page.buildId, message, entry, stack, sourceMaps.frames(stack), op)
sink.error(error)
return error
}

private fun registerHost(engine: JsEngine) {
Expand All @@ -201,13 +209,14 @@ class PageHost(
val detail = (args[1] as? JsValue.Str)?.value ?: ""
val fields = runCatching { Json.parseToJsonElement(detail).jsonObject }.getOrNull()
if (kind == "E1" && fields != null) {
sink.businessError(
report(
"E1",
fields["message"]?.jsonPrimitive?.contentOrNull ?: "",
entry = fields["entry"]?.jsonPrimitive?.contentOrNull ?: "?",
message = fields["message"]?.jsonPrimitive?.contentOrNull ?: "",
stack = fields["stack"]?.jsonPrimitive?.contentOrNull,
)
} else {
sink.businessError(entry = "?", message = "unparseable $kind report: $detail", stack = null)
report("E1", "unparseable $kind report: $detail")
}
JsValue.Undefined
}
Expand Down
107 changes: 107 additions & 0 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/SourceMaps.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package wang.harlon.tinyui

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive

/**
* The `.js.map` files `tinyui build` wrote, keyed by module name (`pages/todos`, `@tiny-ui/core`), parsed on first use.
* Ship them in debug builds only (docs/build-chain.md); release stacks are mapped offline with the build id.
*/
class SourceMaps(maps: Map<String, String>) {
private val entries: Map<String, Lazy<SourceMap>> = maps.mapValues { (_, json) -> lazy { SourceMap(json) } }

/** Parses an engine stack (` at fn (module:line:col)` per line), mapping the frames whose module has a map. */
fun frames(stack: String?): List<StackFrame> {
if (stack.isNullOrBlank()) return emptyList()
return stack.lineSequence().mapNotNull(::frame).toList()
}

private fun frame(line: String): StackFrame? {
val m = FRAME.matchEntire(line.trim()) ?: return null
val function = m.groups[1]?.value ?: "<anonymous>"
val location = m.groups[2]?.value ?: m.groups[3]!!.value
val loc = LOCATION.matchEntire(location)
?: return StackFrame(function, location, 0, null, mapped = false)
val file = loc.groupValues[1]
val lineNo = loc.groupValues[2].toInt()
val column = loc.groups[3]?.value?.toInt()
// a broken map must not cost the report itself: fall back to the engine's frame
val mapped = runCatching { entries[file.removePrefix("${PageHost.MODULE_SCHEME}:")]?.value?.lookup(lineNo, column) }.getOrNull()
return if (mapped != null) StackFrame(function, mapped.file, mapped.line, mapped.column, mapped = true)
else StackFrame(function, file, lineNo, column, mapped = false)
}

companion object {
val EMPTY = SourceMaps(emptyMap())
private val FRAME = Regex("""at (?:(.+?) \((.+)\)|(.+))""")
private val LOCATION = Regex("""(.+?):(\d+)(?::(\d+))?""")
}
}

/** Source map v3 (`mappings` VLQ), enough to answer "which source line is generated line L, column C". */
internal class SourceMap(json: String) {
class Position(val file: String, val line: Int, val column: Int)

private val sources: List<String>
/** Per generated line: segments as (generatedColumn, sourceIndex, sourceLine, sourceColumn), all 0-based. */
private val lines: List<IntArray>

init {
val root = Json.parseToJsonElement(json).jsonObject
sources = root["sources"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList()
lines = decode(root["mappings"]?.jsonPrimitive?.content ?: "")
}

/** [line] and [column] as the engine prints them (1-based); without a column the first segment of the line answers. */
fun lookup(line: Int, column: Int?): Position? {
val segments = lines.getOrNull(line - 1) ?: return null
if (segments.isEmpty()) return null
var best = -1
val target = (column ?: 1) - 1
for (i in 0 until segments.size / 4) {
if (segments[i * 4] <= target) best = i else break
}
if (best < 0) best = 0
val source = sources.getOrNull(segments[best * 4 + 1]) ?: return null
return Position(source, segments[best * 4 + 2] + 1, segments[best * 4 + 3] + 1)
}

private fun decode(mappings: String): List<IntArray> {
val out = ArrayList<IntArray>()
var source = 0; var sourceLine = 0; var sourceColumn = 0
for (group in mappings.split(';')) {
var generated = 0
val segments = ArrayList<Int>()
if (group.isNotEmpty()) for (segment in group.split(',')) {
val fields = vlq(segment)
generated += fields[0]
if (fields.size >= 4) {
source += fields[1]; sourceLine += fields[2]; sourceColumn += fields[3]
segments += generated; segments += source; segments += sourceLine; segments += sourceColumn
}
}
out += segments.toIntArray()
}
return out
}

private fun vlq(segment: String): IntArray {
val values = ArrayList<Int>()
var value = 0; var shift = 0
for (c in segment) {
val digit = BASE64.indexOf(c)
require(digit >= 0) { "bad VLQ character '$c'" }
value = value or ((digit and 31) shl shift)
if (digit and 32 != 0) { shift += 5; continue }
values += if (value and 1 != 0) -(value shr 1) else value shr 1
value = 0; shift = 0
}
return values.toIntArray()
}

private companion object {
const val BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
}
}
3 changes: 3 additions & 0 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUI.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,7 @@ import wang.harlon.quickjs.QuickJs
object TinyUI {
/** Version of the quickjs-kmp SDK this build links against. */
val engineVersion: String get() = QuickJs.sdkVersion

/** Debug builds show the (mapped) JS stack on the page-failure screen; keep false in release. */
var debug: Boolean = false
}
Loading
Loading