KratosLaunch.kt
src/jvmMain/kotlin/afield/simulation/kratos/KratosLaunch.kt · 289 lines
@file:afield.runtime.completion.CompletionCategory("Simulation")
package afield.simulation.kratos
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketTimeoutException
import java.nio.file.Files
import java.nio.file.Path
import java.util.UUID
import java.util.concurrent.TimeUnit
Where the Kratos worker process comes from, and which end of the socket Field owns.
The protocol above this is identical in all three cases: one authenticated frame stream, one
session, the envelope described in optional/kratos/protocol.md. What differs is everything
before the first frame.
| who runs Python | who listens | who dials | |
|---|---|---|---|
LocalProcess |
Field, as a child process | Field, on an ephemeral 127.0.0.1 port | the worker |
Container |
Field, as a container | the worker, inside its namespace | Field, on the published port |
Attach |
nobody, it is already running | the worker | Field |
Why the container case reverses the direction
LocalProcess binds 127.0.0.1 in the JVM and hands the port to the child, which connects back.
A container cannot do that. Its network namespace is not the host's, so the host's loopback
interface is not reachable from inside it, and on macOS there is a virtual machine in between as
well. Publishing a port is the direction that does work through both, so in Container the
worker binds and Field dials.
That does mean the worker binds 0.0.0.0, inside the container. Only the port the runtime
publishes leaves that namespace, and it is published to 127.0.0.1 on the host, so the host's
exposure is the same loopback-only socket either way. The token is checked on the first frame in
both directions.
Why the container launch exists at all
Kratos publishes manylinux_2_28_x86_64 and win_amd64 wheels. There is no macOS wheel for
either architecture, so on an Apple Silicon checkout uv sync --project optional/kratos fails
during resolution and LocalProcess has nothing to run. The container carries the same pinned
environment, resolved from the same uv.lock. Under emulation it is slow enough that the
measured component times mean nothing as absolute numbers; the ratios between them still do.
sealed interface KratosLaunch
One way of bringing a worker up and handing back a socket connected to it.
LocalProcess, Container and Attach are the three, and the table above says which end of the
socket each of them owns. A KratosConfig with no launch of its own resolves to LocalProcess,
so that is what a caller who never mentions this interface gets.
sealed interface KratosLaunch {
fun token(): String = UUID.randomUUID().toString()
The token the worker will demand on its first frame. Generated per launch, except when attaching.
fun start(config: KratosConfig, token: String, log: Path): KratosStarted
Bring up a worker and return a connected socket.
@param log the file both the worker's stdout and stderr are redirected to; quoted back in startup and transport failures, so a native abort with no Java stack still says something.
data class LocalProcess
Field owns the listener, the worker is an ordinary child process, and it connects back. The
default, and the profile everything in VERIFICATION.md was measured against.
data class LocalProcess(
val python: String,
val worker: Path,
) : KratosLaunch {
override fun start(config: KratosConfig, token: String, log: Path): KratosStarted
override fun start(config: KratosConfig, token: String, log: Path): KratosStarted {
require(Files.isRegularFile(worker)) { "Worker script missing: $worker" }
val server = ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
server.soTimeout = 100
val process = ProcessBuilder(python, "-u", worker.toString(), "--port", server.localPort.toString())
.redirectErrorStream(true).redirectOutput(log.toFile())
.also { environment(it.environment(), config, token, null) }
.start()
try {
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(config.startupTimeoutMs.toLong())
while (true) {
check(process.isAlive) { "Worker exited during startup: ${logTail(log)}" }
if (System.nanoTime() > deadline) throw SocketTimeoutException("Kratos worker startup timed out")
try {
return KratosStarted(server.accept(), process) {}
} catch (_: SocketTimeoutException) {
// Keep checking that the child is still alive, and the deadline.
}
}
} catch (e: Exception) {
process.destroyForcibly()
throw e
} finally {
server.close()
}
}
}
data class Container
The worker runs in a container and Field dials the published port.
The image carries the Python environment only. optional/kratos/python is bind-mounted
read-only at /opt/field-kratos, so editing the worker does not mean rebuilding, and the
worker source Field runs is always the one in the checkout.
Build it with:
podman build --platform linux/amd64 -t field-kratos:10.4.0 optional/kratos
@param image the tag built from optional/kratos/Containerfile.
@param runtime podman or docker; resolved on PATH.
@param platform passed to --platform. Needed on arm64 hosts, where it selects emulation;
null omits the flag and takes the runtime's default.
@param workerDirectory the host directory holding worker.py and thermal.py.
@param extraArgs inserted before the image name — --cpus, --memory, an extra -v.
data class Container(
val image: String = System.getenv("AFIELD_KRATOS_IMAGE") ?: "field-kratos:10.4.0",
val runtime: String = System.getenv("AFIELD_KRATOS_RUNTIME") ?: "podman",
val platform: String? = System.getenv("AFIELD_KRATOS_PLATFORM") ?: "linux/amd64",
val workerDirectory: Path = Path.of("optional/kratos/python").toAbsolutePath(),
val extraArgs: List<String> = emptyList(),
val startupTimeoutMs: Int = 120_000,
) : KratosLaunch {
init { require(startupTimeoutMs > 0) { "startupTimeoutMs must be positive" } }
override fun start(config: KratosConfig, token: String, log: Path): KratosStarted
body — 73 lines, from line 133
override fun start(config: KratosConfig, token: String, log: Path): KratosStarted {
require(Files.isDirectory(workerDirectory)) { "Worker directory missing: $workerDirectory" }
require(Files.isRegularFile(workerDirectory.resolve("worker.py"))) {
"No worker.py under $workerDirectory"
}
val name = "field-kratos-" + UUID.randomUUID().toString().take(12)
val port = freePort()
val setup = config.setupScript?.toAbsolutePath()
val command = buildList {
add(runtime); add("run"); add("--rm"); add("--name"); add(name)
platform?.let { add("--platform"); add(it) }
add("-p"); add("127.0.0.1:$port:$port")
// Valueless -e inherits from this ProcessBuilder's environment, so the token never
// appears in an argv that `ps` will show to every user on the machine.
add("-e"); add("FIELD_KRATOS_TOKEN")
add("-e"); add("OMP_NUM_THREADS")
add("-v"); add("$workerDirectory:/opt/field-kratos:ro")
if (setup != null) {
add("-v"); add("${setup.parent}:/opt/field-kratos-setup:ro")
add("-e"); add("FIELD_KRATOS_SETUP")
}
addAll(extraArgs)
add(image)
add("/opt/field-kratos-env/.venv/bin/python"); add("-u")
add("/opt/field-kratos/worker.py"); add("--listen"); add(port.toString())
}
val process = ProcessBuilder(command)
.redirectErrorStream(true).redirectOutput(log.toFile())
.also {
environment(it.environment(), config, token,
setup?.let { path -> "/opt/field-kratos-setup/${path.fileName}" })
}
.start()
val remove = {
runCatching {
ProcessBuilder(runtime, "rm", "-f", name)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start().waitFor(5, TimeUnit.SECONDS)
}
Unit
}
try {
// A successful connect is NOT evidence the worker is up. Podman publishes the port
// by running a proxy on the host, and that proxy accepts from the moment `run`
// starts -- before the image is unpacked, before Python is loaded, before anything
// in the container has called bind(). Dialling on connect-success gets a socket
// that reads EOF on the first frame, which then looks like a protocol fault rather
// than a race. So wait for the worker's own line instead: it prints READY to stdout
// immediately before accept(), and stdout is this log.
val ready = "field-kratos listening on $port"
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(startupTimeoutMs.toLong())
while (!logTail(log).contains(ready)) {
check(process.isAlive) {
"Container worker exited during startup (${command.first()} run $image): ${logTail(log)}"
}
if (System.nanoTime() > deadline) {
throw SocketTimeoutException(
"Kratos container worker did not bind $port inside the container within " +
"$startupTimeoutMs ms. Emulated x86-64 startup is slow; raise " +
"Container.startupTimeoutMs. ${logTail(log)}"
)
}
Thread.sleep(50)
}
return KratosStarted(Socket(InetAddress.getByName("127.0.0.1"), port), process, remove)
} catch (e: Exception) {
process.destroyForcibly()
remove()
throw e
}
}
}
data class Attach
A worker somebody else started — on a Linux box over an SSH tunnel, in a container run by hand, under a debugger.
Field owns no process here, so KratosSession.close closes the socket and leaves the worker
running. The worker must have been started with --listenport` and the same
[token](#L222) inFIELD_KRATOS_TOKEN`, and its first frame check will reject anything else.
data class Attach(
val host: String = "127.0.0.1",
val port: Int,
val token: String,
) : KratosLaunch {
init { require(port in 1..65535) { "port out of range: $port" } }
override fun token(): String = token
override fun start(config: KratosConfig, token: String, log: Path): KratosStarted
override fun start(config: KratosConfig, token: String, log: Path): KratosStarted {
val socket = Socket()
socket.connect(java.net.InetSocketAddress(host, port), config.startupTimeoutMs)
return KratosStarted(socket, null) {}
}
}
companion object
companion object {
Bind an ephemeral port, note it, and hand it back free.
There is a window between closing this socket and the container runtime binding the same port. Nothing in the protocol can close it — the runtime is a separate process and takes the port on its own schedule — so the failure mode is a startup error naming the port rather than silent misbehaviour.
private fun freePort(): Int =
ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { it.localPort }
private fun environment(env: MutableMap<String, String>, config: KratosConfig, token: String, setup: String?) {
env["FIELD_KRATOS_TOKEN"] = token
env["OMP_NUM_THREADS"] = config.threads.toString()
if (setup != null) env["FIELD_KRATOS_SETUP"] = setup
else config.setupScript?.let { env["FIELD_KRATOS_SETUP"] = it.toAbsolutePath().toString() }
}
internal fun logTail(log: Path): String
The last 4 KiB of the worker log, or empty. Quoted into startup and transport failures.
internal fun logTail(log: Path): String = if (Files.exists(log)) {
Files.newByteChannel(log).use { channel ->
channel.position((channel.size() - 4096).coerceAtLeast(0))
val bytes = java.nio.ByteBuffer.allocate(4096)
channel.read(bytes)
String(bytes.array(), 0, bytes.position())
}
} else ""
}
}
class KratosStarted
What a KratosLaunch hands back: the connected socket, the process Field owns (if any), and a best-effort release for whatever the launch created besides that process.
class KratosStarted(
val socket: Socket,
val process: Process?,
val release: () -> Unit,
)
internal fun containerRuntimeAvailable(runtime: String): Boolean
True when runtime resolves on PATH and reports a version — the cheap half of a container probe.
internal fun containerRuntimeAvailable(runtime: String): Boolean = runCatching {
val process = ProcessBuilder(runtime, "--version")
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
process.waitFor(5, TimeUnit.SECONDS) && process.exitValue() == 0
}.getOrDefault(false)
internal fun containerImagePresent(runtime: String, image: String): Boolean
True when image is already built locally, so a missing image is reported rather than pulled.
internal fun containerImagePresent(runtime: String, image: String): Boolean = runCatching {
val process = ProcessBuilder(runtime, "image", "exists", image)
.redirectOutput(ProcessBuilder.Redirect.DISCARD)
.redirectError(ProcessBuilder.Redirect.DISCARD)
.start()
process.waitFor(10, TimeUnit.SECONDS) && process.exitValue() == 0
}.getOrDefault(false)