First working version.
This commit is contained in:
1 parent
85b0d6b5ac
commit
511f20dc9a
4 files changed
+200
-59
No files matched your search
@@ -1,2 +1,3 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
baud
|
baud
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What this is
|
||||||
|
|
||||||
|
A tool that runs an interactive shell behind a simulated serial (baud-rate) throttle,
|
||||||
|
so that terminal output animates one character at a time instead of appearing all at
|
||||||
|
once (e.g. to replay old VT100/ANSI animations that a fast terminal would render
|
||||||
|
instantly).
|
||||||
|
|
||||||
|
- `baudshell` — the program. Pure Python 3, self-contained: it launches `bash --norc`
|
||||||
|
in a PTY and throttles **both** directions of I/O in-process (no helper binary, no
|
||||||
|
`cc` build step).
|
||||||
|
- `baud.c` — an *optional* standalone C pipe filter with the same throttling idea
|
||||||
|
(reads stdin, re-emits it one char per `10/baud` seconds to stdout). It is **not**
|
||||||
|
used by `baudshell`; it exists for the "throttle a piped animation file" use case,
|
||||||
|
e.g. `cat anim.ans | baud 9600`. It compiles independently (`cc baud.c -o baud`);
|
||||||
|
the `baud` binary it produces is gitignored.
|
||||||
|
|
||||||
|
There is no build system, test suite, linter, or CI. Everything besides the two source
|
||||||
|
files (the `baud` binary, `.DS_Store`) is in `.gitignore`.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
No build step is needed for the shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./baudshell # or: python3 baudshell
|
||||||
|
./baudshell 2400 # simulate 2400 baud instead of the default 9600
|
||||||
|
```
|
||||||
|
|
||||||
|
`argv[1]`, if given, is the **baud rate** (a positive integer; default 9600, lower is
|
||||||
|
slower). A non-integer or non-positive rate prints an error and exits 2.
|
||||||
|
|
||||||
|
The program puts the terminal into raw mode and restores it on exit, so keystrokes
|
||||||
|
reach the shell char-by-char. To leave, type `exit` in the shell (or close the
|
||||||
|
terminal); the program detects the shell's exit and cleans up.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
`baudshell` multiplexes two file descriptors with `select()`: the terminal
|
||||||
|
(`sys.stdin`) and the PTY master. The single subprocess is `bash` (in its own session
|
||||||
|
via `preexec_fn=os.setsid`, on the PTY slave); the throttle itself runs in-process.
|
||||||
|
|
||||||
|
```
|
||||||
|
keyboard ─► fd_term ─► throttled_copy ─► master ─► shell (bash)
|
||||||
|
│
|
||||||
|
terminal ◄─ fd_term ◄─ throttled_copy ◄─ master ◄┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Throttling is `throttled_copy(src, dst, baud)`**: read a chunk from `src`, then
|
||||||
|
write it to `dst` **one byte at a time, sleeping `10/baud` seconds between bytes**.
|
||||||
|
This runs for both directions — keystrokes are throttled into the shell, and the
|
||||||
|
shell's output is throttled out to the terminal, mirroring a real serial link.
|
||||||
|
- `delay_per_char(baud)` returns `10/baud` seconds. A serial byte is 10 bits
|
||||||
|
(1 start + 8 data + 1 stop), so a line at `baud` bits/s carries `baud/10` bytes/s;
|
||||||
|
this is the same formula the original `baud.c` used.
|
||||||
|
|
||||||
|
### End-of-stream / clean shutdown (the reason the old version hung)
|
||||||
|
|
||||||
|
The loop breaks as soon as either copy returns `True` (stream finished), which is how
|
||||||
|
the two termination cases are handled:
|
||||||
|
|
||||||
|
- **Shell exits** (`master` reaches EOF) → stop, then reap the shell and propagate its
|
||||||
|
exit status. This is the fix for the original bug: the previous version ignored the
|
||||||
|
copy result and spun forever after the shell exited.
|
||||||
|
- **stdin reaches EOF** (terminal closed / piped input drained) → stop, and
|
||||||
|
`shell.terminate()` kills the still-running shell.
|
||||||
|
|
||||||
|
A `finally` block always restores the terminal's termios settings. Throttle math:
|
||||||
|
rate must be `> 0`; unlike the C original, Python's `time.sleep` has no upper bound, so
|
||||||
|
very low rates are accepted rather than rejected.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run bash behind a simulated serial (baud-rate) throttle.
|
||||||
|
|
||||||
|
Every byte of shell output -- and of your keyboard input -- is written one byte
|
||||||
|
at a time, pausing between bytes by the amount implied by a serial line running
|
||||||
|
at the chosen baud rate. The result is that output scrolls across the screen
|
||||||
|
character by character, as it would on a real slow serial terminal, instead of
|
||||||
|
appearing all at once. This makes old VT100/ANSI animations visible again: most
|
||||||
|
terminal emulators render as fast as the machine allows, so they do not animate
|
||||||
|
without a throttle.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./baudshell [BAUD]
|
||||||
|
|
||||||
|
BAUD baud rate to simulate, in bits/second. Default: 9600. Lower is slower.
|
||||||
|
|
||||||
|
When the shell exits (type `exit`), the program detects end-of-stream on the
|
||||||
|
pseudo-terminal and shuts down cleanly, restoring the terminal to its original
|
||||||
|
mode.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import sys
|
||||||
|
import tty
|
||||||
|
import termios
|
||||||
|
import select
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
DEFAULT_BAUD = 9600
|
||||||
|
SHELL = ["bash", "--norc"]
|
||||||
|
CHUNK = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def delay_per_char(baud):
|
||||||
|
"""Seconds to pause between bytes on a serial line at `baud` bits/second.
|
||||||
|
|
||||||
|
A serial byte is 10 bits (1 start + 8 data + 1 stop), so a line at `baud`
|
||||||
|
bits/second delivers baud/10 bytes/second, and one byte takes 10/baud
|
||||||
|
seconds.
|
||||||
|
"""
|
||||||
|
return 10 / baud
|
||||||
|
|
||||||
|
|
||||||
|
def throttled_copy(src, dst, baud):
|
||||||
|
"""Copy from fd `src` to fd `dst` one byte at a time, pausing between bytes
|
||||||
|
to simulate a serial link at `baud`.
|
||||||
|
|
||||||
|
Returns True when the stream is finished -- `src` reached end-of-stream or
|
||||||
|
either end broke -- so the caller can stop. Returns False while more data
|
||||||
|
may still flow.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = os.read(src, CHUNK)
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
if not data:
|
||||||
|
return True
|
||||||
|
|
||||||
|
step = delay_per_char(baud)
|
||||||
|
for byte in data:
|
||||||
|
try:
|
||||||
|
os.write(dst, bytes((byte,)))
|
||||||
|
except OSError:
|
||||||
|
return True
|
||||||
|
time.sleep(step)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
baud = DEFAULT_BAUD
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
try:
|
||||||
|
baud = int(sys.argv[1])
|
||||||
|
except ValueError:
|
||||||
|
sys.stderr.write(f"Invalid baud rate: {sys.argv[1]!r}\n")
|
||||||
|
return 2
|
||||||
|
if baud <= 0:
|
||||||
|
sys.stderr.write(f"Invalid baud rate: {baud} (must be positive)\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
fd_term = sys.stdin.fileno()
|
||||||
|
|
||||||
|
master, slave = pty.openpty()
|
||||||
|
shell = subprocess.Popen(
|
||||||
|
SHELL,
|
||||||
|
stdin=slave,
|
||||||
|
stdout=slave,
|
||||||
|
stderr=slave,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
os.close(slave)
|
||||||
|
|
||||||
|
old = termios.tcgetattr(fd_term)
|
||||||
|
tty.setraw(fd_term)
|
||||||
|
|
||||||
|
exit_code = 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
readable, _, _ = select.select([fd_term, master], [], [])
|
||||||
|
|
||||||
|
# Keyboard -> shell. stdin reaching EOF (terminal closed, or piped
|
||||||
|
# input drained) means there is nothing more to feed in.
|
||||||
|
if fd_term in readable and throttled_copy(fd_term, master, baud):
|
||||||
|
break
|
||||||
|
|
||||||
|
# Shell -> terminal. The master side reaching EOF means the shell
|
||||||
|
# process has exited; stop and fall through to cleanup.
|
||||||
|
if master in readable and throttled_copy(master, fd_term, baud):
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
# Always hand the terminal back in a usable state.
|
||||||
|
termios.tcsetattr(fd_term, termios.TCSADRAIN, old)
|
||||||
|
if shell.poll() is None:
|
||||||
|
shell.terminate()
|
||||||
|
|
||||||
|
# Reap the shell and propagate its exit status.
|
||||||
|
exit_code = shell.wait()
|
||||||
|
if exit_code is not None and exit_code < 0:
|
||||||
|
exit_code = 128 + (-exit_code) # killed by signal N -> status 128+N
|
||||||
|
return exit_code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""pty_baud.py – run a shell behind two baud-rate simulators."""
|
|
||||||
import os, pty, sys, tty, termios, select, subprocess
|
|
||||||
|
|
||||||
BAUD = sys.argv[1] if len(sys.argv) > 1 else "baud"
|
|
||||||
SHELL = ["bash", "--norc"]
|
|
||||||
|
|
||||||
master, slave = pty.openpty()
|
|
||||||
shell = subprocess.Popen(SHELL, stdin=slave, stdout=slave, stderr=slave,
|
|
||||||
preexec_fn=os.setsid, close_fds=True)
|
|
||||||
os.close(slave)
|
|
||||||
|
|
||||||
baud_out = subprocess.Popen([BAUD], stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE)
|
|
||||||
baud_in = subprocess.Popen([BAUD], stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.PIPE)
|
|
||||||
|
|
||||||
# ── flatten every endpoint to an int fd ──────────────────────────
|
|
||||||
fd_term = sys.stdin.fileno()
|
|
||||||
fd_baud_in_stdin = baud_in.stdin.fileno()
|
|
||||||
fd_baud_in_out = baud_in.stdout.fileno()
|
|
||||||
fd_baud_out_in = baud_out.stdin.fileno()
|
|
||||||
fd_baud_out_out = baud_out.stdout.fileno()
|
|
||||||
# master is already an int from openpty()
|
|
||||||
|
|
||||||
old = termios.tcgetattr(fd_term)
|
|
||||||
tty.setraw(fd_term)
|
|
||||||
|
|
||||||
def pump(r, w):
|
|
||||||
try:
|
|
||||||
d = os.read(r, 4096) # r is already an int
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
if not d:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
os.write(w, d) # w is already an int
|
|
||||||
except OSError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
r, _, _ = select.select(
|
|
||||||
[fd_term, master, fd_baud_in_out, fd_baud_out_out],
|
|
||||||
[], [], 0.01)
|
|
||||||
|
|
||||||
if fd_term in r: pump(fd_term, fd_baud_in_stdin)
|
|
||||||
if master in r: pump(master, fd_baud_out_in)
|
|
||||||
if fd_baud_in_out in r: pump(fd_baud_in_out, master)
|
|
||||||
if fd_baud_out_out in r: pump(fd_baud_out_out, fd_term)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
termios.tcsetattr(fd_term, termios.TCSADRAIN, old)
|
|
||||||
for p in (shell, baud_in, baud_out):
|
|
||||||
try: p.terminate()
|
|
||||||
except OSError: pass
|
|
||||||
|
|
||||||
Reference in new issue
Block a user