Skip to content

burn: let terminal mode carry keystrokes, not just serial output - #136

Merged
openipc-ai merged 5 commits into
masterfrom
terminal-mode-reads-the-keyboard
Sep 8, 2026
Merged

burn: let terminal mode carry keystrokes, not just serial output#136
openipc-ai merged 5 commits into
masterfrom
terminal-mode-reads-the-keyboard

Conversation

@openipc-ai

Copy link
Copy Markdown
Contributor

defib burn -t advertised a U-Boot console and delivered a viewer.

The terminal-mode loop read the serial port and wrote the screen, and no code path in the CLI read stdin at all — no add_reader, no termios, no msvcrt, on any platform. README.md said raw terminal passthrough — type commands directly, which was not true of the normal U-Boot branch. (The separate download-command branch, the defib> prompt, does take input, which is probably how it went unnoticed.)

It cost the reporter in OpenIPC/firmware#2381 an evening. They had a bricked Hi3516CV300, got a live prompt over the bootrom — exactly the position from which the flash can be rewritten — and it answered nothing they typed:

Re-burning with -b gets me an #OpenIPC prompt (not hisilcon), but it's unresponsive to any/all keystrokes and i have to ctrl-c out of it

Worth recording because it misleads: the flood of OpenIPC # <INTERRUPT> on their screen looks like Ctrl-C spam swallowing the keystrokes. It is not. RecoverySession.run bounds that loop and had already finished, and the output is the banner deliberately replayed from post_burn_buffer so -b -t does not hide it. The keys were never sent, so nothing ate them.

The fix

Keys are polled between serial reads, in the loop that was already there:

with raw_terminal():
    while not stop:
        typed = read_available_keys()
        if typed:
            await transport.write(typed)
        data = await transport.read(256, timeout=0.1)

No thread and no executor — during a recovery that loop is also holding the serial link to the board, and I had just been pulled up in #135 for putting a blocking call on it. Both readers are non-blocking (select with a zero timeout; msvcrt.kbhit).

Details that matter:

  • cbreak, not raw. ISIG stays on, so Ctrl-C still exits the terminal as the banner has always promised. Sending Ctrl-C to U-Boot is therefore still not possible; that is a deliberate trade for not changing the documented exit key, and rarely needed once you are at a prompt.
  • Local echo off. A serial console echoes what it received; echoing locally too shows every character twice.
  • Windows special keys dropped. An arrow or function key arrives as a marker byte plus a scan code, which mean nothing to U-Boot and type garbage at the prompt.
  • No terminal, no problem. Where there is nothing to configure — Windows, a pipe, stdin captured under pytest — raw_terminal is a no-op and reading still works, so anything feeding stdin keeps working and callers need no platform branch.

README.md now describes what the code does.

Testing

New tests/test_terminal_keyboard.py, 13 tests: real pipes for the POSIX reader, a fake msvcrt for the Windows one, termios save/restore including on an exception, and two tests that pin the regression itself — that the terminal block reads the keyboard and writes it to the transport, and that the README does not promise what the code cannot do.

797 passed, 3 skipped
ruff: All checks passed
mypy: no issues found in 67 source files

Not verified against a camera — I have no Hi3516CV300 in front of me. The reporter is mid-recovery and has a workaround in the meantime (burn -b without -t, then PuTTY on the same port; the RAM-loaded U-Boot stays at its prompt), so they can confirm this independently.

`-t` advertised a U-Boot console and delivered a viewer. The terminal-mode
loop read the port and wrote the screen, and no code path in the CLI read
stdin at all -- on any platform, not just Windows. README.md said "raw
terminal passthrough -- type commands directly", which was not true.

It cost the reporter in OpenIPC/firmware#2381 an evening. They had a
bricked Hi3516CV300, reached a live `OpenIPC #` prompt over the bootrom --
exactly the position from which the flash can be rewritten -- and the
prompt answered nothing they typed. The flood of `<INTERRUPT>` on their
screen made it look like Ctrl-C spam was eating the keystrokes, which it
was not: the break loop is bounded and had finished, and that output is the
banner deliberately replayed from post_burn_buffer. The keys were never
sent, so there was nothing to eat them.

Keys are now polled between serial reads. No thread, no executor, and
nothing that can stall the loop -- which during a recovery is also holding
the serial link to the board. cbreak keeps ISIG on, so Ctrl-C still exits
the terminal as the banner has always promised, and disables local echo,
because a serial console echoes what it received and doing both shows every
character twice. Windows drops the marker-plus-scan-code pairs a special
key produces, which mean nothing to U-Boot and type garbage at the prompt.

Where there is no terminal to configure -- Windows, a pipe, stdin captured
under pytest -- raw_terminal is a no-op and reading still works, so
automation that feeds stdin keeps working and callers need no platform
branch.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Enable two-way keyboard input in burn terminal mode

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Forwards non-blocking keyboard input to normal U-Boot serial sessions.
• Preserves Ctrl-C exit behavior across POSIX and Windows terminals.
• Documents two-way terminal behavior and adds cross-platform regression coverage.
Diagram

