feat: add metadata-based script timeouts with validation and request timeout handling

This commit is contained in:
2026-02-24 22:48:57 +08:00
parent 97b337e143
commit cdc91d7ffd
6 changed files with 166 additions and 10 deletions

View File

@@ -165,6 +165,7 @@ Run with compose:
```bash ```bash
# optional: export HOST_API_TOKEN=your-token # optional: export HOST_API_TOKEN=your-token
# optional: export HOST_PORT=8080 # optional: export HOST_PORT=8080
# optional: export MAX_RUN_CONCURRENCY=8
docker compose up -d --build docker compose up -d --build
``` ```

View File

@@ -13,6 +13,11 @@ services:
- "${HOST_PORT:-8080}:8080" - "${HOST_PORT:-8080}:8080"
environment: environment:
HOST_API_TOKEN: ${HOST_API_TOKEN:-} HOST_API_TOKEN: ${HOST_API_TOKEN:-}
MAX_RUN_CONCURRENCY: ${MAX_RUN_CONCURRENCY:-}
volumes: volumes:
- ./scripts:/app/scripts - ./scripts:/app/scripts
command: ["--host=0.0.0.0", "--port=8080", "--scripts-dir=/app/scripts"] command:
- "--host=0.0.0.0"
- "--port=8080"
- "--scripts-dir=/app/scripts"
- "--max-run-concurrency=${MAX_RUN_CONCURRENCY:-}"

View File

