anotther compiler inference bug andlyng.io.console improvements

This commit is contained in:
sergeych committed 2026-04-03 11:55:25 +03:00
1 parent caad7d8ab9
commit fd6d05d568
7 files changed
+389 -130

No files matched your search

@@ -17,6 +17,7 @@
package net.sergeych.lyng.io.console
import kotlinx.coroutines.delay
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.ScopeFacade
@@ -58,18 +59,31 @@ fun createConsoleModule(policy: ConsoleAccessPolicy, manager: ImportManager): Bo
if (manager.packageNames.contains(CONSOLE_MODULE_NAME)) return false
manager.addPackage(CONSOLE_MODULE_NAME) { module ->
buildConsoleModule(module, policy)
buildConsoleModule(module, policy, getSystemConsole())
}
return true
}
fun createConsole(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean = createConsoleModule(policy, manager)
private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAccessPolicy) {
internal fun createConsoleModule(
policy: ConsoleAccessPolicy,
manager: ImportManager,
console: LyngConsole,
): Boolean {
if (manager.packageNames.contains(CONSOLE_MODULE_NAME)) return false
manager.addPackage(CONSOLE_MODULE_NAME) { module ->
buildConsoleModule(module, policy, console)
}
return true
}
private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAccessPolicy, baseConsole: LyngConsole) {
// Load Lyng declarations for console enums/types first (module-local source of truth).
module.eval(Source(CONSOLE_MODULE_NAME, consoleLyng))
ConsoleEnums.initialize(module)
val console: LyngConsole = LyngConsoleSecured(getSystemConsole(), policy)
val console: LyngConsole = LyngConsoleSecured(baseConsole, policy)
val consoleType = object : net.sergeych.lyng.obj.ObjClass("Console") {}
@@ -179,7 +193,7 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces
addClassFn("events") {
consoleGuard {
console.events().toConsoleEventStream()
ObjConsoleEventStream { console.events() }
}
}
@@ -210,12 +224,8 @@ private suspend inline fun ScopeFacade.consoleGuard(crossinline block: suspend (
}
}
private fun ConsoleEventSource.toConsoleEventStream(): ObjConsoleEventStream {
return ObjConsoleEventStream(this)
}
private class ObjConsoleEventStream(
private val source: ConsoleEventSource,
private val sourceFactory: () -> ConsoleEventSource,
) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass
get() = type
@@ -224,35 +234,61 @@ private class ObjConsoleEventStream(
val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventStream", ObjIterable).apply {
addFn("iterator") {
val stream = thisAs<ObjConsoleEventStream>()
ObjConsoleEventIterator(stream.source)
ObjConsoleEventIterator(stream.sourceFactory)
}
}
}
}
private class ObjConsoleEventIterator(
private val source: ConsoleEventSource,
private val sourceFactory: () -> ConsoleEventSource,
) : Obj() {
private var cached: Obj? = null
private var closed = false
private var source: ConsoleEventSource? = null
override val objClass: net.sergeych.lyng.obj.ObjClass
get() = type
private fun ensureSource(): ConsoleEventSource {
val current = source
if (current != null) return current
return sourceFactory().also { source = it }
}
private suspend fun recycleSource(reason: String, error: Throwable? = null) {
if (error != null) {
consoleFlowDebug(reason, error)
} else {
consoleFlowDebug(reason)
}
val current = source
source = null
runCatching { current?.close() }
.onFailure { consoleFlowDebug("console-bridge: failed to close recycled source", it) }
if (!closed) delay(25)
}
private suspend fun ensureCached(): Boolean {
if (closed) return false
if (cached != null) return true
while (!closed && cached == null) {
val event = try {
source.nextEvent()
val currentSource = try {
ensureSource()
} catch (e: Throwable) {
// Consumer loops must survive source/read failures: report and keep polling.
consoleFlowDebug("console-bridge: nextEvent failed; dropping failure and continuing", e)
recycleSource("console-bridge: source creation failed; retrying", e)
continue
}
val event = try {
currentSource.nextEvent()
} catch (e: Throwable) {
// Consumer loops must survive source/read failures: rebuild the source and keep polling.
recycleSource("console-bridge: nextEvent failed; recycling source", e)
continue
}
if (event == null) {
closeSource()
return false
recycleSource("console-bridge: source ended; recreating")
continue
}
cached = try {
event.toObjEvent()
@@ -268,10 +304,10 @@ private class ObjConsoleEventIterator(
private suspend fun closeSource() {
if (closed) return
closed = true
// Do not close the underlying console source from VM iterator cancellation.
// CmdFrame.cancelIterators() may call cancelIteration() while user code is still
// expected to keep processing input (e.g. recover from app-level exceptions).
// The source lifecycle is managed by the console runtime.
val current = source
source = null
runCatching { current?.close() }
.onFailure { consoleFlowDebug("console-bridge: failed to close iterator source", it) }
}
suspend fun hasNext(): Boolean = ensureCached()
@@ -36,6 +36,97 @@ import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
internal object JvmConsoleKeyDecoder {
fun decode(firstCode: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
if (firstCode == 27) {
val next = nextCode(25L)
if (next == null || next < 0) {
return key("Escape")
}
if (next == '['.code || next == 'O'.code) {
val sb = StringBuilder()
sb.append(next.toChar())
var i = 0
while (i < 6) {
val c = nextCode(25L) ?: break
if (c < 0) break
sb.append(c.toChar())
if (c.toChar().isLetter() || c == '~'.code) break
i += 1
}
return keyFromAnsiSequence(sb.toString()) ?: key("Escape")
}
val base = decodePlainKey(next)
return ConsoleEvent.KeyDown(
key = base.key,
code = base.code,
ctrl = base.ctrl,
alt = true,
shift = base.shift,
meta = false,
)
}
return decodePlainKey(firstCode)
}
private fun decodePlainKey(code: Int): ConsoleEvent.KeyDown = when (code) {
3 -> key("c", ctrl = true)
9 -> key("Tab")
10, 13 -> key("Enter")
127, 8 -> key("Backspace")
32 -> key(" ")
else -> {
if (code in 1..26) {
val ch = ('a'.code + code - 1).toChar().toString()
key(ch, ctrl = true)
} else {
val ch = code.toChar().toString()
key(ch, shift = ch.length == 1 && ch[0].isLetter() && ch[0].isUpperCase())
}
}
}
private fun keyFromAnsiSequence(seq: String): ConsoleEvent.KeyDown? {
val shift = seq.contains(";2")
val alt = seq.contains(";3")
val ctrl = seq.contains(";5")
val key = when {
seq.endsWith("A") -> "ArrowUp"
seq.endsWith("B") -> "ArrowDown"
seq.endsWith("C") -> "ArrowRight"
seq.endsWith("D") -> "ArrowLeft"
seq.endsWith("H") -> "Home"
seq.endsWith("F") -> "End"
seq.endsWith("~") -> when (seq.substringAfter('[').substringBefore(';').substringBefore('~')) {
"2" -> "Insert"
"3" -> "Delete"
"5" -> "PageUp"
"6" -> "PageDown"
else -> null
}
else -> null
} ?: return null
return key(key, ctrl = ctrl, alt = alt, shift = shift)
}
private fun key(
value: String,
ctrl: Boolean = false,
alt: Boolean = false,
shift: Boolean = false,
): ConsoleEvent.KeyDown {
require(value.isNotEmpty()) { "ConsoleEvent.KeyDown.key must never be empty" }
return ConsoleEvent.KeyDown(
key = value,
code = null,
ctrl = ctrl,
alt = alt,
shift = shift,
meta = false,
)
}
}
/**
* JVM console implementation:
* - output/capabilities/input use a single JLine terminal instance
@@ -508,40 +599,7 @@ object JvmLyngConsole : LyngConsole {
val code = reader.read(120L)
if (code == NonBlockingReader.READ_EXPIRED) return null
if (code < 0) throw EOFException("non-blocking reader returned EOF")
return decodeKey(code) { timeout -> readNextCode(reader, timeout) }
}
private fun decodeKey(code: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
if (code == 27) {
val next = nextCode(25L)
if (next == null || next < 0) {
return key("Escape")
}
if (next == '['.code || next == 'O'.code) {
val sb = StringBuilder()
sb.append(next.toChar())
var i = 0
while (i < 6) {
val c = nextCode(25L) ?: break
if (c < 0) break
sb.append(c.toChar())
if (c.toChar().isLetter() || c == '~'.code) break
i += 1
}
return keyFromAnsiSequence(sb.toString()) ?: key("Escape")
}
// Alt+key
val base = decodePlainKey(next)
return ConsoleEvent.KeyDown(
key = base.key,
code = base.code,
ctrl = base.ctrl,
alt = true,
shift = base.shift,
meta = false
)
}
return decodePlainKey(code)
return JvmConsoleKeyDecoder.decode(code) { timeout -> readNextCode(reader, timeout) }
}
private fun readNextCode(reader: NonBlockingReader, timeoutMs: Long): Int? {
@@ -550,53 +608,4 @@ object JvmLyngConsole : LyngConsole {
if (c < 0) throw EOFException("non-blocking reader returned EOF while decoding key sequence")
return c
}
private fun decodePlainKey(code: Int): ConsoleEvent.KeyDown = when (code) {
3 -> key("c", ctrl = true)
9 -> key("Tab")
10, 13 -> key("Enter")
127, 8 -> key("Backspace")
32 -> key(" ")
else -> {
if (code in 1..26) {
val ch = ('a'.code + code - 1).toChar().toString()
key(ch, ctrl = true)
} else {
val ch = code.toChar().toString()
key(ch, shift = ch.length == 1 && ch[0].isLetter() && ch[0].isUpperCase())
}
}
}
private fun keyFromAnsiSequence(seq: String): ConsoleEvent.KeyDown? = when (seq) {
"[A", "OA" -> key("ArrowUp")
"[B", "OB" -> key("ArrowDown")
"[C", "OC" -> key("ArrowRight")
"[D", "OD" -> key("ArrowLeft")
"[H", "OH" -> key("Home")
"[F", "OF" -> key("End")
"[2~" -> key("Insert")
"[3~" -> key("Delete")
"[5~" -> key("PageUp")
"[6~" -> key("PageDown")
else -> null
}
private fun key(
value: String,
ctrl: Boolean = false,
alt: Boolean = false,
shift: Boolean = false,
): ConsoleEvent.KeyDown {
require(value.isNotEmpty()) { "ConsoleEvent.KeyDown.key must never be empty" }
return ConsoleEvent.KeyDown(
key = value,
code = null,
ctrl = ctrl,
alt = alt,
shift = shift,
meta = false
)
}
}
@@ -22,16 +22,14 @@ import net.sergeych.lyng.ExecutionError
import net.sergeych.lyng.Scope
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjIllegalOperationException
import net.sergeych.lyngio.console.*
import net.sergeych.lyngio.console.security.ConsoleAccessOp
import net.sergeych.lyngio.console.security.ConsoleAccessPolicy
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.test.*
class LyngConsoleModuleTest {
@@ -112,4 +110,63 @@ class LyngConsoleModuleTest {
assertIs<ObjIllegalOperationException>(error.errorObject)
}
}
@Test
fun eventsIteratorRecoversAfterSourceFailure() = runBlocking {
val scope = newScope()
var eventsCalls = 0
val console = object : LyngConsole {
override val isSupported: Boolean = true
override suspend fun isTty(): Boolean = true
override suspend fun geometry(): ConsoleGeometry? = null
override suspend fun ansiLevel(): ConsoleAnsiLevel = ConsoleAnsiLevel.NONE
override suspend fun write(text: String) {}
override suspend fun flush() {}
override fun events(): ConsoleEventSource {
eventsCalls += 1
val callNo = eventsCalls
return object : ConsoleEventSource {
private var emitted = false
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
if (emitted) return null
emitted = true
return when (callNo) {
1 -> throw IllegalStateException("synthetic source failure")
else -> ConsoleEvent.KeyDown(key = "x")
}
}
override suspend fun close() {}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean = enabled
}
assertTrue(createConsoleModule(PermitAllConsoleAccessPolicy, scope.importManager, console))
val result = scope.eval(
"""
import lyng.io.console
val it = Console.events().iterator()
assert(it.hasNext())
val ev = it.next()
assert(ev is ConsoleKeyEvent)
assert((ev as ConsoleKeyEvent).key == "x")
true
""".trimIndent()
)
assertIs<ObjBool>(result)
assertTrue(result.value)
assertEquals(2, eventsCalls)
}
}
@@ -0,0 +1,59 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class JvmConsoleKeyDecoderTest {
private fun decode(vararg bytes: Int): ConsoleEvent.KeyDown {
var index = 1
return JvmConsoleKeyDecoder.decode(bytes[0]) { _ ->
if (index >= bytes.size) null else bytes[index++]
}
}
@Test
fun decodesArrowLeft() {
val ev = decode(27, '['.code, 'D'.code)
assertEquals("ArrowLeft", ev.key)
assertFalse(ev.ctrl)
assertFalse(ev.alt)
assertFalse(ev.shift)
}
@Test
fun decodesCtrlArrowRightModifier() {
val ev = decode(27, '['.code, '1'.code, ';'.code, '5'.code, 'C'.code)
assertEquals("ArrowRight", ev.key)
assertTrue(ev.ctrl)
assertFalse(ev.alt)
assertFalse(ev.shift)
}
@Test
fun decodesAltCharacter() {
val ev = decode(27, 'x'.code)
assertEquals("x", ev.key)
assertTrue(ev.alt)
assertFalse(ev.ctrl)
}
}