runtime-and-event-loop.md 5.9 KB


type: architecture title: Runtime and event loop description: Process startup in src/main.rs (including the --version flag and the build version embedded by build.rs), the tokio runtime, the two event channels, the input thread, the WhatsApp forwarder, the optional web panel thread and the draw/recv TUI loop. tags: [runtime, tokio, event-loop, ratatui, crossterm, web-panel, version] verified:

  • by: openwiki/0.7.2 at: 2026-10-21T21:49:12.291Z sources:
  • id: openwiki-source-36f054f1f3 resource: repo://build.rs
  • id: openwiki-source-b55a21a31e resource: repo://src/main.rs generated: { by: "claude-code", at: "2026-10-21T21:49:12.291Z" } ---

Runtime and event loop

The binary (wh) is a single-process terminal app. src/main.rs wires the modules together and runs one serialised event loop: every state change goes through App::handle_event, so App needs no locking.

Build version

build.rs runs git rev-parse --short HEAD at compile time and exports the result as the WH_GIT_HASH environment variable (unknown when git is missing, fails or prints nothing); it asks cargo to rebuild when .git/HEAD, .git/refs or .git/packed-refs change (build.rs:14-33). main.rs combines it into BUILD_VERSION = <CARGO_PKG_VERSION> (<hash>) (src/main.rs:24-25), which the terminal UI shows at the bottom right.

Startup sequence

main (src/main.rs:36-64) runs on the multi-threaded tokio runtime (#[tokio::main]). If any command-line argument is --version or -V (wants_version, src/main.rs:66-72) it only prints wh <BUILD_VERSION> and exits, without reading the configuration or touching the terminal. Otherwise it:

  1. Loads the configuration with Config::load (config.toml, SOUL.md, vision.md) and creates the data directory (ensure_data_dir). See Configuration and data.
  2. Opens the SQLite history store at history_db_path().
  3. Creates two unbounded tokio::sync::mpsc channels: one for AppEvent and one for WaEvent.
  4. Starts the WhatsApp client with whatsapp::start(session_db_path, wa_tx).
  5. Spawns the WhatsApp forwarder.
  6. Starts the optional web control panel with start_web_panel (src/main.rs:88-97): nothing happens when web_port is unset, otherwise web::start(port, app_tx) binds the port.
  7. Builds App from AppDeps { config, history, wa, events }.
  8. Spawns the input thread.
  9. Runs the TUI until quit.

Any failure before the TUI starts aborts with an anyhow context such as cannot load configuration, cannot open history database, cannot start WhatsApp client or cannot start web server on port <port> (for example when the port is already in use).

Event sources

 crossterm (blocking)     whatsapp-rust bot      tiny_http (blocking)    tokio tasks in App
   "wh-input" thread       Translator task         "wh-web" thread       (AI, image description,
        |                        |                       |                send, reply timers)
        | Key/Redraw             | WaEvent               | Web(WebRequest)      |
        v                        v                       v                      | AiReply / Sent /
   app_tx  <------  WhatsApp forwarder (Wa(..))  <-------+                      | ImagesDescribed /
        ^                                                                       | ReplyDue
        +-----------------------------------------------------------------------+
        |
     app_rx  -->  event_loop: draw ui::render -> recv -> app.handle_event
  • WhatsApp forwarder (src/main.rs:74-86): a tokio task that wraps each WaEvent in AppEvent::Wa and stops when either channel side closes.
  • Web thread (wh-web, see Web control panel): a blocking HTTP server on its own OS thread; each API request is sent as AppEvent::Web and the thread waits on a oneshot channel for the state snapshot that App sends back after handling it.
  • Input thread (src/main.rs:99-134): a named OS thread (wh-input) because crossterm::event::read blocks. On a read error it sends WaEvent::Error("terminal input error: …") and stops instead of spinning.
  • Event mapping (src/main.rs:136-147): only KeyEventKind::Press keys become AppEvent::Key (Windows also emits release and repeat events); Resize becomes AppEvent::Redraw; everything else is dropped.
  • Background tasks spawned by App (AI completions, image downloads and descriptions, sends, reply-delay timers) hold a clone of the sender and report with AiReply / ImagesDescribed / Sent / ReplyDue.

TUI loop

run_tui (src/main.rs:149-158) calls ratatui::init, runs the loop and always calls ratatui::restore, so raw mode and the alternate screen are left even on errors.

event_loop (src/main.rs:160-184) repeats while !app.should_quit: draw a frame with ui::render, await the next event, and pass it to app.handle_event. The loop also ends cleanly when the event channel closes. Draw errors and fatal handle_event errors end the process. should_quit is set by F10 or by a quit request from the web panel.

Before dispatching, the loop records list_mode_before = app.list_mode; if the event changed the list mode (F2 switches chats/contacts), it calls terminal.clear() to force a full repaint, so wide glyphs from the previous list cannot leave stale cells behind.

Because rendering happens only after an event, the screen is redrawn on key presses, resizes, WhatsApp events, web panel requests and background task results — there is no timer tick.

Tests

src/main.rs tests check that only --version / -V request the version and that BUILD_VERSION starts with the package version and names a commit in parentheses. See Unit tests.