Summary
TerminalEventParser never recognises CSI sequences outside the small set it handles explicitly. SGR colour sequences — \x1b[31m, \x1b[0m and friends — are the common case, and they cause two separate failures depending on timing.
Pasting any coloured text into a session triggers this: git diff output, ls --color output, coloured logs, anything copied from another terminal.
1. Unrecognised sequences are typed into the remote desktop as literal keystrokes
from sshdesk.input.terminal import TerminalEventParser
parser = TerminalEventParser()
parser.feed(b"\x1b[31m", now=0.0)
for event in parser.flush(now=1.0):
print(event)
KeyEvent(action=2, modifiers=0, key_code=2, unicode=0) # ESCAPE
KeyEvent(action=2, modifiers=0, key_code=0, unicode=91) # '['
KeyEvent(action=2, modifiers=0, key_code=0, unicode=51) # '3'
KeyEvent(action=2, modifiers=0, key_code=0, unicode=49) # '1'
KeyEvent(action=2, modifiers=0, key_code=0, unicode=109) # 'm'
After ESCAPE_DELAY the fallback at the end of feed() does del self.buffer[0], which removes only the ESC byte and emits an ESCAPE key. The remaining [31m is then parsed as ordinary characters. So an escape sequence that should be discarded is delivered to the remote host as five keystrokes — an Escape press followed by [31m.
2. Under continuous input the same sequences accumulate and kill the session
\x1b[31m matches none of MOUSE_RE, CSI_KEY_RE, CSI_TILDE_RE or SEQUENCES, so before the delay elapses the loop breaks and leaves it in the buffer — along with everything after it, since parsing stops at the unmatched prefix:
parser = TerminalEventParser()
parser.feed(b"\x1b[31mhello\x1b[0m")
print(bytes(parser.buffer)) # b'\x1b[31mhello\x1b[0m' — nothing consumed
flush() clears that residue, but in the read loop flush() only runs when select() times out:
ready, _, _ = select.select((input_fd,), (), (), 0.05)
if ready:
data = os.read(input_fd, 4096)
events = self.parser.feed(data)
else:
events = self.parser.flush()
During a paste ready stays true, so feed() is called back to back and flush() never runs. feed() checks the buffer before parsing:
self.buffer.extend(data)
if len(self.buffer) > 8192:
self.buffer.clear()
raise ValueError("terminal input sequence exceeds 8192 bytes")
Residue crosses 8192 and it raises. Reproduction, matching the loop's 4096-byte reads:
parser = TerminalEventParser()
chunk = b"\x1b[32mx\x1b[0m" * 256 # 2048 bytes of coloured text
for i in range(10):
parser.feed(chunk, now=i * 0.001)
# ValueError: terminal input sequence exceeds 8192 bytes
I hit this with ~11 KB of git-diff-style coloured text. It reproduces at any feed rate, since residue only clears on a flush() with no new data.
The exception is not contained. _input_worker forwards it:
except Exception as exc: # noqa: BLE001 - forward worker failures to main
self._controls.put(exc)
and the main loop re-raises it:
if isinstance(control, BaseException):
raise control
So the session ends.
Suggested fix
Both follow from the same gap: there is no generic CSI consumer. A CSI sequence is ESC [, parameter bytes 0x30–0x3F, intermediate bytes 0x20–0x2F, then a final byte 0x40–0x7E. Matching that shape and discarding it when no specific handler applies would fix both — unknown sequences stop being typed as text, and they stop accumulating.
That also lets the 8192 guard do its intended job. The message says "sequence", but it is currently measured against the whole buffer before parsing, so it fires on input that is merely unconsumed rather than on one oversized sequence. Checking the residue after the parse loop would match the stated intent.
Environment
- sshdesk at
main (commit 3a6e421), Python 3.13.3, Linux aarch64
pytest -q tests/ — 88 passed, so this is not covered by the current suite
I have a fix and regression tests ready and will open a PR linked to this issue.
Summary
TerminalEventParsernever recognises CSI sequences outside the small set it handles explicitly. SGR colour sequences —\x1b[31m,\x1b[0mand friends — are the common case, and they cause two separate failures depending on timing.Pasting any coloured text into a session triggers this:
git diffoutput,ls --coloroutput, coloured logs, anything copied from another terminal.1. Unrecognised sequences are typed into the remote desktop as literal keystrokes
After
ESCAPE_DELAYthe fallback at the end offeed()doesdel self.buffer[0], which removes only the ESC byte and emits an ESCAPE key. The remaining[31mis then parsed as ordinary characters. So an escape sequence that should be discarded is delivered to the remote host as five keystrokes — an Escape press followed by[31m.2. Under continuous input the same sequences accumulate and kill the session
\x1b[31mmatches none ofMOUSE_RE,CSI_KEY_RE,CSI_TILDE_REorSEQUENCES, so before the delay elapses the loopbreaks and leaves it in the buffer — along with everything after it, since parsing stops at the unmatched prefix:flush()clears that residue, but in the read loopflush()only runs whenselect()times out:During a paste
readystays true, sofeed()is called back to back andflush()never runs.feed()checks the buffer before parsing:Residue crosses 8192 and it raises. Reproduction, matching the loop's 4096-byte reads:
I hit this with ~11 KB of git-diff-style coloured text. It reproduces at any feed rate, since residue only clears on a
flush()with no new data.The exception is not contained.
_input_workerforwards it:and the main loop re-raises it:
So the session ends.
Suggested fix
Both follow from the same gap: there is no generic CSI consumer. A CSI sequence is
ESC [, parameter bytes0x30–0x3F, intermediate bytes0x20–0x2F, then a final byte0x40–0x7E. Matching that shape and discarding it when no specific handler applies would fix both — unknown sequences stop being typed as text, and they stop accumulating.That also lets the 8192 guard do its intended job. The message says "sequence", but it is currently measured against the whole buffer before parsing, so it fires on input that is merely unconsumed rather than on one oversized sequence. Checking the residue after the parse loop would match the stated intent.
Environment
main(commit3a6e421), Python 3.13.3, Linux aarch64pytest -q tests/— 88 passed, so this is not covered by the current suiteI have a fix and regression tests ready and will open a PR linked to this issue.