fixed terminal problems in lyng CLI with new console support on linux

This commit is contained in:
Sergey Chernov 2026-04-03 12:12:55 +03:00
parent fd6d05d568
commit 2414da59a7
3 changed files with 127 additions and 15 deletions

View File

@ -22,11 +22,14 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import platform.posix.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource
internal actual fun getNativeSystemConsole(): LyngConsole = LinuxPosixLyngConsole
private const val RAW_IDLE_POLL_MS = 10L
private const val NON_RAW_IDLE_POLL_MS = 25L
private const val ESCAPE_FOLLOWUP_TIMEOUT_MS = 25L
internal object LinuxConsoleKeyDecoder {
fun decode(firstCode: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
if (firstCode == 27) {
@ -132,6 +135,7 @@ object LinuxPosixLyngConsole : LyngConsole {
}
}
consoleFlowDebug("linux-events: source created")
return object : ConsoleEventSource {
var closed = false
var lastGeometry: ConsoleGeometry? = null
@ -147,23 +151,27 @@ object LinuxPosixLyngConsole : LyngConsole {
}
val rawRequested = stateMutex.withLock { rawModeRequested }
val pollSliceMs = if (timeoutMs <= 0L) 250L else minOf(250L, timeoutMs)
if (rawRequested) {
val ev = readKeyEvent(pollSliceMs)
val ev = readKeyEventNonBlocking()
if (ev != null) return ev
} else {
delay(25)
}
if (timeoutMs > 0L && started.elapsedNow() >= timeoutMs.milliseconds) {
val elapsedMs = started.elapsedNow().inWholeMilliseconds
if (timeoutMs > 0L && elapsedMs >= timeoutMs) {
return null
}
val remainingMs = if (timeoutMs > 0L) timeoutMs - elapsedMs else Long.MAX_VALUE
val idleMs = if (rawRequested) RAW_IDLE_POLL_MS else NON_RAW_IDLE_POLL_MS
val sleepMs = if (timeoutMs > 0L) minOf(idleMs, remainingMs) else idleMs
if (sleepMs > 0L) delay(sleepMs)
}
return null
}
override suspend fun close() {
closed = true
consoleFlowDebug("linux-events: source closed")
}
}
}
@ -183,12 +191,11 @@ object LinuxPosixLyngConsole : LyngConsole {
}
savedAttrsBlob = saved
attrs.c_lflag = attrs.c_lflag and ICANON.convert<UInt>().inv() and ECHO.convert<UInt>().inv()
attrs.c_iflag = attrs.c_iflag and IXON.convert<UInt>().inv() and ISTRIP.convert<UInt>().inv()
attrs.c_oflag = attrs.c_oflag and OPOST.convert<UInt>().inv()
configureRawInput(attrs)
if (tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr) != 0) return@withLock false
}
rawModeRequested = true
consoleFlowDebug("linux-events: setRawMode(true): enabled")
true
} else {
val hadRaw = rawModeRequested
@ -203,6 +210,7 @@ object LinuxPosixLyngConsole : LyngConsole {
tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr)
}
}
consoleFlowDebug("linux-events: setRawMode(false): disabled hadRaw=$hadRaw")
hadRaw
}
}
@ -225,19 +233,55 @@ object LinuxPosixLyngConsole : LyngConsole {
val ready = poll(pfd.ptr, 1.convert(), timeoutMs.toInt())
if (ready <= 0) return null
readReadyByte()
}
private fun readReadyByte(): Int? {
val buf = ByteArray(1)
val count = buf.usePinned { pinned ->
read(STDIN_FILENO, pinned.addressOf(0), 1.convert())
}
if (count <= 0) return null
if (count <= 0) {
if (count < 0) {
consoleFlowDebug("linux-events: stdin read returned $count errno=$errno")
}
return null
}
val b = buf[0].toInt()
if (b < 0) b + 256 else b
return if (b < 0) b + 256 else b
}
private fun readKeyEvent(timeoutMs: Long): ConsoleEvent.KeyDown? {
val first = readByte(timeoutMs) ?: return null
private fun readByteNow(): Int? = memScoped {
val pfd = alloc<pollfd>()
pfd.fd = STDIN_FILENO
pfd.events = POLLIN.convert()
pfd.revents = 0
val ready = poll(pfd.ptr, 1.convert(), 0)
if (ready <= 0) return null
readReadyByte()
}
private fun readKeyEventNonBlocking(): ConsoleEvent.KeyDown? {
val first = readByteNow() ?: return null
return LinuxConsoleKeyDecoder.decode(first) { timeout ->
readByte(timeout)
readByte(minOf(timeout, ESCAPE_FOLLOWUP_TIMEOUT_MS))
}
}
}
@OptIn(ExperimentalForeignApi::class)
internal fun configureRawInput(attrs: termios) {
attrs.c_iflag = attrs.c_iflag and BRKINT.convert<UInt>().inv()
attrs.c_iflag = attrs.c_iflag and ICRNL.convert<UInt>().inv()
attrs.c_iflag = attrs.c_iflag and INPCK.convert<UInt>().inv()
attrs.c_iflag = attrs.c_iflag and ISTRIP.convert<UInt>().inv()
attrs.c_iflag = attrs.c_iflag and IXON.convert<UInt>().inv()
attrs.c_oflag = attrs.c_oflag and OPOST.convert<UInt>().inv()
attrs.c_cflag = attrs.c_cflag or CS8.convert<UInt>()
attrs.c_lflag = attrs.c_lflag and ECHO.convert<UInt>().inv()
attrs.c_lflag = attrs.c_lflag and ICANON.convert<UInt>().inv()
attrs.c_lflag = attrs.c_lflag and IEXTEN.convert<UInt>().inv()
attrs.c_lflag = attrs.c_lflag and ISIG.convert<UInt>().inv()
attrs.c_cc[VMIN] = 0u
attrs.c_cc[VTIME] = 1u
}

