Skip to content

Library M9: the app as a library

M9 is the ninth of the eleven milestones of #48, which turns Henad into a library published on crates.io. henad-app's library gains AppOptions, AppOpening, OpenAt at the root, run_native, results_folder, and on wasm32 start_web and init_web_logger, and the official binary builds its options over example_models() and calls one of the two. state, ui, HenadApp and wgpu_configuration are private, and henad-app re-exports no eframe item. henad-models is an optional dependency behind the default example-models feature, which the binary requires. The About window, the menu, the Copy command and the Sweep tab's advice read the product from the options. A review pass followed, with the maintainer's decision to name the official app "Henad" everywhere, its eframe app name and storage folder included.

State before

48-library stood at 4e1bd2b, M8's commit, with a clean tree. henad-app's lib.rs declared pub mod state and pub mod ui, and exported HenadApp with new(cc, models) and open_results, wgpu_configuration, requested_threads and, on wasm32, init_thread_pool. main.rs filled eframe's NativeOptions with the icon and the title "Henad Engine", parsed --open itself, and on wasm32 started the worker pool, found the canvas and started WebRunner, panicking when eframe failed. henad-models was a normal dependency. The About window held Henad's logo, tagline, links and CARGO_PKG_LICENSE as constants, the menu read "About Henad", and HOST_BUILD, henad-app's own build_info!(), was the host the About window, the run details and every sweep's manifest recorded. The runs table's Copy command always wrote henad-cli, and the Sweep tab named henad-cli in three places. The Model panel's message for a set with no model the device runs had landed in M4 (NO_MODEL_RUNS).

What was done

The library

henad-app's public API is [4.5.1]'s, in three new modules beside lib.rs.

  • options.rs holds AppOptions, AppOpening, AppError and WebStartError. AppOptions::new(models, product, host) defaults to Henad's icon, no links, no licence, no command line and no opening, and icon_png, source_url, documentation_url, license, cli_command and opening set each. The fields sit in the crate-private Product, which AppState holds as product. Product::official marks Henad's own app. main.rs is a crate of its own and cannot reach a pub(crate) item, so the flag is set through the hidden AppOptions::__official. Product's Debug prints the icon's byte length in place of its bytes, and AppOptions derives Debug alone.
  • AppOptions::check_opening is [4.2.5]'s check, before any device exists: the set has to hold the opening's id, a Run has to set the declared number of parameters, and a Setup's entry has to share the set's entry's schema_hash. A refusal is a private OpeningError, held inside AppError or WebStartError.
  • native.rs holds run_native, which checks the opening, decodes the icon (a PNG that fails to decode is logged and left out), fills NativeOptions and runs eframe under the product name, and results_folder, moved from main.rs with its two tests.
  • web.rs holds start_web and init_web_logger. start_web reads ?threads=, awaits init_thread_pool, logs a failure as before, and only then calls the options closure. A pool that failed leaves thread_pool_note, which the Performance tab shows as a banner. It checks the opening, finds the_canvas_id, starts WebRunner, and removes loading_text. A missing window or document, a refusal and an eframe failure are written into loading_text where there is one, and returned, where the old main panicked. main.rs logs the returned error, and start_web does not, so a start failure is logged once.
  • AppError's Debug writes its Display and, for eframe's error, the source, as ShaderBuildError's does. A main returning it prints a readable message.
  • lib.rs re-exports AppOptions, AppOpening, OpenAt, run_native and results_folder (native), AppError (native), and start_web, init_web_logger and WebStartError (wasm32). requested_threads and init_thread_pool stay public as before. No eframe item is re-exported. The crate doc has a no_run example over a host's own ModelSet.
  • state, ui, HenadApp and wgpu_configuration are private. HenadApp::new(cc, options) builds the AppState and hands the opening to AppState::open. HOST_BUILD is gone, and the sweep's Provenance and the run details read AppState::product.host.

