diff --git a/.gitignore b/.gitignore index 5e9e58d..a05e50b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store baud +__pycache__/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c38fd1d --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/baudshell b/baudshell new file mode 100755 index 0000000..9cdbee9 --- /dev/null +++ b/baudshell @@ -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()) diff --git a/baudshell.py b/baudshell.py deleted file mode 100755 index e9bd43a..0000000 --- a/baudshell.py +++ /dev/null @@ -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 -