Skip to content

RuntimeDebugProbeService.kt

src/jvmMain/kotlin/afield/app/diagnostics/debug/RuntimeDebugProbeService.kt · 658 lines

package afield.app.diagnostics.debug

The runtime debug probe: samples a symbol at a line of a running box or a src/ file and shows the values in the box's "debug probe" output channel.

RuntimeDebugProbeService.startProbe targets box code and RuntimeDebugProbeService.startSourceProbe targets a source file. Both need Field to have been launched with a JDWP port; RuntimeDebugProbeService.availabilityError explains when it was not.

import afield.document.graph.Box
import afield.app.protocol.DebugProbeEvent
import afield.app.protocol.DebugProbeRequest
import afield.app.protocol.DebugProbeSampleData
import afield.app.protocol.DebugProbeScopeData
import afield.app.protocol.DebugSourceProbeRequest
import afield.runtime.execution.KotlinExecution
import afield.app.protocol.Message
import afield.ui.visualization.h2
import afield.app.protocol.payload
import afield.app.diagnostics.logging.lprintln
import java.io.File
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter

internal data class RuntimeProbeTarget

A resolved place to install the breakpoint, decoupled from where it came from. The box path fills this from a compiled-snippet snapshot; the source path fills it straight from a className / file name + line. sourceNamePrefix is only set for REPL snippets (digit-suffixed generated source names); real src/ files match exactly on sourceName and/or className.

internal data class RuntimeProbeTarget(
    val className: String?,
    val sourceName: String,
    val sourceNamePrefix: String?,
    val localLine: Int
) {

val displayName: String

What to show in the card header: the file if we have one, else the class.

    val displayName: String
        get() = sourceName.takeIf { it.isNotBlank() } ?: className.orEmpty()
}

internal data class ProbeContext

The bits of a probe request the session/rendering layer needs, shared by both the box path (DebugProbeRequest) and the source path (DebugSourceProbeRequest).

internal data class ProbeContext(
    val requestId: String,
    val uid: String,
    val symbol: String,
    val line: Int,
    val maxHits: Int
)

internal data class RuntimeProbeSample

internal data class RuntimeProbeSample(
    val hitIndex: Int,
    val threadName: String,
    val declaringType: String,
    val method: String,
    val line: Int,
    val resolution: String,
    val valueKind: String,
    val valueType: String?,
    val valueText: String,
    val visualValue: Any?,
    val receivedAtMs: Long
)

internal data class RuntimeProbeView

body — 87 lines, from line 68
internal data class RuntimeProbeView(
    val status: String,
    val error: String?,
    val detail: String?,
    val scope: ScopeSnapshotPayload?,
    val samples: List<RuntimeProbeSample>
)

private class RuntimeProbeSession(
    private val ctx: ProbeContext,
    private val target: RuntimeProbeTarget
) {
    @Volatile private var status: String = "waiting"
    @Volatile private var error: String? = null
    @Volatile private var detail: String? = null
    @Volatile private var scope: ScopeSnapshotPayload? = null
    private val samples = mutableListOf<RuntimeProbeSample>()

    fun recordAck() {
        status = "waiting"
    }

    fun recordSample(sample: RuntimeProbeSample) {
        synchronized(samples) {
            samples += sample
            while (samples.size > 128) {
                samples.removeAt(0)
            }
        }
        status = "sample"
        error = null
    }

    fun recordComplete(response: CompleteResponse) {
        status = if (response.error == null) "complete" else "error"
        error = response.error
        detail = response.detail
        scope = response.scope
    }

    fun recordError(message: String) {
        status = "error"
        error = message
        detail = null
        scope = null
    }

    fun renderHtml(): String = RuntimeDebugProbeService.renderProbeHtml(ctx, target, snapshot())

    fun structuredSamples(): List<DebugProbeSampleData> = synchronized(samples) {
        samples.map { sample ->
            DebugProbeSampleData(
                hitIndex = sample.hitIndex,
                threadName = sample.threadName,
                declaringType = sample.declaringType,
                method = sample.method,
                line = sample.line,
                resolution = sample.resolution,
                valueKind = sample.valueKind,
                valueType = sample.valueType,
                valueText = sample.valueText
            )
        }
    }

    fun structuredScope(): List<DebugProbeScopeData> =
        scope?.symbols?.map { symbol ->
            DebugProbeScopeData(
                name = symbol.name,
                resolution = symbol.resolution,
                type = symbol.type,
                preview = symbol.preview,
                method = symbol.method,
                line = symbol.line
            )
        } ?: emptyList()

    fun currentError(): String? = error

    private fun snapshot(): RuntimeProbeView = RuntimeProbeView(
        status = status,
        error = error,
        detail = detail,
        scope = scope,
        samples = synchronized(samples) { samples.toList() }
    )
}

