feat: add metadata-based script timeouts with validation and request timeout handling
This commit is contained in:
@@ -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<String, Pair<String, ScriptMetadat
|
||||
|
||||
private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver())
|
||||
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>? {
|
||||
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 {
|
||||
var description: String? = null
|
||||
var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS
|
||||
val params = mutableListOf<ScriptParamDefinition>()
|
||||
|
||||
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<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 {
|
||||
@@ -175,7 +261,8 @@ data class ScriptExecutionResult(
|
||||
val ok: Boolean,
|
||||
val output: String,
|
||||
val metadata: ScriptMetadata,
|
||||
val missingRequiredParams: List<String>
|
||||
val missingRequiredParams: List<String>,
|
||||
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<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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ data class ScriptParamDefinition(
|
||||
|
||||
data class ScriptMetadata(
|
||||
val description: String? = null,
|
||||
val params: List<ScriptParamDefinition> = emptyList()
|
||||
val params: List<ScriptParamDefinition> = emptyList(),
|
||||
val timeoutMs: Long = 10_000L,
|
||||
)
|
||||
|
||||
data class ScriptRequestContext(
|
||||
|
||||
@@ -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 {
|
||||
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<ScriptMetadata, String> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user