Openings

  • AppState::open(AppOpening) opens a results folder as --open did, or calls open_run or the new open_setup. A run or setup it cannot open, such as a GPU model the compute filter hid, leaves nothing selected, keeps an OpeningRefusal in opening_refusal, and brings the Model tab to the front. The refusal is a lead line, "Run not opened" or "Model not opened", and the reason. The Model panel shows both with "Select a model to continue." while nothing is selected, and select_model clears it. Where no model of the set runs here, the panel shows the refusal above "No model in this build runs on this device." in place of NO_MODEL_RUNS, which would name the GPU a second time, and a set without models reads "This build includes no models." in place of a sentence about GPUs.
  • The parameter counts of OpeningError and open_run's message go through ui::plural.
  • AppState::open_setup(&RunSetup, OpenAt) looks the setup's id up in the app's own set, checks the setup's values, seed and schedule against that entry through RunSetup::from_parts, and writes them into the editing fields. A default seed stays None with an empty Seed field. It records no OpenedRun.
  • open_run and open_setup share open_values, the old tail of open_run.
  • The Model panel's message for a set the compute filter leaves empty (NO_MODEL_RUNS) landed in M4. M9 adds its test.

The product in the UI

  • The About window draws the product's name and links. The official app keeps Henad's white logo and tagline, and another product shows its icon and no tagline. The rows are Version, Commit, Sources and Build from the host build, License when set, Built on (from ENGINE_BUILD, with henad-core's version when it differs) for any product but the official one, and one Models row per distinct model-source build. A build without a commit, which is every henad-models build since its build script stamps a source hash alone, shows the hash of its sources. Copy copies the product name and every row.
  • The About menu shows a link only when the options set it, and its entry reads "About" and the product name, "About Henad" in the official app.
  • The About window's image is decoded once into a OnceCell, a failed decode included, where a host icon that is no PNG was decoded again every frame.
  • ResultsStore::cli_command(program, run_id) writes the given program. The runs table hides Copy command without a cli_command, and its tooltip names the program.
  • AppOptions::cli_command is documented as one program name, which the Copy command quotes as one shell word.
  • The Sweep tab's three mentions of henad-cli (the browser's GPU refusal, the run limit in SweepDraft::check, and the values editor) name the options' command through options::cli_phrase, or "on the command line" without one. The panel holds the command, and cached_check copies it onto the draft before it checks. The draft is pure and has 43 test calls of check, which keep their signature.
  • The run details read engine_version from ENGINE_BUILD, as M8 did for the CLI's info line.

Dead code

Private modules turned up the dead code that pub had kept alive.

  • NoteLine::is_issue is deleted and ResultsStore::used_bytes is behind #[cfg(test)], as the design names. SeriesCache::is_empty had no caller either, and is deleted.
  • ui/mcs.rs lists every MCS entry, and about 60 are unused. The module allows dead_code with that reason, since AGENTS.md has it hold one constant per entry.
  • clippy's needless_pass_by_ref_mut fired on AppState::open_file, performance_ui, stats_ui and system_ui, which now take shared references.
  • On wasm32, make_room, record_empty_series, series_room and insert_series are native only behind #[cfg(not(target_arch = "wasm32"))], and OpenResult::Folder and ResultsSource::Folder carry a wasm-only expect(dead_code), since both targets match on them. AppError is native only, since only run_native returns it.

The binary, the manifest and the web

  • main.rs builds the official options, "Henad" with the icon, the two links, CARGO_PKG_LICENSE, cli_command("henad-cli") and __official(), and calls run_native, with --open through results_folder, or start_web after init_web_logger. The eframe app name "Henad" names the window and the storage folder Henad, and 0.2.0's Henad-Engine folder is left behind once, by the maintainer's decision. In a browser eframe keeps its state under the fixed key egui_memory_ron (eframe-0.36.1 src/web/storage.rs:46, 65), and the rename leaves it alone.
  • Cargo.toml gains [lib], the [[bin]] with required-features = ["example-models"], default = ["example-models"], example-models = ["dep:henad-models"], henad-models as an optional normal dependency, and henad-models as a dev-dependency for the tests.
  • index.html gains the crossOriginIsolated check after the loading element. It appends a note under the spinner, saying the app might run on one thread or not start, and its comment and Trunk.toml's say Chrome starts the build without the headers, on one thread. Its two older comments name start_web in place of main.rs.
  • The CI web job runs scripts/build_web.sh build --release and then checks dist/henad-app.js for wbg_rayon_start_worker and initThreadPool and that dist/snippets/wasm-bindgen-rayon-*/src/workerHelpers.no-bundler.js exists.