object RuntimeDebugProbeService

object RuntimeDebugProbeService {
    private const val helperMainClass = "afield.app.diagnostics.debug.JdiProbeHelperKt"
    private const val probeChannel = "debug probe"
    private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
    private val clientLock = Any()
    @Volatile private var client: LiveProbeClient? = null

fun availabilityError(): String?

    fun availabilityError(): String? {
        val port = jdwpPort()
            ?: return "This Field process was launched without remote debugging, so the debug " +
                "probe has no JDWP port to attach to. Remote debugging is off by default. To fix: " +
                "relaunch Field through the backdoor (tools/field_backdoor.py, which enables it " +
                "automatically), or set AFIELD_JDWP_PORT=5005 before `./gradlew runJvm`. You don't " +
                "have to lose your work — just relaunch Field, reopen the document, and probe again."
        if (port <= 0) return "Runtime debug probe JDWP port must be a positive integer (got $port)."
        return null
    }

fun startProbe

Editor path: probe a symbol in a running Kotlin box's snippet code.

    fun startProbe(
        box: Box,
        request: DebugProbeRequest,
        execution: KotlinExecution,
        reply: (Message) -> Unit
    ) {
        val ctx = ProbeContext(
            requestId = request.requestId,
            uid = request.uid,
            symbol = request.symbol,
            line = request.line,
            maxHits = request.maxHits
        )
        availabilityError()?.let { reply(errorEvent(ctx, it)); return }

        val target = resolveTarget(box, request, execution)
        if (target == null) {
            reply(errorEvent(ctx, "No running compiled snippet matches the current editor text. Relaunch that box, then retry the probe."))
            return
        }

        runProbe(ctx, target, request.threadName, request.timeoutMs, reply)
    }

fun startSourceProbe(request: DebugSourceProbeRequest, reply: (Message) -> Unit) {

MCP / agent path: probe a symbol at a source location anywhere in the loaded JVM — a src/ file and/or a fully-qualified class name plus a line. No box, no snippet: the JDI helper resolves candidate classes by className / sourceName and sets the breakpoint at the real source line.

    fun startSourceProbe(request: DebugSourceProbeRequest, reply: (Message) -> Unit) {
        val ctx = ProbeContext(
            requestId = request.requestId,
            uid = "",
            symbol = request.symbol,
            line = request.line,
            maxHits = request.maxHits
        )
        availabilityError()?.let { reply(errorEvent(ctx, it)); return }

        if (request.className.isNullOrBlank() && request.sourceName.isNullOrBlank()) {
            reply(errorEvent(ctx, "A source probe needs a 'class' (fully-qualified) or 'file' (source name) to locate the code."))
            return
        }
        if (request.line <= 0) {
            reply(errorEvent(ctx, "A source probe needs a positive 1-based source line."))
            return
        }

        val target = RuntimeProbeTarget(
            className = request.className?.takeIf { it.isNotBlank() },
            sourceName = request.sourceName?.takeIf { it.isNotBlank() }.orEmpty(),
            sourceNamePrefix = null,
            localLine = request.line
        )
        runProbe(ctx, target, request.threadName, request.timeoutMs, reply)
    }

Shared machinery: install the probe via the JDI helper and stream events back.

source — 204 lines, from line 232
    private fun runProbe(
        ctx: ProbeContext,
        target: RuntimeProbeTarget,
        threadName: String?,
        timeoutMs: Long,
        reply: (Message) -> Unit
    ) {
        val session = RuntimeProbeSession(ctx, target)
        session.recordAck()
        reply(renderEvent(ctx, "ack", session.renderHtml(), done = false, sequence = 0))

        val liveClient = try {
            client()
        } catch (t: Throwable) {
            lprintln("error,debug-probe", "failed to start helper: ${t.message}")
            session.recordError(t.message ?: "Failed to start debug helper")
            reply(
                renderEvent(
                    ctx, "error", session.renderHtml(), done = true, sequence = 1,
                    error = session.currentError(),
                    samples = session.structuredSamples()
                )
            )
            return
        }

        val probeRequest = ProbeVariableRequest(
            requestId = ctx.requestId,
            className = target.className,
            sourceName = target.sourceName.takeIf { it.isNotBlank() },
            sourceNamePrefix = target.sourceNamePrefix,
            line = target.localLine,
            variableName = ctx.symbol,
            threadName = threadName,
            maxHits = ctx.maxHits,
            timeoutMs = timeoutMs
        )

        liveClient.probe(probeRequest) { response ->
            when (response) {
                is AckResponse -> Unit
                is SampleResponse -> {
                    val sample = RuntimeProbeSample(
                        hitIndex = response.hitIndex,
                        threadName = response.threadName,
                        declaringType = response.declaringType,
                        method = response.method,
                        line = response.line,
                        resolution = response.resolution,
                        valueKind = response.value.kind,
                        valueType = response.value.type,
                        valueText = response.value.text,
                        visualValue = toVisualizationValue(response.value),
                        receivedAtMs = System.currentTimeMillis()
                    )
                    session.recordSample(sample)
                    reply(
                        renderEvent(
                            ctx = ctx,
                            phase = "sample",
                            html = session.renderHtml(),
                            done = false,
                            sequence = response.hitIndex,
                            samples = session.structuredSamples(),
                            scope = session.structuredScope()
                        )
                    )
                }

                is CompleteResponse -> {
                    session.recordComplete(response)
                    reply(
                        renderEvent(
                            ctx = ctx,
                            phase = "complete",
                            html = session.renderHtml(),
                            done = true,
                            sequence = response.hits + 1,
                            error = session.currentError(),
                            samples = session.structuredSamples(),
                            scope = session.structuredScope()
                        )
                    )
                }

                is ErrorResponse -> {
                    session.recordError(response.detail ?: response.error)
                    reply(
                        renderEvent(
                            ctx = ctx,
                            phase = "error",
                            html = session.renderHtml(),
                            done = true,
                            sequence = 1,
                            error = session.currentError(),
                            samples = session.structuredSamples(),
                            scope = session.structuredScope()
                        )
                    )
                }
            }
        }
    }

    private fun resolveTarget(
        box: Box,
        request: DebugProbeRequest,
        execution: KotlinExecution
    ): RuntimeProbeTarget? {
        val segment = execution.resolveDebugProbeSegment(box, request.allCode, request.line) ?: return null
        val snapshot = box.ki.context.debugScripts.latestMatchingSource(segment.expandedText) ?: return null
        return RuntimeProbeTarget(
            className = snapshot.className,
            sourceName = snapshot.sourceName,
            // REPL snippet source names carry a digit suffix; match the family.
            sourceNamePrefix = snapshot.sourceName.dropLastWhile { it.isDigit() },
            localLine = segment.localLine
        )
    }

    private fun client(): LiveProbeClient {
        client?.takeIf { it.isAlive() }?.let { return it }

        synchronized(clientLock) {
            client?.takeIf { it.isAlive() }?.let { return it }
            client?.close()

            val javaHome = System.getProperty("java.home")
            val javaName = if (System.getProperty("os.name").lowercase().contains("win")) "java.exe" else "java"
            val javaBin = File(javaHome, "bin/$javaName")

            val next = LiveProbeClient(
                helperMainClass = helperMainClass,
                debugHost = jdwpHost(),
                debugPort = jdwpPort() ?: error("JDWP port missing"),
                javaBin = javaBin.absolutePath
            )
            client = next
            return next
        }
    }

    private fun jdwpHost(): String =
        System.getProperty("afield.jdwp.host")
            ?: System.getenv("AFIELD_JDWP_HOST")
            ?: "127.0.0.1"

    private fun jdwpPort(): Int? {
        val raw = System.getProperty("afield.jdwp.port")
            ?: System.getenv("AFIELD_JDWP_PORT")
            ?: return null
        return raw.toIntOrNull()
    }

    private fun errorEvent(ctx: ProbeContext, message: String): Message =
        renderEvent(
            ctx = ctx,
            phase = "error",
            html = renderImmediateErrorHtml(ctx, message),
            done = true,
            sequence = 1,
            error = message
        )

    private fun renderEvent(
        ctx: ProbeContext,
        phase: String,
        html: String,
        done: Boolean,
        sequence: Int,
        error: String? = null,
        samples: List<DebugProbeSampleData> = emptyList(),
        scope: List<DebugProbeScopeData> = emptyList()
    ): Message = Message("debug-probe-event").payload(
        DebugProbeEvent(
            requestId = ctx.requestId,
            uid = ctx.uid,
            phase = phase,
            symbol = ctx.symbol,
            line = ctx.line,
            html = html,
            channel = probeChannel,
            annotation = probeAnnotation(ctx.symbol),
            done = done,
            sequence = sequence,
            error = error,
            samples = samples,
            scope = scope
        )
    )

    private fun renderImmediateErrorHtml(ctx: ProbeContext, message: String): String = buildString {
        append("<div style='min-width:320px; max-width:900px; color:#ddd; font:12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;'>")
        append("<div style='display:flex; align-items:center; gap:8px; flex-wrap:wrap;'>")
        append("<div style='font-size:13px; color:#ff9b9b;'><strong>debug probe</strong> <code>")
        append(escapeHtml(ctx.symbol))
        append("</code></div>")
        append(statusBadge("error", true))
        append("</div>")
        append("<div style='margin-top:8px; color:#ffb3b3;'>")
        append(escapeHtml(message))
        append("</div>")
        append("</div>")
    }

internal fun renderProbeHtml(ctx: ProbeContext, target: RuntimeProbeTarget, view: RuntimeProbeView): String

body — 221 lines, from line 437
    internal fun renderProbeHtml(ctx: ProbeContext, target: RuntimeProbeTarget, view: RuntimeProbeView): String = buildString {
        val samples = view.samples
        val latest = samples.lastOrNull()
        val errorMessage = view.error?.let { humanizeProbeError(it, samples.size) }
        val statusText = when {
            view.status == "waiting" -> "waiting"
            view.status == "sample" -> "sampling"
            view.status == "complete" -> "complete"
            else -> "error"
        }
        val statusColor = if (view.status == "error") "#ff9b9b" else "#9fd0ff"

        append("<div style='min-width:340px; max-width:980px; color:#ddd; font:12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;'>")
        append("<div style='display:flex; align-items:center; gap:8px; flex-wrap:wrap;'>")
        append("<div style='font-size:13px; color:")
        append(statusColor)
        append(";'><strong>debug probe</strong> <code>")
        append(escapeHtml(ctx.symbol))
        append("</code></div>")
        append(statusBadge(statusText, view.status == "error"))
        append(metaChip("${samples.size}/${ctx.maxHits} samples"))
        latest?.let { append(metaChip(it.resolution)) }
        latest?.valueType?.takeIf { it.isNotBlank() }?.let { append(metaChip(it)) }
        append("</div>")

        append("<div style='margin-top:6px; color:#888;'>")
        append(escapeHtml(target.displayName))
        append(":")
        append(target.localLine)
        // For the source path the file name is the display name, so only append the
        // class as a suffix when it adds something the display name didn't already show.
        target.className?.takeIf { it.isNotBlank() && it != target.displayName }?.let {
            append(" • ")
            append(escapeHtml(it))
        }
        append("</div>")

        append("<div style='display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;'>")
        append(summaryCard("Arrivals", arrivalSummary(samples)))
        append(summaryCard("Latest", latest?.valueText?.let { truncateText(it) } ?: "Waiting for first hit"))
        append(summaryCard("Threads", threadSummary(samples)))
        append("</div>")

        if (errorMessage != null) {
            append("<div style='margin-top:10px; padding:8px 10px; border-radius:8px; background:rgba(120,24,24,0.34); color:#ffc6c6;'>")
            append(escapeHtml(errorMessage))
            view.detail?.takeIf { it.isNotBlank() }?.let {
                append("<div style='margin-top:4px; color:#ffdddd;'>")
                append(escapeHtml(it))
                append("</div>")
            }
            append("</div>")
        } else if (view.status == "waiting") {
            append("<div style='margin-top:10px; padding:8px 10px; border-radius:8px; background:rgba(60,80,120,0.18); color:#c9ddff;'>")
            append("Waiting for an execution hit on this line.")
            append("</div>")
        }

        if (samples.isNotEmpty()) {
            append(renderRecentSamples(samples))
        }

        if (shouldRenderVisualizer(samples.size, ctx.maxHits, view.status != "waiting")) {
            append("<div style='margin-top:12px;'>")
            append(samples.map { it.visualValue }.h2)
            append("</div>")
        }

        view.scope?.takeIf { it.symbols.isNotEmpty() }?.let {
            append(renderScopeSnapshot(it))
        }

        append("</div>")
    }

    private fun renderRecentSamples(samples: List<RuntimeProbeSample>): String = buildString {
        append("<div style='margin-top:12px;'>")
        append("<div style='color:#9ad; margin-bottom:6px;'>Recent hits</div>")
        append("<div style='display:grid; gap:6px;'>")
        samples.takeLast(8).forEach { sample ->
            append("<div style='padding:8px 10px; border-radius:8px; background:rgba(255,255,255,0.04);'>")
            append("<div style='display:flex; gap:8px; flex-wrap:wrap; align-items:center;'>")
            append(metaChip("#${sample.hitIndex}"))
            append(metaChip(formatTime(sample.receivedAtMs)))
            append(metaChip("${sample.declaringType}.${sample.method}:${sample.line}"))
            append(metaChip(sample.threadName))
            append("</div>")
            append("<div style='margin-top:6px; color:#f0f0f0;'><code>")
            append(escapeHtml(truncateText(sample.valueText)))
            append("</code></div>")
            append("</div>")
        }
        append("</div>")
        append("</div>")
    }

    private fun renderScopeSnapshot(scope: ScopeSnapshotPayload): String = buildString {
        append("<div style='margin-top:12px;'>")
        append("<div style='color:#9ad; margin-bottom:6px;'>Symbols in scope</div>")
        append("<div style='display:grid; gap:6px;'>")
        scope.symbols.forEach { symbol ->
            append("<div style='padding:8px 10px; border-radius:8px; background:rgba(255,255,255,0.04);'>")
            append("<div style='display:flex; gap:8px; flex-wrap:wrap; align-items:center;'>")
            append("<code>")
            append(escapeHtml(symbol.name))
            append("</code>")
            append(metaChip(symbol.resolution))
            symbol.type?.takeIf { it.isNotBlank() }?.let { append(metaChip(it)) }
            symbol.method?.takeIf { it.isNotBlank() }?.let {
                val where = if (symbol.line > 0) "$it:${symbol.line}" else it
                append(metaChip(where))
            }
            append("</div>")
            symbol.preview.takeIf { it.isNotBlank() }?.let {
                append("<div style='margin-top:6px; color:#ccc;'><code>")
                append(escapeHtml(it))
                append("</code></div>")
            }
            append("</div>")
        }
        if (scope.omittedCount > 0) {
            append("<div style='color:#888; margin-top:4px;'>")
            append(scope.omittedCount)
            append(" more symbol(s) omitted.</div>")
        }
        append("</div>")
        append("</div>")
    }

    private fun metaChip(text: String): String =
        "<span style='display:inline-block; padding:2px 8px; border-radius:999px; background:rgba(255,255,255,0.08); color:#cfd8e3;'>${escapeHtml(text)}</span>"

    private fun statusBadge(text: String, error: Boolean): String =
        "<span style='display:inline-block; padding:2px 8px; border-radius:999px; background:${if (error) "rgba(160,32,32,0.38)" else "rgba(56,92,152,0.32)"}; color:${if (error) "#ffd0d0" else "#d8e7ff"};'>${escapeHtml(text)}</span>"

    private fun summaryCard(label: String, value: String): String = buildString {
        append("<div style='min-width:180px; padding:8px 10px; border-radius:8px; background:rgba(255,255,255,0.04);'>")
        append("<div style='color:#888; margin-bottom:4px;'>")
        append(escapeHtml(label))
        append("</div>")
        append("<div style='color:#f0f0f0;'>")
        append(escapeHtml(value))
        append("</div>")
        append("</div>")
    }

    private fun arrivalSummary(samples: List<RuntimeProbeSample>): String {
        val first = samples.firstOrNull() ?: return "No hits yet"
        val last = samples.last()
        if (samples.size == 1) return formatTime(first.receivedAtMs)
        val deltaMs = (last.receivedAtMs - first.receivedAtMs).coerceAtLeast(0L)
        return "${formatTime(first.receivedAtMs)} - ${formatTime(last.receivedAtMs)} (${formatDuration(deltaMs)})"
    }

    private fun threadSummary(samples: List<RuntimeProbeSample>): String {
        if (samples.isEmpty()) return "No hits yet"
        val names = samples.map { it.threadName }.distinct()
        return if (names.size == 1) names.first() else "${names.size} threads (${names.first()})"
    }

    private fun formatTime(ms: Long): String =
        timeFormatter.format(Instant.ofEpochMilli(ms).atZone(ZoneId.systemDefault()).toLocalTime())

    private fun formatDuration(ms: Long): String {
        if (ms < 1_000) return "${ms}ms"
        val seconds = ms / 1_000
        val millis = ms % 1_000
        return "${seconds}.${millis.toString().padStart(3, '0')}s"
    }

    private fun shouldRenderVisualizer(sampleCount: Int, maxHits: Int, settled: Boolean): Boolean {
        val threshold = minOf(4, maxHits.coerceAtLeast(2))
        return sampleCount >= threshold || (settled && sampleCount >= 2)
    }

    private fun humanizeProbeError(error: String, sampleCount: Int): String =
        when (error) {
            "symbol_not_visible" -> "The symbol was not visible at the executable location(s) for this line."
            "timed_out_waiting_for_hit" -> if (sampleCount > 0) "The probe timed out before collecting the requested samples." else "The probe timed out before the line executed."
            "missing_debug_info" -> "This class was loaded without local variable debug information."
            "thread_not_inspectable" -> "The debugger could not inspect the current thread state."
            "no_executable_location" -> "No executable bytecode location matched that source line."
            "class_not_loaded" -> "The generated class for this snippet is not loaded in the JVM yet."
            "thread_not_found" -> "The requested thread name was not found."
            "request_install_failed" -> "The debug helper failed while installing the probe."
            else -> error
        }

    private fun toVisualizationValue(value: ValuePayload): Any? =
        when (value.kind) {
            "null" -> null
            "primitive" -> when (value.type) {
                "boolean" -> value.text.toBooleanStrictOrNull() ?: value.text
                "byte" -> value.text.toByteOrNull() ?: value.text
                "short" -> value.text.toShortOrNull() ?: value.text
                "int" -> value.text.toIntOrNull() ?: value.text
                "long" -> value.text.toLongOrNull() ?: value.text
                "float" -> value.text.toFloatOrNull() ?: value.text
                "double" -> value.text.toDoubleOrNull() ?: value.text
                else -> value.text
            }

            "string" -> value.text
            else -> value.text
        }

    private fun probeAnnotation(symbol: String): String = "<i>debug ${escapeHtml(symbol)}</i>"

    private fun truncateText(text: String, maxChars: Int = 140): String {
        val compact = text.replace('\n', ' ').replace('\r', ' ').trim()
        if (compact.length <= maxChars) return compact
        return compact.take(maxChars - 1) + "…"
    }

    private fun escapeHtml(text: String): String = text
        .replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace("\"", "&quot;")
        .replace("'", "&#39;")
}