# Domain Pitfalls **Domain:** Rust TUI login-shell markdown vault reader (BBS system) **Researched:** 2026-02-28 **Confidence:** MEDIUM — external tools unavailable; based on ratatui 0.30 docs knowledge (training cutoff Aug 2025) and established patterns from crossterm, notify, and Rust SSH shell deployments. Flag for verification where noted. --- ## Critical Pitfalls Mistakes that lock users out, corrupt terminal state, or require architectural rewrites. --- ### Pitfall 1: Terminal Not Restored on Panic — User Locked Out **What goes wrong:** When a panic occurs (or `process::exit` is called) inside the ratatui event loop, the terminal is left in raw mode with the alternate screen active. The user's SSH session becomes completely non-interactive — keypresses produce garbage, no prompt appears, the shell is unusable. Because this binary IS the login shell, the user cannot recover without a second connection to kill the process. **Why it happens:** Ratatui's `Terminal` struct does call `disable_raw_mode` and `LeaveAlternateScreen` in its `Drop` impl, but only if `Drop` runs. Rust panics unwind by default and `Drop` runs — BUT: if `panic = "abort"` is set in `Cargo.toml` profile, or if a `std::process::exit` call bypasses drop, or if a double-panic occurs, cleanup never runs. Additionally, custom panic hooks that call `process::exit(1)` before cleanup destroy the terminal silently. **Consequences:** - User SSH session left in broken terminal state - If using `screen`/`tmux` the session may appear frozen - Multiple users can be simultaneously affected if the vault file triggers a panic at parse time (all sessions crash on the same bad file) **Prevention:** 1. Install a panic hook that restores terminal state BEFORE printing the panic message: ```rust use std::panic; let original_hook = panic::take_hook(); panic::set_hook(Box::new(move |panic_info| { // Restore terminal first let _ = crossterm::terminal::disable_raw_mode(); let _ = crossterm::execute!( std::io::stderr(), crossterm::terminal::LeaveAlternateScreen, crossterm::cursor::Show, ); original_hook(panic_info); })); ``` 2. Never use `process::exit` inside the event loop — always propagate errors up to `main` and exit cleanly there. 3. Use `color-eyre` or a similar hook that integrates terminal restoration with pretty error printing. 4. Never set `panic = "abort"` in release profile for this crate. 5. Wrap the entire event loop in a `catch_unwind` as a last resort for parsing panics from untrusted markdown files. **Detection:** - After any error/panic during development, check if your terminal cursor is invisible or keystrokes produce garbage. - Add a test that deliberately panics inside the TUI and verifies the terminal is usable afterward. **Phase:** Address in Phase 1 (TUI bootstrap) — before any other features. This is the very first thing to implement. --- ### Pitfall 2: `SIGTERM`/`SIGHUP` Leaves Terminal Broken **What goes wrong:** When SSH closes a connection (user disconnects, timeout, `kill` command), the shell receives `SIGHUP`. The default Rust signal behavior terminates the process immediately — without running `Drop` destructors or the panic hook. The controlling terminal is left in raw mode. For a login shell this is guaranteed to happen: every normal SSH disconnect triggers `SIGHUP`. **Why it happens:** Rust's default `SIGHUP` handler calls `libc::_exit` (or the C runtime equivalent), which does NOT run Rust destructors. The ratatui `Terminal` Drop impl never runs. The PTY allocated by sshd is released at the kernel level, but the terminal mode state written to that PTY before the signal may persist for the next process that inherits the session. **Consequences:** - After normal disconnect, if the user reconnects (or the PTY is reused), the terminal may still be in a broken state - More critically: `SIGTERM` sent by process supervisors (systemd, sshd `MaxSessions` limits) causes the same silent breakage **Prevention:** Use `signal-hook` (already in the dependency tree) to register `SIGTERM` and `SIGHUP` handlers that set a shutdown flag, letting the event loop drain cleanly: ```rust use signal_hook::consts::{SIGTERM, SIGHUP}; use signal_hook::flag; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; let shutdown = Arc::new(AtomicBool::new(false)); flag::register(SIGTERM, Arc::clone(&shutdown))?; flag::register(SIGHUP, Arc::clone(&shutdown))?; // Check shutdown flag each event loop tick ``` The event loop checks the flag and exits cleanly (running Drop, restoring terminal) before the process ends. **Detection:** - Disconnect an SSH session mid-use and reconnect. If the new session has a broken prompt, this bug is present. - `stty sane` being needed after testing is a warning sign. **Phase:** Phase 1 (TUI bootstrap) — handle alongside panic hook. Both are "process lifecycle" concerns. --- ### Pitfall 3: Filesystem Watcher Consumes Excessive Resources in Multi-User Deployment **What goes wrong:** Each SSH connection spawns an independent process. Each process independently creates a filesystem watcher (via `notify`) watching the entire vault directory. With 10 concurrent users, that is 10 separate inotify/kqueue watchers on the same directory tree. On Linux, the default `fs.inotify.max_user_watches` kernel limit is 8192. A vault with 500 markdown files watched by 10 processes = 5000 inotify watches. This hits system limits and causes the `notify` crate to silently fail or return errors on new processes — meaning new SSH connections silently get stale content. **Why it happens:** The natural architecture (each process owns its own watcher) does not account for system-level watch handle limits. The `notify` crate does not alert you when it fails to add watchers — it may return `Ok(())` but not fire events. **Consequences:** - Users on connections beyond the limit see stale markdown content - No error is surfaced to users — the app just silently shows old data - Requires sysadmin intervention to raise kernel limits, or architectural change **Prevention:** Option A (simple): Use debounced polling fallback — `notify::PollWatcher` instead of `notify::RecommendedWatcher`. Polling is less efficient but has no per-watch handle limits. Acceptable for a read-mostly system where 30-second staleness is fine. Option B (correct for production): Do not watch in each user process. Instead, have a separate daemon/background thread that watches and writes a "content version" file (a simple `last_modified` timestamp file). User processes poll that single file cheaply, then re-read vault files only when the version changes. Zero inotify handle amplification. Option C (pragmatic): Watch only the specific file currently being viewed rather than the whole vault. Re-register the watch on navigation. **Detection:** - `cat /proc/sys/fs/inotify/max_user_watches` — if your vault size × expected users approaches this number, you will hit the limit. - `notify` errors that are silently swallowed — always log watcher errors even in production. **Phase:** Phase 3 (filesystem watching) — design the watcher architecture with this in mind from the start. Do not add per-process full-vault watching as an initial implementation. --- ### Pitfall 4: Path Traversal via Wiki-Links in Vault **What goes wrong:** Markdown files contain `[[../../../etc/passwd]]` or `[link](../../sensitive-file.md)`. The link resolver naively joins the vault root with the link target and reads any file on the filesystem. In a multi-user SSH deployment, this allows any authenticated user to read arbitrary files accessible to the process's Unix user. **Why it happens:** The most natural link resolution code `vault_root.join(link_target)` does NOT prevent traversal. `PathBuf::join` will happily produce `/vault/../../../etc/passwd` which `canonicalize` resolves to `/etc/passwd`. **Consequences:** - Information disclosure of any file the process has read access to - If the binary runs as root (bad practice but common in naive setups), complete filesystem read access - Content creator's vault files can accidentally (or maliciously) reference out-of-vault paths **Prevention:** Always canonicalize BOTH the vault root and the resolved path, then verify the resolved path starts with the vault root: ```rust fn resolve_link(vault_root: &Path, current_file: &Path, link: &str) -> Option { let base = current_file.parent().unwrap_or(vault_root); let candidate = base.join(link); let canonical_vault = vault_root.canonicalize().ok()?; let canonical_candidate = candidate.canonicalize().ok()?; if canonical_candidate.starts_with(&canonical_vault) { Some(canonical_candidate) } else { None // Silently reject traversal attempts } } ``` Display a "link not found" message for rejected paths — do not distinguish between "traversal rejected" and "file not found" in the UI (avoid information leakage). **Detection:** - In tests: attempt to follow `[[../../../etc/passwd]]` from a vault file. Verify no content is shown. - Code review: any call to `PathBuf::join(user_input)` without subsequent canonical-prefix check is a flag. **Phase:** Phase 2 (link resolution) — build correct path validation into the first version of the resolver. Never make it "work first, secure later." --- ### Pitfall 5: Wrong Rust Edition Breaks Compilation **What goes wrong:** `Cargo.toml` currently specifies `edition = "2024"`. As of the Rust 2021 stable release, valid editions are `2015`, `2018`, and `2021`. Edition 2024 is not a stable, released edition (it may exist as a nightly feature but is not in stable Rust as of mid-2025). This causes a build error or unexpected behavior depending on toolchain version. **Why it happens:** This is an existing issue in the scaffolded project (noted in CONCERNS.md). It may have been set speculatively or copied from an incorrect source. **Consequences:** - Build fails on stable Rust toolchains - CI/CD pipelines fail - Contributors cannot build the project **Prevention:** Change `edition = "2024"` to `edition = "2021"` in `Cargo.toml` immediately. This is the first action before any other development. **Detection:** - `cargo build` fails with "invalid value for key `edition`" or similar. - Current: already flagged in CONCERNS.md. **Phase:** Phase 0 / pre-Phase 1 — fix before any development begins. --- ## Moderate Pitfalls Mistakes that cause degraded UX, subtle bugs, or painful refactors. --- ### Pitfall 6: Unicode Width Miscalculation Breaks Rendering **What goes wrong:** Markdown content will contain Unicode — em-dashes, smart quotes, CJK characters, emoji, combining characters. Ratatui's `Span` and line-wrapping logic relies on `unicode-width` to calculate display columns. But the markdown parser may produce `String`s that are passed to `Text::raw()` without accounting for zero-width combiners or double-width CJK characters. Lines appear misaligned, overflow their widget bounds, or truncate in the wrong place visually. **Why it happens:** `str::len()` returns byte count, not display columns. Even `chars().count()` returns codepoints, not display width. A codepoint like `가` (Korean) is 2 display columns wide. Emoji sequences using ZWJ (zero-width joiner) can be 1-6 codepoints but display as a single character. **Prevention:** - Always use `unicode_width::UnicodeWidthStr::width()` when calculating display columns for line-wrapping - Let ratatui do the wrapping — use `Paragraph::wrap(Wrap { trim: false })` rather than pre-wrapping in the markdown renderer - Test with a vault file containing a CJK header, an emoji in a list item, and a combining-accent character **Phase:** Phase 2 (markdown rendering) — establish correct width handling from the first rendering implementation. --- ### Pitfall 7: Logging to stdout/stderr Corrupts the Terminal Display **What goes wrong:** Any `println!`, `eprintln!`, or logger (e.g., `env_logger` writing to stderr) that fires while ratatui is in raw mode + alternate screen will inject escape sequences into the wrong place. At best you get garbled characters flickering at the bottom of the screen. At worst (if stdout is used and ratatui also writes to stdout) the screen state becomes inconsistent and ratatui's diffing algorithm renders garbage on subsequent frames. **Why it happens:** Ratatui draws to the terminal by writing escape sequences to stdout (or the configured writer). Raw mode captures all output. Any write to the same fd that bypasses ratatui's `Terminal` abstraction corrupts the frame buffer. **Prevention:** - Never use `println!` or `eprintln!` after TUI initialization - Configure the logging backend to write to a file, not stdout/stderr. Use `env_logger` with `RUST_LOG` pointed at a file, or `tracing-appender` for async file logging - In development, use `tracing-appender`'s non-blocking writer to `/tmp/bbs-md.log` so you can `tail -f` it in a second terminal - Add a lint or CI check: grep for `println!` / `eprintln!` outside of `main` (before TUI init) **Detection:** - Garbled characters or flicker in the TUI output during any operation that triggers a log statement - `RUST_LOG=debug cargo run` causing visible corruption is a reliable test **Phase:** Phase 1 (TUI bootstrap) — configure file logging before writing any other feature code. --- ### Pitfall 8: Blocking File I/O on the Main Thread Causes Input Lag **What goes wrong:** Reading a large markdown file (or a vault with hundreds of files scanned for link resolution) on the same thread as the event loop causes the UI to freeze for the duration of the I/O. In a BBS aesthetic with "snappy" navigation feel, a 200ms freeze on every link follow is immediately noticeable. On slow storage (NFS-mounted vaults, network filesystems), this can be seconds. **Why it happens:** The ratatui event loop is single-threaded by design. `std::fs::read_to_string` blocks the thread. If vault loading happens inline with the `handle_event` function, the terminal stops processing input and updating during the load. **Prevention:** - Load file content in a separate thread (or async task) and send results back to the main thread via a `std::sync::mpsc::channel` or `tokio::sync::mpsc` - While loading, show an immediate "loading..." indicator in the UI — gives instant feedback - Cache parsed markdown pages so re-visits are instant. An `LruCache` keyed by path + mtime avoids re-parsing unchanged files - The vault index (all file paths + their outbound links) can be built once at startup in a background thread **Phase:** Phase 2 (navigation/file loading) — design async loading from the first navigation implementation, not as a later optimization. --- ### Pitfall 9: Navigation History Stack Memory Growth **What goes wrong:** Users browse deeply through a large vault (follow 50 links, use back, follow more links). If every navigation event pushes the full rendered page content onto a history stack, memory grows unboundedly. With multiple concurrent sessions each holding deep history, memory usage multiplies. **Why it happens:** The natural implementation of "back" stores `Vec` (pre-rendered ratatui `Text` objects). Ratatui `Text` containing a large document can be several hundred KB. 100 history entries × 5 concurrent users = 500 pages in RAM. **Prevention:** - Store only the path and scroll position in history, not the rendered content: `Vec<(PathBuf, u16)>` - Re-render on back navigation (fast if cached, acceptable if not) - Cap history depth at a reasonable maximum (e.g., 100 entries) — this is how browsers work too **Phase:** Phase 2 (navigation) — use path-based history from the start. --- ### Pitfall 10: Scroll State Desync When Terminal Is Resized **What goes wrong:** User scrolls to line 50 of a document in a 80x24 terminal. They resize the terminal (or reconnect from a different client). The document re-wraps. What was line 50 in the old layout is now line 35 or line 72. The scroll position is still stored as a raw line offset, pointing to the wrong location in the document. Content appears to "jump." **Why it happens:** Terminal resize sends a `SIGWINCH` signal (or a crossterm `Event::Resize`) and the viewport height changes. If scroll position is stored as an absolute line number in the pre-wrap rendered output, it becomes invalid when the document re-wraps to the new width. **Prevention:** - Listen for `Event::Resize` in the crossterm event stream and trigger re-render - Store scroll position as a fractional position (0.0–1.0 through the document) or as a heading/anchor proximity, not an absolute line number - Alternatively: always scroll to the top on resize — simpler, and matches most terminal pager behavior (less, man pages) **Phase:** Phase 2 (markdown rendering + scrolling) — decide the scroll position representation before implementing scroll. --- ### Pitfall 11: Broken Pipe Error When SSH Client Disconnects Mid-Write **What goes wrong:** The rendering loop writes to the terminal (PTY). If the SSH client disconnects while a frame is being written, the PTY write returns `EPIPE`. If this is not handled, ratatui returns an error that propagates as an unhandled panic or causes the process to receive `SIGPIPE` and abort without cleanup. **Why it happens:** `SIGPIPE` is the default Unix behavior when writing to a closed pipe. Rust's stdlib masks `SIGPIPE` for library writers but it can surface depending on signal handler configuration. Even if `SIGPIPE` is suppressed, the `write()` syscall returns `EPIPE` as an `io::Error`. If the ratatui render loop does not gracefully handle this error, it either panics or spins retrying indefinitely. **Prevention:** - Treat any `io::Error` with `ErrorKind::BrokenPipe` from the render loop as a clean shutdown signal (user disconnected), not an error worth logging at error level - Exit cleanly (restore terminal, flush, exit 0) when broken pipe is detected ```rust match terminal.draw(|f| ui(f, &app)) { Err(e) if e.kind() == io::ErrorKind::BrokenPipe => break, // clean exit Err(e) => return Err(e.into()), Ok(_) => {} } ``` **Phase:** Phase 1 (event loop) — handle broken pipe in the initial render loop implementation. --- ## Minor Pitfalls --- ### Pitfall 12: `Cargo.toml` Edition = "2024" — Already Identified See Critical Pitfall 5. Flagged here for cross-reference since it blocks all work. --- ### Pitfall 13: Wiki-Link vs Markdown-Link Precedence Ambiguity **What goes wrong:** A file contains `[[link]]` embedded inside a standard markdown link text: `[See [[wiki]] page](page.md)`. Or a file has `[[link.md]]` while the vault has both `link.md` and `link/index.md`. The parser must define precedence rules or produce inconsistent behavior that confuses content authors. **Prevention:** Define and document the precedence rules explicitly: 1. Parse standard markdown first, extract link spans 2. Apply wiki-link parsing only to text nodes (not already-parsed link text or URLs) 3. For ambiguous file resolution, prefer exact path match over `path/index.md` fallback, document this **Phase:** Phase 2 (link parsing) — establish the grammar in the first parser implementation. --- ### Pitfall 14: Case-Sensitivity Mismatch on Linux vs macOS **What goes wrong:** The vault is developed on macOS (case-insensitive HFS+) where `[[My Page]]` resolves to `my-page.md` fine. On Linux (case-sensitive ext4) where the app runs in production, the same link fails because `my-page.md` != `My-page.md`. Content that "works" in development is broken in deployment. **Prevention:** - Normalize link targets to lowercase when constructing file paths, and name vault files in lowercase - Document the naming convention in project README for content authors - In the resolver: if exact-case match fails, perform a case-insensitive scan of the directory and warn (log) when a case-insensitive match is used **Phase:** Phase 2 (link resolution) — add a case-insensitive fallback scan from day one. --- ### Pitfall 15: Missing `index.md` Crashes Instead of Graceful Error **What goes wrong:** The app is configured to open `index.md` as the landing page. The vault directory exists but `index.md` doesn't (typo, not yet created, mounted incorrectly). The app panics or returns an unrecoverable error, locking the user out. **Prevention:** Show a styled error page within the TUI ("Index not found. Please create index.md in the vault.") rather than exiting. The app should remain alive and functional even without content. Check for `index.md` existence at startup and display the error page UI instead of crashing. **Phase:** Phase 1 (TUI bootstrap + initial vault loading). --- ### Pitfall 16: ANSI Escape Sequences in Markdown Content Confuse the Renderer **What goes wrong:** Vault markdown files contain literal ANSI escape sequences (e.g., imported from another tool, or hand-crafted for color). The markdown parser passes these through as text. Ratatui then renders them as literal escape-code characters (`\x1b[31m`) rather than interpreting them, producing garbage characters in the output. Alternatively, if they are interpreted (via the terminal, bypassing ratatui's styled spans), they corrupt the ratatui frame state. **Prevention:** Strip ANSI escape sequences from all markdown text content before passing to ratatui. Use the `strip-ansi-escapes` crate or a simple regex `\x1b\[[0-9;]*m`. Apply ratatui styles directly via `Style` rather than passing raw escapes. **Phase:** Phase 2 (markdown rendering). --- ## Phase-Specific Warnings | Phase Topic | Likely Pitfall | Mitigation | |---|---|---| | Phase 0: Cargo setup | Invalid edition = "2024" blocks build | Fix to `edition = "2021"` immediately | | Phase 1: TUI bootstrap | No panic hook = user locked out on first crash | Install panic hook + signal handlers before event loop | | Phase 1: Event loop | Broken pipe on SSH disconnect = unhandled error | Treat `BrokenPipe` as clean exit | | Phase 1: Logging | `println!` in raw mode corrupts display | Configure file-only logging before any feature code | | Phase 2: Markdown rendering | Unicode width errors misalign content | Use `unicode-width` throughout; let ratatui wrap | | Phase 2: Link resolution | Path traversal via `../` in wiki-links | Canonicalize + prefix-check every resolved path | | Phase 2: Link resolution | Case-sensitivity mismatch Linux vs macOS | Case-insensitive fallback resolver with warning | | Phase 2: Link parsing | Wiki-link / markdown-link ambiguity | Define and document precedence rules upfront | | Phase 2: Navigation | History stack storing rendered pages = memory growth | Store path + scroll offset only | | Phase 2: Scrolling | Scroll position breaks on terminal resize | Use fractional position or reset on resize | | Phase 2: File loading | Blocking I/O freezes event loop | Load files in background thread, use channels | | Phase 2: Entry point | Missing index.md crashes instead of showing error | Graceful "no index" UI page | | Phase 3: Filesystem watch | Per-process inotify watchers exhaust kernel limits | Use polling or version-file pattern instead | | Phase 3: Rendering | ANSI escapes in vault content corrupts ratatui frames | Strip ANSI from all markdown text before rendering | --- ## Sources - ratatui 0.30 documentation and source (training data, cutoff Aug 2025) — MEDIUM confidence - crossterm 0.29 signal/raw-mode behavior — MEDIUM confidence - Linux kernel inotify limits (`fs.inotify.max_user_watches`) — HIGH confidence (stable kernel behavior) - Rust `PathBuf::join` path traversal behavior — HIGH confidence (stable stdlib behavior) - `signal-hook` crate patterns — MEDIUM confidence - Unicode width rendering in terminal applications — HIGH confidence (well-established problem) - SSH login shell SIGHUP behavior — HIGH confidence (POSIX standard) **Verification recommended for:** - Exact ratatui 0.30 `Drop` behavior and terminal restoration guarantees (check ratatui changelog) - Whether `notify` 6.x `RecommendedWatcher` silently fails or returns errors when watch limits are hit - Rust edition 2024 stability status — may have changed since training cutoff