Tests

  • New: a_mismatched_opening_is_refused (options.rs), with a test-only GridModel registered under the id sir for the schema mismatch, and an_opened_setup_keeps_the_default_seed, a_gpu_only_set_without_compute_opens_with_nothing_selected and an_opening_of_a_hidden_gpu_model_opens_with_nothing_selected (state.rs).
  • New: a_product_other_than_henad_names_the_engine_it_is_built_on (ui/about.rs), and the crate doc's no_run example.
  • open_names_the_folder_after_it_in_either_form and open_never_takes_a_flag_for_its_folder moved to native.rs unchanged.
  • The two headless helpers of state.rs and ui/sweep/session.rs are one #[cfg(test)] AppState::headless(models, compute).

Docs

  • guide/app.md: the About menu and window in the Overview, and the Copy command's program in Opening a run.
  • henad-app's README says the crate is also a library.
  • AGENTS.md: the Debug rule (henad-app's public types included, its private modules out), the diagram line, the dependency rule (henad-models behind the feature and the dev-dependency edge), and the henad-app paragraph's opening, the openings, the product's readers and the Copy command.
  • CHANGELOG: six Added, four Changed and one Removed entry. The Removed entry is "Breaking:" and replaces M4's Changed entry on HenadApp::new and wgpu_configuration, which named items no 0.3 release will have.

The review pass

After the first hand-over the maintainer folded M9's review in before the commit, with one decision of their own.

  • The official app is "Henad" everywhere: the window title, the About heading, the menu entry "About Henad" and the eframe app name, so the storage folder is Henad and 0.2.0's saved window state is left behind once. The CHANGELOG says so.
  • AppError's Debug prints the readable message, start_web returns an error for a missing window, logs nothing a caller logs, and leaves the Performance tab note when the pool fails. The page's note is appended under the spinner.
  • Product::is_official's package-name test became the explicit official flag, Product got its manual Debug, AppOptions lost Clone, OpenAt became #[non_exhaustive], and AppOptions::new documents host as the build the manifest, the run details and the About window record.
  • The Model panel's refusal text, ui::plural for parameter counts, cli_command's documentation, the guide's About paragraph ("Select About Henad", "Press Copy"), and the cached icon decode.
  • The CHANGELOG's visible text changes, and AGENTS.md's dependency rule and Copy command passage rewrapped to its width.

Edited tree

.
├── .github/workflows/ci.yml                ~ web job: --release, worker-glue check
├── AGENTS.md                               ~ henad-app as a library, the feature, the dependency rule, Debug
├── CHANGELOG.md                            ~ M9's Added, Changed and Removed
├── Trunk.toml                              ~ the headers comment: Chrome starts on one thread
├── index.html                              ~ crossOriginIsolated note under the spinner, comments name start_web
├── zensical.toml                           ~ nav entry #38
├── crates/henad-app/
│   ├── Cargo.toml                          ~ [lib], [[bin]] required-features, example-models, henad-models optional and dev
│   ├── README.md                           ~ the library
│   └── src/
│       ├── lib.rs                          ~ crate doc, private modules, re-exports, HenadApp::new(cc, options)
│       ├── main.rs                         ~ the official options over run_native or start_web
│       ├── options.rs                      + AppOptions, Product, AppOpening, opening check, AppError, WebStartError
│       ├── native.rs                       + run_native, results_folder (moved from main.rs)
│       ├── web.rs                          + start_web, init_web_logger
│       ├── state.rs                        ~ product, opening_refusal, open, open_setup, open_values, headless
│       └── ui/
│           ├── about.rs                    ~ product block, Built on, Models rows, Copy
│           ├── menu_bar.rs                 ~ links from the options, "About" and the product name
│           ├── model.rs                    ~ the opening's refusal
│           ├── mcs.rs                      ~ dead_code allowed for the palette
│           ├── performance.rs              ~ &AppState, the thread pool banner
│           ├── stats.rs, system.rs         ~ &AppState
│           ├── export/metadata.rs          ~ host and engine_version
│           ├── files/mod.rs                ~ Folder expect on wasm32
│           ├── results/{mod,store,table}.rs   ~ Copy command from the options, native-only series loading
│           └── sweep/{mod,builder,draft,header,layout,parameters,session}.rs   ~ the command in the advice, host build
└── docs/
    ├── guide/app.md                        ~ About menu and window, Copy command
    └── developing/agent-record/20261002-38-library-app.md   +

State after

M9 is implemented and uncommitted on 48-library, on top of 4e1bd2b, in the main checkout as asked, with no worktree and no branch. The workspace version stays 0.2.0.

  • HENAD_REQUIRE_GPU=1 ./check.sh passes: 1046 tests, none failed, with the wasm32 typecheck, the packaging, cargo-deny and docs steps and the web build. M8 passed at 1040.
  • uv run --locked zensical build passes with no issues.
  • cargo clippy -p henad-app --all-targets with -D warnings -W clippy::all passes with all features and with none.
  • cargo tree -p henad-app --no-default-features -e normal -i henad-models prints nothing on stdout, and cargo build -p henad-app --no-default-features builds the library alone.

The web gate, on start_web

scripts/build_web.sh serve --release served the release build with Trunk's COOP and COEP headers, and dist/ passed the worker-glue check the CI job now runs. The Claude in Chrome extension was not connected, so a separate Chrome on a temporary profile, on the second display, was driven through the DevTools protocol. With ?threads=8#dev (#dev skips the service worker), the console showed thread pool: 8 workers, 14 reported by the browser from henad_app::web and no "thread pool init failed", window.crossOriginIsolated was true, the loading element was gone, and the System tab's Worker threads row read 8.

Served without the two headers, the same build logged "thread pool init failed" with Chrome's DataCloneError (SharedArrayBuffer transfer requires self.crossOriginIsolated), then started on one thread and removed the loading element, the new message with it. That finding led to the review's web changes.

After the review the gate ran again on the revised start_web, in a headless Chrome, since the second display had been detached. The release build was served again, and dist/ passed the worker-glue check.

  • With ?threads=8, web.rs logged 8 workers, crossOriginIsolated was true, the loading element was gone, the Performance tab had no banner, and the System tab's Worker threads row read 8.
  • Served without the headers, the pool failed as before, and the Performance tab showed the "Thread pool failed to start" banner with its note.
  • The same page with the wasm file blocked stayed on the spinner, with the headers note appended under "Loading…".
  • The About menu read Source code, Documentation and About Henad. The window's heading read "Henad", with the tagline, the links, and the Models row "henad-models 0.2.0 (sources 5d34734087eb6a85)".

The egui MCP run

The first run came before the review and the rename. The release app with inspection ran on the second display, with Henad-Engine/app.ron backed up, its window moved there, and the backup restored afterwards, byte for byte. The window reopened at the size written into app.ron.

  • Picked Game of Life and built it at 1024² (314,116 alive at tick 0).
  • In the Sweep tab, varied Initial Density over 0.2,0.3,0.4. The values editor read "Same format as henad-cli --vary". Start ran "Sweep finished: 3 runs, 0 failed, in 341 ms".
  • In Results, selected run 1. Copy command put henad-cli game_of_life --seed 4320778953317010875 --warmup 0 --steps 1000 --export-stats run-1.csv on the clipboard. Open at end built the run, stepped it to tick 1000, and the Statistics tab read 46,147 alive, the run's final value in the table.
  • The About menu listed Source code, Documentation and About Henad Engine. The window showed the name, tagline, links, Version 0.2.0, the commit marked modified, Sources, Build, License MIT OR Apache-2.0 and Models. Copy put the name and the six rows on the clipboard.
  • The first run showed the Models row as "henad-models 0.2.0 (Unknown commit)", which led to the source-hash fallback. After it the row read "henad-models 0.2.0 (sources 5d34734087eb6a85)".

The second run, after the rename, seeded a new Henad/app.ron with a window entry on the second display. The second display had been detached by then, and macOS listed the main display alone, so the window opened on the main display at 1512 by 949. The egui inspection label and the window title, read through CGWindowListCopyWindowInfo, both read "Henad". The app was closed at once without driving it, and the seeded Henad folder was removed, since it did not exist before. Henad-Engine/app.ron is the same as its backup. The About heading, the menu entry and the Performance tab banner were then seen in the web build above.

The third run drove the renamed app on the main display, which the maintainer allowed with the second display detached. With no Henad/app.ron the window opened at eframe's default 400 by 300, and the egui MCP server resized it to 1400 by 850.

  • Picked Game of Life and built it at 1024² (314,116 alive).
  • Varied Initial Density over 0.2,0.3,0.4 in the Sweep tab, and Start ran "Sweep finished: 3 runs, 0 failed, in 357 ms".
  • Selected run 1 in Results. Copy command put the same henad-cli game_of_life --seed 4320778953317010875 --warmup 0 --steps 1000 --export-stats run-1.csv on the clipboard, and Open at end reached tick 1000 with 46,147 alive, the run's final value.
  • The About menu's entry read "About Henad". The window's heading read "Henad", with the tagline, the links, License MIT OR Apache-2.0 and Models "henad-models 0.2.0 (sources 5d34734087eb6a85)", and Copy put "Henad" and the six rows on the clipboard.
  • The app autosaved its state to a new ~/Library/Application Support/Henad/app.ron during the run, the folder the rename names. The folder did not exist before the run and was removed after it. Henad-Engine/app.ron is the same as its backup.

Proposed commit message: feat: app as a library

Issues found & future directions

  • Deviations from [4.5.1]. AppError is native only, where the design leaves it on both targets: on wasm32 nothing returns it. WebStartError::source() is always None, since eframe's web error is a JsValue and no std::error::Error. Its text is kept in the message. AppOptions::__official is a hidden public setter, which the design does not have.
  • The official About window keeps Henad's logo and tagline through Product::official. The design says only that the window shows "a product block". Another product gets its icon_png and no tagline, and no setter offers a tagline or a separate logo.
  • [4.3.5] expects a page stuck on "Loading…" without isolation. Chrome instantiates the threaded build without isolation and fails only the pool. The page's note now says the app might run on one thread or not start, and a failed pool leaves a banner in the Performance tab. The design's template index.html replaces the loading text with the headers message, and M10d writes it, so the template page wants the same wording.
  • The Performance tab note cannot tell why the pool failed. web-sys 0.3 has no binding for Window::crossOriginIsolated, and the note names the headers as the likely cause and points at the console. Reading the flag needs js-sys, which henad-app does not depend on.
  • The example-models lines of henad-cli and henad-app carry no "Breaking:". Neither crate had features in 0.2.0, so no 0.2 dependent can have turned one off, and their libraries never re-exported henad-models.
  • The nightly atomics clippy of [4.10] fails on henad-app, before and after M9. On the pinned nightly with -D warnings -W clippy::all, 12 findings fire both at 4e1bd2b and here: arc_with_non_send_sync (four, wgpu handles are not Send with atomics), manual_midpoint, as_chunks_mut, a tuple-to-array conversion, an unnecessary get, and needless_pass_by_ref_mut, unused_self and let_underscore_untyped in two cfg-split functions of ui/sweep/mod.rs. M9 adds none. Lint henad-app alone with --no-deps, since the newer clippy also fires in henad-compute. M10b's lint job adds this pass, and these come first.
  • A wheel event sent through the DevTools protocol did not scroll an egui scroll area. Closing tabs made room instead, in the gate's throwaway profile.
  • No CI line builds henad-app without default features. The checks above ran by hand, as M8's did, and M10b's features job is their home.
  • Next. M10a (the testing kit) depends on M5, M6 and M7, and M10b's facade on M8, M9 and M10a.

Manual notes (human)