sequenceDiagram
    actor User
    participant Keys as Keyboard Reader
    participant Loop as Terminal Loop
    participant Serial as Serial Transport
    participant Board as U-Boot Board
    participant Screen as Stdout
    User->>Keys: Type command
    Loop->>Keys: Poll without blocking
    Keys-->>Loop: Return typed bytes
    Loop->>Serial: Write typed bytes
    Serial->>Board: Send keystrokes
    Loop->>Serial: Read with timeout
    Board-->>Serial: Return console output
    Serial-->>Loop: Return output bytes
    Loop->>Screen: Display output
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Event-loop stdin reader
  • ➕ Provides event-driven input with minimal polling latency.
  • ➕ Could separate keyboard readiness from serial read timing.
  • ➖ Async stdin support differs across POSIX and Windows event loops.
  • ➖ Still requires platform-specific terminal configuration and key filtering.
  • ➖ Adds lifecycle coordination alongside the serial loop.
2. Dedicated keyboard thread
  • ➕ Can wrap blocking stdin APIs consistently.
  • ➕ Keeps keyboard waits outside the main asynchronous loop.
  • ➖ Introduces thread synchronization and shutdown complexity.
  • ➖ Creates another failure path during a recovery session.
  • ➖ May complicate ownership of serial writes and terminal restoration.

Recommendation: Keep the PR's non-blocking polling approach. It fits the existing bounded serial-read loop, avoids threads and executor work while recovery owns the transport, supports both platform families with small adapters, and preserves the established Ctrl-C lifecycle. Event-loop readers would only be preferable if substantially lower than 100 ms input latency became a requirement.

Files changed (4) +309 / -9

Bug fix (2) +144 / -8
app.pyForward terminal keystrokes to the serial transport +24/-8

Forward terminal keystrokes to the serial transport

• Wraps the normal U-Boot loop in terminal-state management and polls available keyboard bytes before each serial read. Keyboard bytes are written to the transport, while write failures close the loop and existing output handling remains intact.

src/defib/cli/app.py

keyboard.pyAdd cross-platform non-blocking keyboard input +120/-0

Add cross-platform non-blocking keyboard input

• Introduces POSIX and Windows keyboard readers that drain available input without blocking. POSIX terminals use restorable cbreak mode, while Windows special-key scan sequences are discarded and unsupported terminal contexts degrade safely to no-ops.

src/defib/cli/keyboard.py

Tests (1) +162 / -0
test_terminal_keyboard.pyCover keyboard forwarding and terminal-state behavior +162/-0

Cover keyboard forwarding and terminal-state behavior

• Tests idle, piped, POSIX, and simulated Windows input, including special-key filtering and terminal restoration after exceptions. Regression checks also ensure the CLI forwards typed bytes and the documentation matches supported behavior.

tests/test_terminal_keyboard.py

Documentation (1) +3 / -1
README.mdDocument two-way U-Boot terminal behavior +3/-1

Document two-way U-Boot terminal behavior

• Clarifies that normal U-Boot terminal mode forwards keystrokes and returns board output. Explicitly states that Ctrl-C exits locally rather than being sent to U-Boot.

README.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 8, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Piped input can freeze the console ✓ Resolved 🐞 Bug ☼ Reliability
Description
_read_posix drains stdin in an unbounded while True loop before returning control to the
asynchronous terminal loop. When a piped producer keeps the descriptor continuously readable, serial
reads, transport cleanup, and observation of the stop flag all remain blocked until that producer
pauses or exits.
Code

src/defib/cli/keyboard.py[R105-108]

+    out = bytearray()
+    while True:
+        try:
+            ready, _, _ = select.select([fd], [], [], 0)
Evidence
The POSIX reader repeatedly calls zero-timeout select and os.read until stdin is no longer
readable, with no byte or iteration bound. The terminal loop invokes this synchronous function
before every serial read, while raw_terminal explicitly supports pipes, so a producer that
continually replenishes the pipe prevents control from returning to the serial loop.

src/defib/cli/keyboard.py[44-45]
src/defib/cli/keyboard.py[97-120]
src/defib/cli/app.py[421-430]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`_read_posix` drains stdin without a per-call limit. A continuously writing pipe can therefore monopolize the event-loop thread and prevent the serial terminal loop from reading board output or responding to its stop flag.
## Issue Context
Piped stdin is explicitly supported when terminal configuration is unavailable. Keyboard polling runs synchronously before each transport read, so it must return after a bounded amount of work even when stdin remains readable.
## Fix Focus Areas
- src/defib/cli/keyboard.py[97-120]
- src/defib/cli/app.py[421-430]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Users can be left with a broken shell ✓ Resolved 🐞 Bug ☼ Reliability
Description
raw_terminal catches and discards every exception from its only termios.tcsetattr restoration
attempt. If restoration fails after tty.setcbreak succeeds, the command exits as though cleanup
completed while the invoking terminal can retain disabled canonical input or echo.
Code

