From cdc91d7ffdc5b6f606b398423168e1889e86469b Mon Sep 17 00:00:00 2001 From: slhafzjw Date: Tue, 24 Feb 2026 22:48:57 +0800 Subject: [PATCH] feat: add metadata-based script timeouts with validation and request timeout handling --- README.md | 1 + docker-compose.yml | 7 +- .../kotlin/work/slhaf/hub/ScriptEngine.kt | 123 +++++++++++++++++- .../kotlin/work/slhaf/hub/ScriptRuntime.kt | 3 +- .../kotlin/work/slhaf/hub/WebScriptService.kt | 41 +++++- tools/slhaf-hub-tui.kts | 1 + 6 files changed, 166 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fea34f6..d5f8749 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ Run with compose: ```bash # optional: export HOST_API_TOKEN=your-token # optional: export HOST_PORT=8080 +# optional: export MAX_RUN_CONCURRENCY=8 docker compose up -d --build ``` diff --git a/docker-compose.yml b/docker-compose.yml index 13e97c8..1f19581 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,11 @@ services: - "${HOST_PORT:-8080}:8080" environment: HOST_API_TOKEN: ${HOST_API_TOKEN:-} + MAX_RUN_CONCURRENCY: ${MAX_RUN_CONCURRENCY:-} volumes: - ./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:-}" diff --git a/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt b/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt index 94cd067..5df5cad 100644 --- a/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt +++ b/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt @@ -5,6 +5,9 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.PrintStream 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.dependencies.* import kotlin.script.experimental.dependencies.maven.MavenDependenciesResolver @@ -20,6 +23,11 @@ private val metadataCache = ConcurrentHashMap\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? { val value = System.getenv("HOST_SCRIPT_CLASSPATH") ?: return null @@ -108,6 +116,7 @@ private fun injectArgsDeclaration(scriptContent: String, args: List): St private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata { var description: String? = null + var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS val params = mutableListOf() scriptContent.lines().forEach { raw -> @@ -119,6 +128,11 @@ private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata { description = comment.substringAfter(":").trim().takeIf { it.isNotBlank() } 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)) { val payload = comment.substringAfter(":").trim() 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 { + val errors = mutableListOf() + val seenParams = mutableSetOf() + + 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 { @@ -175,7 +261,8 @@ data class ScriptExecutionResult( val ok: Boolean, val output: String, val metadata: ScriptMetadata, - val missingRequiredParams: List + val missingRequiredParams: List, + val timedOut: Boolean = false, ) fun cachedMetadata(scriptFile: File): ScriptMetadata? { @@ -189,6 +276,11 @@ fun removeCachedMetadata(scriptFile: File) { 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 { synchronized(evalLock) { val oldOut = System.out @@ -232,7 +324,8 @@ fun evalAndCapture(scriptFile: File, requestContext: ScriptRequestContext = Scri ok = result is ResultWithDiagnostics.Success && missingRequired.isEmpty(), output = finalText, metadata = metadata, - missingRequiredParams = missingRequired + missingRequiredParams = missingRequired, + timedOut = false, ) } finally { 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 { + 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, + ) + } +} diff --git a/src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt b/src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt index d6c3fbe..bc171f6 100644 --- a/src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt +++ b/src/main/kotlin/work/slhaf/hub/ScriptRuntime.kt @@ -9,7 +9,8 @@ data class ScriptParamDefinition( data class ScriptMetadata( val description: String? = null, - val params: List = emptyList() + val params: List = emptyList(), + val timeoutMs: Long = 10_000L, ) data class ScriptRequestContext( diff --git a/src/main/kotlin/work/slhaf/hub/WebScriptService.kt b/src/main/kotlin/work/slhaf/hub/WebScriptService.kt index b293f0e..911fc72 100644 --- a/src/main/kotlin/work/slhaf/hub/WebScriptService.kt +++ b/src/main/kotlin/work/slhaf/hub/WebScriptService.kt @@ -52,6 +52,16 @@ private fun String.jsonEscaped(): String = buildString(length) { } } +private fun metadataValidationMessage(errors: List): 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 { val description = metadata.description?.let { "\"${it.jsonEscaped()}\"" } ?: "null" 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" """{"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 { val cached = cachedMetadata(script) if (cached != null) return cached to "cache" - - val executed = evalAndCapture(script, ScriptRequestContext(args = emptyList())).metadata - return executed to "parsed" + val parsed = loadMetadataFromComments(script) + return parsed to "comments" } suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) { @@ -83,6 +92,14 @@ suspend fun handleCreateScript(call: ApplicationCall, scriptsDir: File) { if (content.isBlank()) { 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.writeText(content) @@ -139,6 +156,14 @@ suspend fun handleUpdateScript(call: ApplicationCall, scriptsDir: File) { if (newContent.isBlank()) { 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() script.writeText(newContent) @@ -181,10 +206,16 @@ suspend fun handleRunRequest(call: ApplicationCall, scriptsDir: File, consumeBod .toList() 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 { result.ok -> HttpStatusCode.OK + result.timedOut -> HttpStatusCode.RequestTimeout result.missingRequiredParams.isNotEmpty() -> HttpStatusCode.BadRequest else -> HttpStatusCode.InternalServerError } diff --git a/tools/slhaf-hub-tui.kts b/tools/slhaf-hub-tui.kts index acf598f..9a9be91 100755 --- a/tools/slhaf-hub-tui.kts +++ b/tools/slhaf-hub-tui.kts @@ -454,6 +454,7 @@ fun openEditor(oldStty: String, file: File): Pair { fun initialScriptTemplate(name: String): String = """ // @desc: $name +// @timeout: 10s // @param: sample | default=value | desc=example parameter val args: Array = emptyArray()