From 66ab7db3d8a8e84b35fca44c90a8bbf52b993fe7 Mon Sep 17 00:00:00 2001 From: sergeych Date: Tue, 13 Oct 2026 02:19:56 +0400 Subject: [PATCH] Fix multiline list parsing and expose actionable HTTP failures --- docs/ai_language_reference.md | 2 ++ docs/lyng.io.http.md | 14 ++++++++ lyngio/build.gradle.kts | 2 +- .../sergeych/lyng/io/http/LyngHttpModule.kt | 6 +++- .../lyngio/http/HttpRequestFailure.kt | 34 +++++++++++++++++++ .../sergeych/lyngio/http/KtorHttpEngine.kt | 26 +++++++++----- .../lyngio/http/HttpRequestFailureTest.kt | 25 ++++++++++++++ lyngio/stdlib/lyng/io/http.lyng | 10 +++++- lynglib/build.gradle.kts | 2 +- .../kotlin/net/sergeych/lyng/Compiler.kt | 1 + .../kotlin/MultilineListLiteralTest.kt | 21 ++++++++++++ 11 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/HttpRequestFailure.kt create mode 100644 lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/HttpRequestFailureTest.kt create mode 100644 lynglib/src/commonTest/kotlin/MultilineListLiteralTest.kt diff --git a/docs/ai_language_reference.md b/docs/ai_language_reference.md index c7013ee..a90e0a6 100644 --- a/docs/ai_language_reference.md +++ b/docs/ai_language_reference.md @@ -43,6 +43,8 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T ## 3. Literals and Core Expressions - Scalars: `null`, `true`, `false`, `void`. +- List literals allow multiline layout, comments between entries and trailing commas. + Newlines before the closing `]` are whitespace, including in nested and empty lists. - List literal: `[a, b, c]`, spreads with `...`. - Spread positions: beginning, middle, end are all valid: `[...a]`, `[0, ...a, 4]`, `[head, ...mid, tail]`. - Spread source must be a `List` at runtime (non-list spread raises an error). diff --git a/docs/lyng.io.http.md b/docs/lyng.io.http.md index 160b626..ae9420e 100644 --- a/docs/lyng.io.http.md +++ b/docs/lyng.io.http.md @@ -194,3 +194,17 @@ val allowLocalOnly = object : HttpAccessPolicy { - **Windows native:** supported via the Ktor WinHttp client backend - **Apple native:** supported via the Ktor Darwin client backend - **Other targets:** may report unsupported; use `Http.isSupported()` before relying on it + +## Request failure diagnostics + +Transport exceptions report the request method and target, a category, the +underlying exception/cause chain, and recovery advice. Query strings and userinfo +are redacted from the diagnostic target. Categories are `HTTP_TLS_FAILED`, +`HTTP_TIMEOUT`, `HTTP_DNS_FAILED`, `HTTP_CONNECTION_FAILED` and +`HTTP_REQUEST_FAILED`. Permission denials are `HTTP_ACCESS_DENIED`; redirect +limit errors retain `HTTP_REDIRECT_LIMIT_EXCEEDED`. + +`HTTP_TLS_FAILED` means the handshake failed before an HTTP response. Do not +retry the same URL unchanged; use another source. A remote TLS fatal alert is +not a Lyng syntax error. Non-2xx responses remain ordinary response objects, and +request cancellation propagates rather than becoming a generic HTTP error. diff --git a/lyngio/build.gradle.kts b/lyngio/build.gradle.kts index 7fcad40..d338a99 100644 --- a/lyngio/build.gradle.kts +++ b/lyngio/build.gradle.kts @@ -29,7 +29,7 @@ plugins { } group = "net.sergeych" -version = "0.0.1-SNAPSHOT" +version = providers.gradleProperty("lyngio.version").getOrElse("0.0.1-SNAPSHOT") private fun Project.sqliteLinuxLinkerOpts(vararg defaultDirs: String): List { val overrideDir = providers.gradleProperty("sqlite3.lib.dir").orNull diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt index 96784a6..baa22c6 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/http/LyngHttpModule.kt @@ -17,6 +17,8 @@ package net.sergeych.lyng.io.http +import kotlinx.coroutines.CancellationException + import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.Scope import net.sergeych.lyng.ScopeFacade @@ -164,8 +166,10 @@ private suspend fun buildHttpModule(module: ModuleScope, policy: HttpAccessPolic private suspend inline fun ScopeFacade.httpGuard(crossinline block: suspend () -> Obj): Obj { return try { block() + } catch (e: CancellationException) { + throw e } catch (e: HttpAccessDeniedException) { - raiseIllegalOperation(e.reasonDetail ?: "http access denied") + raiseIllegalOperation("HTTP_ACCESS_DENIED: " + (e.reasonDetail ?: "http access denied")) } catch (e: Exception) { raiseIllegalOperation(e.message ?: "http error") } diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/HttpRequestFailure.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/HttpRequestFailure.kt new file mode 100644 index 0000000..166a999 --- /dev/null +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/HttpRequestFailure.kt @@ -0,0 +1,34 @@ +package net.sergeych.lyngio.http + +/** Transport diagnostics, preserved by the Lyng bridge instead of a generic "http error". */ +class LyngHttpRequestException(request: LyngHttpRequest, cause: Throwable) : + IllegalStateException(describeHttpFailure(request, cause), cause) + +internal fun describeHttpFailure(request: LyngHttpRequest, cause: Throwable): String { + val causes = mutableListOf() + var current: Throwable? = cause + repeat(8) { + val item = current ?: return@repeat + if (causes.any { it === item }) return@repeat + causes += item + current = item.cause + } + val details = causes.joinToString(" <- ") { "${it::class.simpleName ?: "Exception"}: ${it.message ?: "no detail"}" } + val text = details.lowercase() + val (code, advice) = when { + "http_redirect_limit_exceeded" in text -> "HTTP_REDIRECT_LIMIT_EXCEEDED" to + "Do not retry this URL unchanged; inspect the redirect chain or use another source." + "tls" in text || "ssl" in text || "handshake" in text || "certificate" in text -> "HTTP_TLS_FAILED" to + "The HTTPS handshake failed before an HTTP response. Do not retry this URL unchanged; changing its path/query will not repair TLS. Use another HTTPS host or source." + "timeout" in text || "timed out" in text -> "HTTP_TIMEOUT" to + "The request exceeded a time limit. At most a bounded retry may help; avoid repeating it indefinitely." + "unknownhost" in text || "unresolvedaddress" in text || "name or service not known" in text -> "HTTP_DNS_FAILED" to + "The hostname could not be resolved. Verify the hostname or use another source." + "connectexception" in text || "connection refused" in text || "connection reset" in text -> "HTTP_CONNECTION_FAILED" to + "The connection failed before a usable response. Verify the endpoint; avoid repeated unchanged requests." + else -> "HTTP_REQUEST_FAILED" to "Use the cause details to choose another source or a different approach." + } + // Query strings and userinfo can contain credentials; the script already knows the full URL. + val target = request.url.substringBefore('?').substringBefore('#').replace(Regex("//[^/@]+@"), "//[redacted]@") + return "$code: ${request.method.uppercase()} $target\nCause: $details\n$advice" +} diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt index a2803b4..0c8df5b 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/http/KtorHttpEngine.kt @@ -5,6 +5,8 @@ import io.ktor.client.call.body import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.plugins.timeout +import io.ktor.client.plugins.HttpTimeout +import kotlinx.coroutines.CancellationException import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.request import io.ktor.client.request.setBody @@ -25,6 +27,7 @@ private class KtorLyngHttpEngine( HttpClient(engineFactory) { expectSuccess = false followRedirects = false + install(HttpTimeout) install(CappedHttpRedirect) { maxRedirects = config.maxRedirects } @@ -39,15 +42,22 @@ private class KtorLyngHttpEngine( throw UnsupportedOperationException(it.message ?: "HTTP client is not supported") } - val response = httpClient.request { - applyRequest(request) + try { + val response = httpClient.request { applyRequest(request) } + return LyngHttpResponse( + status = response.status.value, + statusText = response.status.description, + headers = response.headers.entries().associate { it.key to it.value.toList() }, + bodyBytes = response.body(), + ) + } catch (e: CancellationException) { + throw e + } catch (e: HttpRedirectLimitExceededException) { + // Keep the existing typed redirect-limit contract for host callers. + throw e + } catch (e: Exception) { + throw LyngHttpRequestException(request, e) } - return LyngHttpResponse( - status = response.status.value, - statusText = response.status.description, - headers = response.headers.entries().associate { it.key to it.value.toList() }, - bodyBytes = response.body(), - ) } private fun HttpRequestBuilder.applyRequest(request: LyngHttpRequest) { diff --git a/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/HttpRequestFailureTest.kt b/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/HttpRequestFailureTest.kt new file mode 100644 index 0000000..3ee75a7 --- /dev/null +++ b/lyngio/src/commonTest/kotlin/net/sergeych/lyngio/http/HttpRequestFailureTest.kt @@ -0,0 +1,25 @@ +package net.sergeych.lyngio.http + +import kotlin.test.* + +class HttpRequestFailureTest { + private val request = LyngHttpRequest("GET", "https://user:secret@example.com/page?key=private#fragment") + @Test fun savedFatalTlsAlertGetsSpecificCauseAndNoUnchangedRetryAdvice() { + val saved = IllegalStateException("Received alert during handshake. Level: FATAL, code: InternalError") + val message = LyngHttpRequestException(request, saved).message!! + assertTrue(message.startsWith("HTTP_TLS_FAILED: GET https://[redacted]@example.com/page")) + assertTrue(message.contains("FATAL, code: InternalError")) + assertTrue(message.contains("Do not retry this URL unchanged")) + assertTrue(message.contains("changing its path/query will not repair TLS")) + assertFalse(message.contains("secret")); assertFalse(message.contains("private")) + } + @Test fun nestedCausesAndTimeoutDnsAndConnectionFailuresRemainDistinct() { + val nested = IllegalStateException("request failed", IllegalArgumentException("TLS certificate rejected")) + assertTrue(describeHttpFailure(request, nested).contains("HTTP_TLS_FAILED")) + for ((detail, code) in listOf("request timed out" to "HTTP_TIMEOUT", + "UnknownHostException: example.com" to "HTTP_DNS_FAILED", + "Connection refused" to "HTTP_CONNECTION_FAILED")) { + assertTrue(describeHttpFailure(request, IllegalStateException(detail)).startsWith(code)) + } + } +} diff --git a/lyngio/stdlib/lyng/io/http.lyng b/lyngio/stdlib/lyng/io/http.lyng index 83726e8..21b12a0 100644 --- a/lyngio/stdlib/lyng/io/http.lyng +++ b/lyngio/stdlib/lyng/io/http.lyng @@ -32,7 +32,15 @@ extern class HttpResponse { fun bytes(): Buffer } -/* HTTP/HTTPS client singleton. */ +/* + HTTP/HTTPS client singleton. + Transport failures include a diagnostic code, request method/URL, underlying cause, + and recovery advice: HTTP_TLS_FAILED, HTTP_TIMEOUT, HTTP_DNS_FAILED, + HTTP_CONNECTION_FAILED, HTTP_REQUEST_FAILED. HTTP_ACCESS_DENIED means no grant. + HTTP_TLS_FAILED occurs before any response: do not retry the URL unchanged; + use another source. Query strings/userinfo are redacted from the diagnostic target. + Non-2xx HTTP statuses are returned normally. Cancellation propagates to the host. +*/ extern object Http { /* True when HTTP client support is available on this runtime. */ fun isSupported(): Bool diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index 505de24..3058ed0 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget group = "net.sergeych" -version = "1.5.10" +version = providers.gradleProperty("lyng.version").getOrElse("1.5.10") // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 995670e..6ce4e2e 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -4021,6 +4021,7 @@ class Compiler( while (true) { val t = cc.next() when (t.type) { + Token.Type.NEWLINE, Token.Type.SINGLE_LINE_COMMENT, Token.Type.MULTILINE_COMMENT -> continue Token.Type.COMMA -> { // todo: check commas sequences like [,] [,,] before, after or instead of expressions } diff --git a/lynglib/src/commonTest/kotlin/MultilineListLiteralTest.kt b/lynglib/src/commonTest/kotlin/MultilineListLiteralTest.kt new file mode 100644 index 0000000..3397b4c --- /dev/null +++ b/lynglib/src/commonTest/kotlin/MultilineListLiteralTest.kt @@ -0,0 +1,21 @@ +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.Script +import kotlin.test.* + +class MultilineListLiteralTest { + @Test fun finalNewlineCommentsAndTrailingCommaAreWhitespace() = runTest { + val scope = Script.newScope() + for (source in listOf( + "[\n \"first\",\n \"second\"\n]", + "[\n \"first\",\n \"second\",\n]", + "[\n // before\n \"first\", /* between */\n \"second\" // after\n]", + )) assertEquals("[first,second]", scope.eval(source).toString(scope).value) + } + @Test fun nestedAndEmptyMultilineListsAndExpressionsRemainValid() = runTest { + val scope = Script.newScope() + assertEquals("[]", scope.eval("[\n]").toString(scope).value) + assertEquals("[[1,2],[3,4]]", scope.eval("[\n [1,2],\n [3,4]\n]").toString(scope).value) + assertEquals("[3,7]", scope.eval("[\n 1+2,\n 3+4\n]").toString(scope).value) + assertFails { scope.eval("[\n @\n]") } + } +}