src/defib/cli/keyboard.py[R64-67]

+        try:
+            termios.tcsetattr(fd, termios.TCSADRAIN, saved)
+        except Exception:
+            pass
Evidence
The function saves the original attributes and enables cbreak mode, then has exactly one restoration
call. Its broad exception handler suppresses every restoration failure, and the surrounding
application proceeds to print closure output without another terminal recovery path.

src/defib/cli/keyboard.py[55-67]
src/defib/cli/app.py[421-438]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`raw_terminal` silently suppresses failure of the only operation that restores the user's terminal settings. After cbreak mode has been enabled, an interrupted or failed restoration can leave the shell in an unusable state without any diagnostic.
## Issue Context
Keep restoration in `finally`, but handle interruptible failures appropriately and ensure an unrecoverable failure is surfaced. A fallback restoration mode or explicit diagnostic should preserve the original exception when the context body is already unwinding.
## Fix Focus Areas
- src/defib/cli/keyboard.py[55-67]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can tweak Display preferences with a live preview to see your comment before it ships

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/defib/cli/keyboard.py
Comment thread src/defib/cli/keyboard.py Outdated
test_real_netsh_yields_at_least_one_adapter failed all three windows-latest
jobs here, on code identical to what it passed on in #135. It asserted that
netsh lists at least one adapter, which is a property of the runner rather
than of anything we wrote -- so it passed once, failed the next time, and
told us nothing about the parser either way.

What it now asserts is ours: netsh is callable, its output parses, and
nothing that is not an adapter name comes back. A header row surviving the
shape test is the failure mode that would matter, and that shows up
regardless of what the host has plugged in. The deterministic tests against
a captured table still pin the parsing itself.

The failure did expose something real. The synchronous path ignored netsh's
exit status, unlike the async twin added in the same PR, so a netsh that
failed to run left us parsing empty stdout and reporting "no adapters" --
a command that did not run, presented as a host without a network. It
checks the exit status now, and a test pins that.
All three windows-latest jobs failed on
TestPosixReading::test_what_was_typed_comes_back with
`assert b'' == b'sf probe 0\n'`.

Windows `select()` accepts sockets only, so the pipe those tests type into
is rejected, the read comes back empty, and the assertion fails. Nothing is
wrong: `read_available_keys` dispatches on the platform and `_read_posix`
is never reached on Windows, so the tests were forcing a code path that
does not run there and failing for the reason it does not.

Skipped on Windows, where `_read_windows` and its fake msvcrt already
cover the reader that does run.

This is the failure the previous commit's netsh flake was hiding: only one
test can be the first to fail, and fixing that one let this one surface.
Third Windows failure on this branch, and the third one of mine:

  UnicodeDecodeError: 'charmap' codec can't decode byte 0x90
  in position 29005: character maps to <undefined>

`Path.read_text()` with no encoding uses the locale codec, which on the
Windows runners is cp1252. src/defib/cli/app.py is UTF-8 and has bytes
cp1252 cannot represent, so a test that reads it to check what the code
does could not even open it. Reproduced locally with
`read_text(encoding="cp1252")`, which gives the identical error.

Fixed everywhere it appears rather than only where CI stopped: both of the
new doc-vs-code tests, the Makefile the agent-map test reads, and one
pre-existing line in test_profiles_usb_recovery.py with the same latent
failure.

The two in src/defib/profiles/loader.py are a real defect rather than a
test artefact. Chip profiles are JSON, which is UTF-8 by definition, so
decoding one with the host's locale codec is wrong wherever the locale is
not UTF-8 -- today's profiles are ASCII, so nothing has broken yet, and a
single non-ASCII byte in one would have broken it only on Windows.
…lence

Two findings from the review of this branch.

The reader drained stdin for as long as it stayed readable. Someone typing
makes it return at once, but a pipe that keeps producing never stops being
readable -- and the poll runs inline before each serial read, so the board's
output, the stop flag and the transport cleanup would all wait for the
producer instead. One poll now collects at most 4 KB, which no one can type
and any flood exceeds.

raw_terminal discarded every exception from its one attempt to restore the
terminal. cbreak leaves the shell with no echo and no line editing, so a
failed restore that says nothing hands the operator a terminal that looks
dead and no reason for it. TCSADRAIN waits for pending output and so is the
half that can fail; it stays the first choice because it does not truncate
what the board was printing, TCSANOW is tried next, and if both fail we say
so on stderr with the command that fixes it. Still never raises -- the body
may already be unwinding with the error that actually matters.

The flood test lowers the cap rather than enlarging the write: filling a
pipe past its buffer would block on whichever platform has the smallest
one, and pipe capacity is not what is under test.
@openipc-ai
openipc-ai merged commit efba972 into master Sep 8, 2026
13 checks passed
@openipc-ai
openipc-ai deleted the terminal-mode-reads-the-keyboard branch September 8, 2026 08:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant