JSONProcess.kt
src/jvmMain/kotlin/afield/io/process/JSONProcess.kt · 252 lines
package and imports — 47 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Data")
package afield.io.process
import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.expanded
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.audio.synth.*
import afield.data.collections.get
import afield.document.workspace.path
import afield.math.linalg.set
import afield.runtime.execution.start
import afield.document.graph.Box
import afield.runtime.execution.ReturnValueRegistry
import afield.runtime.lifecycle.RunsAt
import afield.app.diagnostics.logging.lprintln
import afield.document.graph.registerLaunchJob
import afield.runtime.lifecycle.returnValueRegistration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.util.concurrent.atomic.AtomicReference
import kotlin.concurrent.thread
Runs an external command and reads newline-delimited JSON from its stdout. Build a
JSONProcess with a working directory, a command and an optional environment; the most recent
parsable stdout line is exposed as JSONProcess.output (an org.json.JSONObject) — a snapshot
of the latest line, not a queue. A leading ~ is expanded in the working directory and in the
executable, but not in the remaining arguments.
Returning a JSONProcess from a box starts the subprocess and ties it to that run:
registerJSONProcessReturnValue installs the ReturnValueRegistry handler, and the process is
destroyed — then force-destroyed if it will not exit — when the box re-runs or the job
completes. stdout and stderr are drained on daemon threads; non-JSON lines, stderr output and a
failure to start are recorded in JSONProcess.lastError and logged rather than thrown, so check
JSONProcess.running, JSONProcess.exitCode and JSONProcess.hasOutput to know what happened.
class JSONProcess
class JSONProcess(
workingDirectory: String? = null,
vararg command: String,
val env: Map<String, String> = emptyMap()
) {
private val workingDirectoryFile = workingDirectory?.let(::expandUserPath)
val command: List<String> = command.toList()
private val latestStdoutLineRef = AtomicReference<String?>(null)
private val latestJsonLineRef = AtomicReference<String?>(null)
private val latestStderrLineRef = AtomicReference<String?>(null)
private val lastErrorRef = AtomicReference<String?>(null)
@Volatile
private var process: Process? = null
var running: Boolean
@Volatile
var running: Boolean = false
private set
var exitCode: Int?
@Volatile
var exitCode: Int? = null
private set
val output: JSONObject
val output: JSONObject
get() = latestJsonLineRef.get()?.let(::JSONObject) ?: JSONObject()
val outputText: String?
val outputText: String?
get() = latestJsonLineRef.get()
val latestStdoutLine: String?
val latestStdoutLine: String?
get() = latestStdoutLineRef.get()
val latestStderrLine: String?
val latestStderrLine: String?
get() = latestStderrLineRef.get()
val lastError: String?
val lastError: String?
get() = lastErrorRef.get()
fun hasOutput(): Boolean = latestJsonLineRef.get() != null
override fun toString(): String
override fun toString(): String {
return "JSONProcess(command=${command.joinToString(" ")}, running=$running, exitCode=$exitCode)"
}
internal suspend fun runForBox(box: Box, strand: String, generation: Int)
body — 66 lines, from line 109
internal suspend fun runForBox(box: Box, strand: String, generation: Int) {
stop()
if (command.isEmpty()) {
lastErrorRef.set("No command provided")
return
}
val resolvedCommand = command.mapIndexed { index, part ->
if (index == 0) expandUserPath(part).path else part
}
val started = try {
ProcessBuilder(resolvedCommand).apply {
workingDirectoryFile?.let(::directory)
environment().putAll(env)
}.start()
} catch (t: Throwable) {
val message = t.message ?: t.toString()
lastErrorRef.set("Failed to start process: $message")
lprintln("error,jsonprocess", "Failed to start ${resolvedCommand.joinToString(" ")}: $message")
return
}
process = started
running = true
exitCode = null
lastErrorRef.set(null)
val completionHandle = currentCoroutineContext()[Job]?.invokeOnCompletion {
stop()
}
try {
val stdoutThread = thread(name = "json-process-stdout", isDaemon = true) {
runCatching {
started.inputStream.bufferedReader().useLines { lines ->
lines.forEach(::handleStdoutLine)
}
}
}
val stderrThread = thread(name = "json-process-stderr", isDaemon = true) {
runCatching {
started.errorStream.bufferedReader().useLines { lines ->
lines.forEach(::handleStderrLine)
}
}
}
while (started.isAlive && insideLifecycleStillValid(box, strand, generation)) {
delay(50)
}
if (started.isAlive) {
destroyProcess(started)
}
exitCode = withContext(Dispatchers.IO) { started.waitFor() }
stdoutThread.join(250)
stderrThread.join(250)
} finally {
completionHandle?.dispose()
process = null
running = false
}
}
fun stop()
body — 52 lines, from line 176
@Synchronized
fun stop() {
val proc = process ?: return
destroyProcess(proc)
process = null
running = false
}
private fun handleStdoutLine(line: String) {
latestStdoutLineRef.set(line)
val trimmed = line.trim()
if (trimmed.isEmpty()) return
try {
JSONObject(trimmed)
latestJsonLineRef.set(trimmed)
lastErrorRef.set(null)
} catch (t: Throwable) {
val message = "Ignoring non-JSON stdout line: ${t.message ?: trimmed}"
lastErrorRef.set(message)
lprintln("warn,jsonprocess", message)
}
}
private fun handleStderrLine(line: String) {
latestStderrLineRef.set(line)
if (line.isNotBlank()) {
lastErrorRef.set(line)
lprintln("warn,jsonprocess", line)
}
}
private fun insideLifecycleStillValid(box: Box, strand: String, generation: Int): Boolean {
return running && box.runGeneration[strand] == generation
}
private fun destroyProcess(process: Process) {
runCatching {
process.destroy()
if (!process.waitFor(250, java.util.concurrent.TimeUnit.MILLISECONDS)) {
process.destroyForcibly()
process.waitFor(250, java.util.concurrent.TimeUnit.MILLISECONDS)
}
}
}
private fun expandUserPath(path: String): File {
if (path == "~") return File(System.getProperty("user.home"))
if (path.startsWith("~/")) return File(System.getProperty("user.home"), path.removePrefix("~/"))
return File(path)
}
}
fun registerJSONProcessReturnValue()
@RunsAt(returnValueRegistration, priority = 12)
fun registerJSONProcessReturnValue() {
ReturnValueRegistry.handlers += handler@{ inside, returnValue, strand, generation, launchEpoch, notify ->
val process = returnValue as? JSONProcess ?: return@handler false
CoroutineScope(Dispatchers.Default + Box.boxContextElement(inside)).launch {
val job = coroutineContext[Job]!!
if (!inside.registerLaunchJob(job, launchEpoch)) {
notify(false, strand, generation)
return@launch
}
notify(true, strand, generation)
try {
process.runForBox(inside, strand, generation)
} finally {
process.stop()
inside.jobs.remove(job)
notify(false, strand, generation)
}
}
true
}
}