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.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import platform.posix.* import platform.posix.*
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource import kotlin.time.TimeSource
internal actual fun getNativeSystemConsole(): LyngConsole = LinuxPosixLyngConsole 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 { internal object LinuxConsoleKeyDecoder {
fun decode(firstCode: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown { fun decode(firstCode: Int, nextCode: (Long) -> Int?): ConsoleEvent.KeyDown {
if (firstCode == 27) { if (firstCode == 27) {
@ -132,6 +135,7 @@ object LinuxPosixLyngConsole : LyngConsole {
} }
} }
consoleFlowDebug("linux-events: source created")
return object : ConsoleEventSource { return object : ConsoleEventSource {
var closed = false var closed = false
var lastGeometry: ConsoleGeometry? = null var lastGeometry: ConsoleGeometry? = null
@ -147,23 +151,27 @@ object LinuxPosixLyngConsole : LyngConsole {
} }
val rawRequested = stateMutex.withLock { rawModeRequested } val rawRequested = stateMutex.withLock { rawModeRequested }
val pollSliceMs = if (timeoutMs <= 0L) 250L else minOf(250L, timeoutMs)
if (rawRequested) { if (rawRequested) {
val ev = readKeyEvent(pollSliceMs) val ev = readKeyEventNonBlocking()
if (ev != null) return ev 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 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 return null
} }
override suspend fun close() { override suspend fun close() {
closed = true closed = true
consoleFlowDebug("linux-events: source closed")
} }
} }
} }
@ -183,12 +191,11 @@ object LinuxPosixLyngConsole : LyngConsole {
} }
savedAttrsBlob = saved savedAttrsBlob = saved
attrs.c_lflag = attrs.c_lflag and ICANON.convert<UInt>().inv() and ECHO.convert<UInt>().inv() configureRawInput(attrs)
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()
if (tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr) != 0) return@withLock false if (tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr) != 0) return@withLock false
} }
rawModeRequested = true rawModeRequested = true
consoleFlowDebug("linux-events: setRawMode(true): enabled")
true true
} else { } else {
val hadRaw = rawModeRequested val hadRaw = rawModeRequested
@ -203,6 +210,7 @@ object LinuxPosixLyngConsole : LyngConsole {
tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr) tcsetattr(STDIN_FILENO, TCSANOW, attrs.ptr)
} }
} }
consoleFlowDebug("linux-events: setRawMode(false): disabled hadRaw=$hadRaw")
hadRaw hadRaw
} }
} }
@ -225,19 +233,55 @@ object LinuxPosixLyngConsole : LyngConsole {
val ready = poll(pfd.ptr, 1.convert(), timeoutMs.toInt()) val ready = poll(pfd.ptr, 1.convert(), timeoutMs.toInt())
if (ready <= 0) return null if (ready <= 0) return null
readReadyByte()
}
private fun readReadyByte(): Int? {
val buf = ByteArray(1) val buf = ByteArray(1)
val count = buf.usePinned { pinned -> val count = buf.usePinned { pinned ->
read(STDIN_FILENO, pinned.addressOf(0), 1.convert()) 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() 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? { private fun readByteNow(): Int? = memScoped {
val first = readByte(timeoutMs) ?: return null 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 -> 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 package net.sergeych.lyngio.console
import kotlinx.cinterop.*
import platform.posix.*
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
@ -64,4 +66,41 @@ class LinuxPosixLyngConsoleTest {
assertEquals("A", ev.key) assertEquals("A", ev.key)
assertTrue(ev.shift) 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 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?) { 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)
}
}
} }