View File

@ -17,6 +17,8 @@
package net.sergeych.lyngio.console
import kotlinx.cinterop.*
import platform.posix.*
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@ -64,4 +66,41 @@ class LinuxPosixLyngConsoleTest {
assertEquals("A", ev.key)
assertTrue(ev.shift)
}
@OptIn(ExperimentalForeignApi::class)
@Test
fun configuresRawModeReadSemantics() = memScoped {
val attrs = alloc<termios>()
attrs.c_iflag =
BRKINT.convert<UInt>() or
ICRNL.convert<UInt>() or
INPCK.convert<UInt>() or
ISTRIP.convert<UInt>() or
IXON.convert<UInt>()
attrs.c_oflag = OPOST.convert<UInt>()
attrs.c_cflag = 0u
attrs.c_lflag =
ECHO.convert<UInt>() or
ICANON.convert<UInt>() or
IEXTEN.convert<UInt>() or
ISIG.convert<UInt>()
attrs.c_cc[VMIN] = 9u
attrs.c_cc[VTIME] = 9u
configureRawInput(attrs)
assertEquals(0u, attrs.c_iflag and BRKINT.convert<UInt>())
assertEquals(0u, attrs.c_iflag and ICRNL.convert<UInt>())
assertEquals(0u, attrs.c_iflag and INPCK.convert<UInt>())
assertEquals(0u, attrs.c_iflag and ISTRIP.convert<UInt>())
assertEquals(0u, attrs.c_iflag and IXON.convert<UInt>())
assertEquals(0u, attrs.c_oflag and OPOST.convert<UInt>())
assertEquals(CS8.convert<UInt>(), attrs.c_cflag and CS8.convert<UInt>())
assertEquals(0u, attrs.c_lflag and ECHO.convert<UInt>())
assertEquals(0u, attrs.c_lflag and ICANON.convert<UInt>())
assertEquals(0u, attrs.c_lflag and IEXTEN.convert<UInt>())
assertEquals(0u, attrs.c_lflag and ISIG.convert<UInt>())
assertEquals(0u, attrs.c_cc[VMIN].toUInt())
assertEquals(1u, attrs.c_cc[VTIME].toUInt())
}
}

View File

@ -17,6 +17,35 @@
package net.sergeych.lyngio.console
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.toKString
import platform.posix.fclose
import platform.posix.fopen
import platform.posix.fputs
import platform.posix.getenv
@OptIn(ExperimentalForeignApi::class)
private val flowDebugLogFilePath: String
get() = getenv("LYNG_CONSOLE_DEBUG_LOG")?.toKString()?.takeIf { it.isNotBlank() }
?: "/tmp/lyng_console_flow_debug.log"
@OptIn(ExperimentalForeignApi::class)
internal actual fun consoleFlowDebug(message: String, error: Throwable?) {
// no-op on Native
runCatching {
val line = buildString {
append("[console-flow] ")
append(message)
append('\n')
if (error != null) {
append(error.toString())
append('\n')
}
}
val file = fopen(flowDebugLogFilePath, "ab") ?: return
try {
fputs(line, file)
} finally {
fclose(file)
}
}
}