@@ -5,6 +5,9 @@ import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.PrintStream import java.io.PrintStream
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import kotlin.script.experimental.api.* import kotlin.script.experimental.api.*
import kotlin.script.experimental.dependencies.* import kotlin.script.experimental.dependencies.*
import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver
@@ -20,6 +23,11 @@ private val metadataCache = ConcurrentHashMap<String, Pair<String, ScriptMetadat
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver()) private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$""") private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array<String>\s*=\s*emptyArray\(\)\s*$""")
private const val DEFAULT_SCRIPT_TIMEOUT_MS = 10_000L
private val metadataParamNameRegex = Regex("[A-Za-z0-9._-]+")
private val evalExecutor = Executors.newCachedThreadPool { r ->
Thread(r, "script-eval-worker").apply { isDaemon = true }
}
fun explicitClasspathFromEnv(): List<File>? { fun explicitClasspathFromEnv(): List<File>? {
val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null
@@ -108,6 +116,7 @@ private fun injectArgsDeclaration(scriptContent: String, args: List<String>): St
private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata { private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
var description: String? = null var description: String? = null
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
val params = mutableListOf<ScriptParamDefinition>() val params = mutableListOf<ScriptParamDefinition>()
scriptContent.lines().forEach { raw -> scriptContent.lines().forEach { raw ->
@@ -119,6 +128,11 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() } description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() }
return@forEach return@forEach
} }
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val raw = comment.substringAfter(":").trim()
parseTimeoutMs(raw)?.let { timeoutMs = it }
return@forEach
}
if (comment.startsWith("@param:", ignoreCase = true)) { if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim() val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) return@forEach if (payload.isBlank()) return@forEach
@@ -152,7 +166,79 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata {
} }
} }
return ScriptMetadata(description = description, params = params) return ScriptMetadata(description = description, params = params, timeoutMs = timeoutMs)
}
fun validateScriptMetadata(scriptContent: String): List<String> {
val errors = mutableListOf<String>()
val seenParams = mutableSetOf<String>()
scriptContent.lines().forEachIndexed { idx, raw ->
val lineNo = idx + 1
val line = raw.trim()
if (!line.startsWith("//")) return@forEachIndexed
val comment = line.removePrefix("//").trim()
if (comment.startsWith("@timeout:", ignoreCase = true)) {
val rawTimeout = comment.substringAfter(":").trim()
if (parseTimeoutMs(rawTimeout) == null) {
errors += "line $lineNo: invalid @timeout '$rawTimeout'. expected format: '@timeout: 10s' or '500ms' or '1m'."
}
return@forEachIndexed
}
if (comment.startsWith("@param:", ignoreCase = true)) {
val payload = comment.substringAfter(":").trim()
if (payload.isBlank()) {
errors += "line $lineNo: empty @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val parts = payload.split("|").map { it.trim() }.filter { it.isNotBlank() }
if (parts.isEmpty()) {
errors += "line $lineNo: invalid @param. expected format: '@param: name | required=true|false | default=value | desc=text'."
return@forEachIndexed
}
val name = parts.first()
if (!metadataParamNameRegex.matches(name)) {
errors += "line $lineNo: invalid param name '$name'. allowed pattern: [A-Za-z0-9._-]+."
}
if (!seenParams.add(name)) {
errors += "line $lineNo: duplicate @param name '$name'. param names must be unique."
}
parts.drop(1).forEach { part ->
when {
part.equals("required", ignoreCase = true) -> Unit
part.startsWith("required=", ignoreCase = true) -> {
val v = part.substringAfter("=").trim()
if (!v.equals("true", ignoreCase = true) && !v.equals("false", ignoreCase = true)) {
errors += "line $lineNo: invalid required value '$v'. expected true/false."
}
}
part.startsWith("default=", ignoreCase = true) -> Unit
part.startsWith("desc=", ignoreCase = true) -> Unit
else -> {
errors += "line $lineNo: unsupported @param option '$part'. supported: required=, default=, desc=."
}
}
}
}
}
return errors
}
private fun parseTimeoutMs(raw: String): Long? {
if (raw.isBlank()) return null
val v = raw.trim().lowercase()
return when {
v.endsWith("ms") -> v.removeSuffix("ms").trim().toLongOrNull()
v.endsWith("s") -> v.removeSuffix("s").trim().toLongOrNull()?.times(1000)
v.endsWith("m") -> v.removeSuffix("m").trim().toLongOrNull()?.times(60_000)
else -> v.toLongOrNull()?.times(1000)
}?.takeIf { it > 0 }
} }
private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata { private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMetadata {
@@ -175,7 +261,8 @@ data class ScriptExecutionResult(
val ok: Boolean, val ok: Boolean,
val output: String, val output: String,
val metadata: ScriptMetadata, val metadata: ScriptMetadata,
val missingRequiredParams: List<String> val missingRequiredParams: List<String>,
val timedOut: Boolean = false,
) )
fun cachedMetadata(scriptFile: File): ScriptMetadata? { fun cachedMetadata(scriptFile: File): ScriptMetadata? {
@@ -189,6 +276,11 @@ fun removeCachedMetadata(scriptFile: File) {
metadataCache.remove(scriptFile.canonicalPath) metadataCache.remove(scriptFile.canonicalPath)
} }
fun loadMetadataFromComments(scriptFile: File): ScriptMetadata {
val content = scriptFile.readText()
return metadataForFile(scriptFile, content)
}
fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult { fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = ScriptRequestContext()): ScriptExecutionResult {
synchronized(evalLock) { synchronized(evalLock) {
val oldOut = System.out val oldOut = System.out
@@ -232,7 +324,8 @@ fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = Scri
ok = result is ResultWithDiagnostics.Success && missingRequired.isEmpty(), ok = result is ResultWithDiagnostics.Success && missingRequired.isEmpty(),
output = finalText, output = finalText,
metadata = metadata, metadata = metadata,
missingRequiredParams = missingRequired missingRequiredParams = missingRequired,
timedOut = false,
) )
} finally { } finally {
ps.flush() ps.flush()
@@ -242,3 +335,27 @@ fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = Scri
} }
} }
} }
fun evalAndCaptureWithTimeout(
scriptFile: File,
requestContext: ScriptRequestContext = ScriptRequestContext(),
timeoutMs: Long,
): ScriptExecutionResult {
val boundedTimeout = timeoutMs.coerceAtLeast(1)
val future = evalExecutor.submit<ScriptExecutionResult> {
evalAndCapture(scriptFile, requestContext)
}
return try {
future.get(boundedTimeout, TimeUnit.MILLISECONDS)
} catch (_: TimeoutException) {
future.cancel(true)
val metadata = loadMetadataFromComments(scriptFile)
ScriptExecutionResult(
ok = false,
output = "[ERROR] Script execution timed out after ${boundedTimeout}ms",
metadata = metadata,
missingRequiredParams = emptyList(),
timedOut = true,
)
}
}

View File

