Files

24 KiB
Raw Permalink Blame History

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:
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);
}));
  1. Never use process::exit inside the event loop — always propagate errors up to main and exit cleanly there.
  2. Use color-eyre or a similar hook that integrates terminal restoration with pretty error printing.
  3. Never set panic = "abort" in release profile for this crate.
  4. 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:

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.


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:

fn resolve_link(vault_root: &Path, current_file: &Path, link: &str) -> Option<PathBuf> {
    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 Strings 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<PathBuf, RenderedPage> 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<RenderedPage> (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.01.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
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.


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