From 4449657b6c5a35397cfe7cf01f0bcd33467f875e Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:50:58 +0200 Subject: [PATCH 1/3] fix CPU spin on stdin EOF in InteractiveBackend (#42) When stdin reaches EOF (e.g. when screen -dmS detaches from the TTY), getchar_no_echo() returns EOF immediately on every call. The input thread loop had no EOF check, causing a tight CPU spin. Detect EOF and shut down the backend gracefully. Added regression test (regression_issue42.sh) that forces interactive mode with /dev/null as stdin and verifies CPU usage stays near zero. Integrated as a CTest test. --- CMakeLists.txt | 3 ++ src/backends/InteractiveBackend.cpp | 6 ++++ tests/regression_issue42.sh | 53 +++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100755 tests/regression_issue42.sh diff --git a/CMakeLists.txt b/CMakeLists.txt index ca910d1..efbe8ab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,4 +92,7 @@ if (COMMANDLINE_TESTS) endif () enable_testing() add_test(NAME commandline_tests COMMAND commandline_tests) + add_test(NAME regression_issue42 + COMMAND bash "${CMAKE_SOURCE_DIR}/tests/regression_issue42.sh" "${CMAKE_BINARY_DIR}" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}") endif () diff --git a/src/backends/InteractiveBackend.cpp b/src/backends/InteractiveBackend.cpp index 21781d9..44a5ed0 100644 --- a/src/backends/InteractiveBackend.cpp +++ b/src/backends/InteractiveBackend.cpp @@ -1,5 +1,7 @@ #include "InteractiveBackend.h" +#include + #include "impls.h" lk::InteractiveBackend::InteractiveBackend(const std::string& prompt) @@ -235,6 +237,10 @@ void lk::InteractiveBackend::input_thread_main() { if (m_key_debug) { fprintf(stderr, "c: 0x%.2x\n", c); } + if (c == EOF) { + m_shutdown.store(true); + break; + } std::unique_lock guard(m_current_buffer_mutex); if (c != '\t') { } diff --git a/tests/regression_issue42.sh b/tests/regression_issue42.sh new file mode 100755 index 0000000..121eda2 --- /dev/null +++ b/tests/regression_issue42.sh @@ -0,0 +1,53 @@ +#!/bin/bash +# Regression test for #42: GNU screen -dmS causes commandline to break and CPU spin. +# +# Simulates the detached-screen scenario: InteractiveBackend is selected +# (forced via env var) but stdin is /dev/null (immediate EOF, as happens +# when screen detaches from the TTY). The input thread must detect EOF +# and shut down instead of spinning. +set -euo pipefail + +BUILD_DIR="${1:-build}" +BINARY="$BUILD_DIR/commandline_test" + +# Build +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" +cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_EXAMPLES=ON >/dev/null 2>&1 +make -j"$(nproc)" commandline_test >/dev/null 2>&1 +cd - >/dev/null + +# Run with forced interactive + EOF on stdin for 3 seconds. +# /usr/bin/time -v captures CPU time so we can detect a spin. +TIMEOUT=3 +TIME_OUT=$(mktemp) + +set +e +COMMANDLINE_FORCE_INTERACTIVE=1 \ + /usr/bin/time -o "$TIME_OUT" -v timeout "$TIMEOUT" "$BINARY" \ + < /dev/null >/dev/null 2>/dev/null +TIMEOUT_RC=$? +set -e + +# Parse user and system CPU time from time output. +user_time=$(awk '/User time \(seconds\)/ {print $NF}' "$TIME_OUT") +sys_time=$(awk '/System time \(seconds\)/ {print $NF}' "$TIME_OUT") +rm -f "$TIME_OUT" + +if [ -z "$user_time" ] || [ -z "$sys_time" ]; then + echo "FAIL: could not parse CPU times from /usr/bin/time output" + exit 1 +fi + +total_cpu=$(awk "BEGIN {print $user_time + $sys_time}") + +# If the process used more than 0.3 s of CPU in $TIMEOUT seconds, +# it was spinning. +threshold=0.3 +if awk "BEGIN {exit !($total_cpu > $threshold)}"; then + echo "FAIL: CPU spin detected: ${total_cpu}s CPU in ${TIMEOUT}s wall time" + echo "User=${user_time}s Sys=${sys_time}s Threshold=${threshold}s" + exit 1 +fi + +echo "PASS: no CPU spin (${total_cpu}s CPU in ${TIMEOUT}s)" From ebb9561c7e03632afe834b8fa02b30be11979ac3 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Tue, 16 Jun 2026 23:54:47 +0200 Subject: [PATCH 2/3] simplify regression_issue42.sh: collapse awk calls, drop build step --- tests/regression_issue42.sh | 61 ++++++++++++------------------------- 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/tests/regression_issue42.sh b/tests/regression_issue42.sh index 121eda2..c4fcd2a 100755 --- a/tests/regression_issue42.sh +++ b/tests/regression_issue42.sh @@ -1,53 +1,32 @@ #!/bin/bash # Regression test for #42: GNU screen -dmS causes commandline to break and CPU spin. # -# Simulates the detached-screen scenario: InteractiveBackend is selected -# (forced via env var) but stdin is /dev/null (immediate EOF, as happens -# when screen detaches from the TTY). The input thread must detect EOF -# and shut down instead of spinning. +# Forced interactive mode + /dev/null stdin simulates the detached-screen scenario: +# stdin reaches EOF immediately. The input thread must detect EOF and shut down +# instead of spinning. set -euo pipefail -BUILD_DIR="${1:-build}" -BINARY="$BUILD_DIR/commandline_test" +BINARY="${1:-build}/commandline_test" -# Build -mkdir -p "$BUILD_DIR" -cd "$BUILD_DIR" -cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_EXAMPLES=ON >/dev/null 2>&1 -make -j"$(nproc)" commandline_test >/dev/null 2>&1 -cd - >/dev/null - -# Run with forced interactive + EOF on stdin for 3 seconds. -# /usr/bin/time -v captures CPU time so we can detect a spin. -TIMEOUT=3 TIME_OUT=$(mktemp) +trap 'rm -f "$TIME_OUT"' EXIT -set +e COMMANDLINE_FORCE_INTERACTIVE=1 \ - /usr/bin/time -o "$TIME_OUT" -v timeout "$TIMEOUT" "$BINARY" \ - < /dev/null >/dev/null 2>/dev/null -TIMEOUT_RC=$? -set -e - -# Parse user and system CPU time from time output. -user_time=$(awk '/User time \(seconds\)/ {print $NF}' "$TIME_OUT") -sys_time=$(awk '/System time \(seconds\)/ {print $NF}' "$TIME_OUT") -rm -f "$TIME_OUT" - -if [ -z "$user_time" ] || [ -z "$sys_time" ]; then - echo "FAIL: could not parse CPU times from /usr/bin/time output" - exit 1 -fi - -total_cpu=$(awk "BEGIN {print $user_time + $sys_time}") - -# If the process used more than 0.3 s of CPU in $TIMEOUT seconds, -# it was spinning. -threshold=0.3 -if awk "BEGIN {exit !($total_cpu > $threshold)}"; then - echo "FAIL: CPU spin detected: ${total_cpu}s CPU in ${TIMEOUT}s wall time" - echo "User=${user_time}s Sys=${sys_time}s Threshold=${threshold}s" + /usr/bin/time -o "$TIME_OUT" -v timeout 3 "$BINARY" \ + < /dev/null >/dev/null 2>/dev/null || true + +total_cpu=$(awk ' + /User time \(seconds\)/ { u = $NF } + /System time \(seconds\)/ { s = $NF } + END { + if (u == "" || s == "") exit 1 + print u + s + } +' "$TIME_OUT") || { echo "FAIL: could not parse CPU time from /usr/bin/time output"; exit 1; } + +if awk "BEGIN {exit !($total_cpu >= 0.3)}"; then + echo "FAIL: CPU spin detected: ${total_cpu}s CPU in 3s" exit 1 fi -echo "PASS: no CPU spin (${total_cpu}s CPU in ${TIMEOUT}s)" +echo "PASS: no CPU spin (${total_cpu}s CPU in 3s)" From e5e4f0da9cc3404d4ad8a942f13ba02b57393f79 Mon Sep 17 00:00:00 2001 From: Lion Kortlepel Date: Wed, 17 Jun 2026 00:40:41 +0200 Subject: [PATCH 3/3] gate regression_issue42 test to UNIX only --- CMakeLists.txt | 8 +++++--- tests/regression_issue42.sh | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index efbe8ab..8416a09 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -92,7 +92,9 @@ if (COMMANDLINE_TESTS) endif () enable_testing() add_test(NAME commandline_tests COMMAND commandline_tests) - add_test(NAME regression_issue42 - COMMAND bash "${CMAKE_SOURCE_DIR}/tests/regression_issue42.sh" "${CMAKE_BINARY_DIR}" - WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}") + if (UNIX) + add_test(NAME regression_issue42 + COMMAND bash "${CMAKE_SOURCE_DIR}/tests/regression_issue42.sh" "${CMAKE_BINARY_DIR}" + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}") + endif () endif () diff --git a/tests/regression_issue42.sh b/tests/regression_issue42.sh index c4fcd2a..d8134d6 100755 --- a/tests/regression_issue42.sh +++ b/tests/regression_issue42.sh @@ -6,6 +6,11 @@ # instead of spinning. set -euo pipefail +if [ ! -e /dev/null ] || ! command -v /usr/bin/time >/dev/null 2>&1; then + echo "SKIP: /usr/bin/time and /dev/null required (Unix-only regression test)" + exit 0 +fi + BINARY="${1:-build}/commandline_test" TIME_OUT=$(mktemp)