@@ -9,7 +9,8 @@ data class ScriptParamDefinition(
data class ScriptMetadata( data class ScriptMetadata(
val description: String? = null, val description: String? = null,
val params: List<ScriptParamDefinition> = emptyList() val params: List<ScriptParamDefinition> = emptyList(),
val timeoutMs: Long = 10_000L,
) )
data class ScriptRequestContext( data class ScriptRequestContext(

View File

@@ -52,6 +52,16 @@ private fun String.jsonEscaped(): String = buildString(length) {
} }
} }
private fun metadataValidationMessage(errors: List<String>): String =
buildString {
appendLine("metadata validation failed:")
errors.forEach { appendLine("- $it") }
appendLine("examples:")
appendLine("// @desc: Demo greeting API")
appendLine("// @timeout: 10s")
appendLine("// @param: name | required=false | default=world | desc=Name to greet")
}.trim()
fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String { fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String): String {
val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null" val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
val params = metadata.params.joinToString(",") { param -> val params = metadata.params.joinToString(",") { param ->
@@ -59,15 +69,14 @@ fun metadataJson(scriptName: String, metadata: ScriptMetadata, source: String):
val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null" val desc = param.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null"
"""{"name":"${param.name.jsonEscaped()}","required":${param.required},"defaultValue":$defaultValue,"description":$desc}""" """{"name":"${param.name.jsonEscaped()}","required":${param.required},"defaultValue":$defaultValue,"description":$desc}"""
} }
return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"params":[$params]}""" return """{"script":"${scriptName.jsonEscaped()}","source":"${source.jsonEscaped()}","description":$description,"timeoutMs":${metadata.timeoutMs},"params":[$params]}"""
} }
fun loadMetadata(script: File): Pair<ScriptMetadata, String> { fun loadMetadata(script: File): Pair<ScriptMetadata, String> {
val cached = cachedMetadata(script) val cached = cachedMetadata(script)
if (cached != null) return cached to "cache" if (cached != null) return cached to "cache"
val parsed = loadMetadataFromComments(script)
val executed = evalAndCapture(script, ScriptRequestContext(args = emptyList())).metadata return parsed to "comments"
return executed to "parsed"
} }
suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) { suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
@@ -83,6 +92,14 @@ suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) {
if (content.isBlank()) { if (content.isBlank()) {
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest) return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
} }
val metadataErrors = validateScriptMetadata(content)
if (metadataErrors.isNotEmpty()) {
return call.respondText(
metadataValidationMessage(metadataErrors),
status = HttpStatusCode.BadRequest,
contentType = ContentType.Text.Plain
)
}
script.parentFile?.mkdirs() script.parentFile?.mkdirs()
script.writeText(content) script.writeText(content)
@@ -139,6 +156,14 @@ suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) {
if (newContent.isBlank()) { if (newContent.isBlank()) {
return call.respondText("script content is empty", status = HttpStatusCode.BadRequest) return call.respondText("script content is empty", status = HttpStatusCode.BadRequest)
} }
val metadataErrors = validateScriptMetadata(newContent)
if (metadataErrors.isNotEmpty()) {
return call.respondText(
metadataValidationMessage(metadataErrors),
status = HttpStatusCode.BadRequest,
contentType = ContentType.Text.Plain
)
}
val previousContent = script.readText() val previousContent = script.readText()
script.writeText(newContent) script.writeText(newContent)
@@ -181,10 +206,16 @@ suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBod
.toList() .toList()
val requestBody = if (consumeBody) call.receiveText() else null val requestBody = if (consumeBody) call.receiveText() else null
val result = evalAndCapture(script, ScriptRequestContext(args = requestArgs, body = requestBody)) val metadata = loadMetadataFromComments(script)
val result = evalAndCaptureWithTimeout(
script,
ScriptRequestContext(args = requestArgs, body = requestBody),
timeoutMs = metadata.timeoutMs,
)
val status = when { val status = when {
result.ok -> HttpStatusCode.OK result.ok -> HttpStatusCode.OK
result.timedOut -> HttpStatusCode.RequestTimeout
result.missingRequiredParams.isNotEmpty() -> HttpStatusCode.BadRequest result.missingRequiredParams.isNotEmpty() -> HttpStatusCode.BadRequest
else -> HttpStatusCode.InternalServerError else -> HttpStatusCode.InternalServerError
} }

View File

@@ -454,6 +454,7 @@ fun openEditor(oldStty: String, file: File): Pair<Boolean, String> {
fun initialScriptTemplate(name: String): String = fun initialScriptTemplate(name: String): String =
""" """
// @desc: $name // @desc: $name
// @timeout: 10s
// @param: sample | default=value | desc=example parameter // @param: sample | default=value | desc=example parameter
val args: Array<String> = emptyArray() val args: Array<String> = emptyArray()