runtime-and-event-loop.md 7.8 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, the configuration watcher task, the draw/recv TUI loop and how the screen is repainted and wiped on exit. tags: [runtime, tokio, event-loop, ratatui, crossterm, web-panel, version, config-reload] sources:

  • id: openwiki-source-36f054f1f3 resource: repo://build.rs
  • id: openwiki-source-d6b2055bbb resource: repo://src/config_watch.rs
  • id: openwiki-source-b55a21a31e resource: repo://src/main.rs generated: { by: "openwiki/0.7.2", at: "2026-10-24T16:42:08.550Z" } verified:
  • by: openwiki/0.7.2 at: 2026-10-23T21:26:24.156Z ---

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:32), which the terminal UI shows at the bottom right.

Startup sequence

main (src/main.rs:44-72) runs on the multi-threaded tokio runtime (#[tokio::main]). If any command-line argument is --version or -V (wants_version, src/main.rs:78-80) 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:100-105): nothing happens when web_port is unset, otherwise web::start(port, app_tx) binds the port.
  7. Spawns the configuration watcher with config_watch::spawn(config.source_files(), app_tx) (src/main.rs:61).
  8. Builds App from AppDeps { config, history, wa, events } (src/main.rs:63-68).
  9. Spawns the input thread.
  10. 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). The web panel is started once, so a changed web_port only applies after a restart.

Event sources

 crossterm (blocking)   whatsapp-rust bot   tiny_http (blocking)  config_watch task   tokio tasks in App
   "wh-input" thread     Translator task     "wh-web" thread      (2 s polling)       (AI, media description
        |                      |                   |                   |               and transcription,
        | Key/Redraw           | WaEvent           | Web(WebRequest)   | ConfigReloaded  sends, reply timers,
        v                      v                   v                   v                 relink)
   app_tx  <----  WhatsApp forwarder (Wa(..))  <---+-------------------+                      | AiReply / Sent /
        ^                                                                                     | ImagesDescribed /
        +-------------------------------------------------------------------------------------+ AudiosTranscribed
        |                                                                                     | ReplyDue / Relinked
     app_rx --> event_loop: draw ui::render -> recv -> app.handle_event
  • WhatsApp forwarder (src/main.rs:86-94): a tokio task that wraps each WaEvent in AppEvent::Wa and stops when either channel side closes. The WaEvent channel outlives a relink: the new session started by WaHandle::relink sends into the same sender, so the forwarder keeps working.
  • 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.
  • Configuration watcher (src/config_watch.rs:24-40): a tokio task that every 2 seconds reads the modification time of config.toml, SOUL.md and VISION.md; when any of them differs from the last snapshot (including a file appearing or disappearing) it runs Config::load and sends AppEvent::ConfigReloaded with the new configuration or the error text. It ends when the receiver is dropped. See Configuration and data.
  • Input thread (src/main.rs:111-142): 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:149-155): 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 descriptions, audio transcriptions, sends, reply-delay timers, relink) hold a clone of the sender and report with AiReply / ImagesDescribed / AudiosTranscribed / Sent / ReplyDue / Relinked.

TUI loop

run_tui (src/main.rs:161-167) calls ratatui::init, runs the loop, then wipes the screen with clear_screen (src/main.rs:174-176: clear all and move the cursor to the top-left, with no cursor-position query) and always calls ratatui::restore, so raw mode and the alternate screen are left even on errors and terminals without an alternate screen are not left showing the UI. The loop error wins over a clear error (result.and(cleared)).

event_loop (src/main.rs:183-206) 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 reads the terminal size and calls terminal.resize with it, which clears the fullscreen viewport and forces a full repaint so wide glyphs from the previous list cannot leave stale cells behind. Terminal::clear is avoided because it queries the cursor position, which times out on some terminals.

Because rendering happens only after an event, the screen is redrawn on key presses, resizes, WhatsApp events, web panel requests, configuration reloads 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. src/config_watch.rs tests that modification-time snapshots track a file's creation and deletion. See Unit tests.