Compare commits

...
5 Commits
Author SHA1 Message Date
thallada ce4a1a127d docs(build): quantify the double-compile cost instead of guessing
The previous text claimed restructuring main.rs to use the lib crate
would 'roughly halve' build time. Measured with --timings on a clean
release build, that was wrong:

  33.7s  keydr (bin)
  26.0s  keydr (lib)
   1.4s  generate_test_profiles (bin)
  61.0s of 292.2s total unit-seconds (21%)

The duplicated units are indeed the two slowest in the graph, but the
saving is ~26s of unit-work (less in wall time, since they overlap with
dependency compilation across 3 jobs), not half the build. The other
~79% is one-time dependency compilation that incremental builds skip.

Replace the estimate with the measurement.
2026-08-07 00:35:12 +00:00
thallada e2eb50eb85 docs(build): correct OOM-daemon guidance and add global cargo/systemd knobs
systemd-oomd is desktop-only on Ubuntu (reverse-deps are ubuntu-desktop*);
'systemctl is-enabled systemd-oomd' returns not-found on this server, so
the previous advice to enable it would simply have failed. Recommend
earlyoom instead, with --avoid/--prefer tuned to protect the shell and
target rustc.

Also document the machine-wide alternatives to per-project tuning:
- sudo systemctl set-property user-1000.slice MemoryHigh=/MemoryMax=
  (covers every shell and build; sshd lives in system.slice so it can
  never lock you out)
- global ~/.cargo/config.toml, which does support [profile.*]

Clarify that the MemoryHigh warning applies to short-lived build scopes,
not to slice-level limits where gradual throttling is appropriate.
2026-08-07 00:28:03 +00:00
thallada a728c758a7 perf(build): upgrade rust-i18n v3 -> v4 to fix codegen blowup
Release builds consumed 5+ GB and never completed on a 4-core / 7.6 GB
VM, thrashing the machine badly enough to lose SSH access.

Root cause was a codegen bug in rust-i18n v3, not machine size. Its
macro emitted one HashMap::from([...]) and one add_translations() call
per translation string, all in a single function body -- 8,652 of each
for our 21 locales / ~9,000 keys. LLVM scales superlinearly on function
size, so optimising that one initialiser took ~4.5 GB.

Verified via cargo expand (lib target):

                          v3.1.5    v4.2.1
  HashMap::from            8,652         0
  add_translations         8,652        21   (one per locale)
  expanded lines          93,026    73,444

Fixed upstream in v4.0.0.

Clean release builds, same machine:

  v3, opt-level=3, thin LTO      5.2 GB   stalled >18m, never finished
  v3, opt-level=1, no LTO        1.8 GB   15m30s   (previous workaround)
  v4, opt-level=3, thin LTO      2.08 GB  3m25s
  v4, stock cargo defaults       1.92 GB  2m17s

Also:
- Remove the opt-level=1 / lto=false workaround; cargo's release
  defaults are correct now. No shipped code is de-optimised.
- Keep only genuine small-machine hygiene in .cargo/config.toml:
  jobs=3 (keeps the box interactive), lld linker, and trimmed debug
  info on dev/test profiles.
- Document the diagnosis, the cargo expand / llvm-lines workflow that
  found it, and the systemd-based safety nets in docs/BUILDING.md.
- Note in main.rs why i18n! must appear in both crate roots (t! expands
  to crate::_rust_i18n_t, and main.rs re-declares the module tree
  rather than depending on the lib target).

Test suite passes; release binary verified working.
2026-08-07 00:17:21 +00:00
thalladaandGitHub d539c8abe7 Add screenshots to README
Added screenshots and details section to README
2026-05-19 17:16:02 -04:00
thalladaandTyler Hallada e018a742a3 Add README.md 2026-05-19 17:08:02 -04:00
6 changed files with 589 additions and 15 deletions
+55
View File
@@ -0,0 +1,55 @@
# Build configuration for keydr.
#
# HISTORY: this file once contained aggressive workarounds (LTO disabled,
# opt-level dropped to 1) because release builds consumed 5+ GB and never
# finished on a 4-core / 7.6 GB VM. That was NOT a hardware limitation —
# it was a codegen bug in rust-i18n v3, fixed by upgrading to v4.
#
# With rust-i18n v4 the same machine does a full opt-level=3 + thin-LTO
# release build with a 2.08 GB peak in 3m25s. See docs/BUILDING.md.
#
# What remains here is ordinary small-machine hygiene, not a handicap:
# nothing below reduces the optimisation level of shipped code.
[build]
# Cap parallelism at 3 of 4 cores. This is about keeping the machine
# interactive (SSH stays responsive during a build), not about memory.
# Remove it if you don't care about using the box while it compiles.
jobs = 3
rustflags = [
# Use the lld that ships with the Rust toolchain. GNU ld is
# single-threaded and holds the whole link graph in memory; lld is
# faster and leaner. The -B flag is the stable-Rust way to select it
# (`-Clink-self-contained=+linker` requires nightly).
#
# If you move this repo to another machine, update this path or
# install system lld and use plain `-fuse-ld=lld`.
"-Clink-arg=-fuse-ld=lld",
"-Clink-arg=-B/home/thallada/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/bin/gcc-ld",
]
[profile.dev]
# Default is debug = 2 (full debug info), the dominant contributor to both
# rustc peak memory and target/ size. Level 1 keeps line numbers, so
# backtraces and panics still point at real source lines; you only lose
# full variable inspection in a debugger.
debug = 1
split-debuginfo = "unpacked"
[profile.dev.package."*"]
# Dependencies are compiled once and rarely stepped through in a debugger.
# Dropping their debug info shrinks target/ and speeds up linking.
debug = 0
[profile.test]
debug = 1
split-debuginfo = "unpacked"
[profile.test.package."*"]
debug = 0
# NOTE: [profile.release] is deliberately absent — cargo's defaults
# (opt-level = 3, codegen-units = 16, no LTO) are correct for this project
# now. Add `lto = "thin"` if you want it; measured at 2.08 GB peak / 3m25s
# on this box, which is comfortable.
Generated
+83 -14
View File
@@ -2,6 +2,19 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"getrandom 0.3.4",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.4"
@@ -32,6 +45,17 @@ version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299"
[[package]]
name = "annotate-snippets"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1"
dependencies = [
"anstyle",
"memchr",
"unicode-width",
]
[[package]]
name = "anstream"
version = "0.6.21"
@@ -97,6 +121,12 @@ dependencies = [
"rustversion",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "atomic"
version = "0.6.1"
@@ -633,6 +663,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "encoding_rs_io"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22"
dependencies = [
"encoding_rs",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -817,9 +856,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -852,6 +893,16 @@ dependencies = [
"walkdir",
]
[[package]]
name = "granit-parser"
version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d03f81ad4732830d85cfd417a9f62cde6dadda4354d37d078a6084a19560aa2d"
dependencies = [
"arraydeque",
"smallvec",
]
[[package]]
name = "h2"
version = "0.4.13"
@@ -1491,6 +1542,12 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nom"
version = "7.1.3"
@@ -2123,12 +2180,11 @@ dependencies = [
[[package]]
name = "rust-i18n"
version = "3.1.5"
version = "4.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fda2551fdfaf6cc5ee283adc15e157047b92ae6535cf80f6d4962d05717dc332"
checksum = "f10cee36dd3b1f7929ea12b759de9eea9eff83bfccbc71f387ef4d41a57c64a4"
dependencies = [
"globwalk",
"once_cell",
"regex",
"rust-i18n-macro",
"rust-i18n-support",
@@ -2137,39 +2193,33 @@ dependencies = [
[[package]]
name = "rust-i18n-macro"
version = "3.1.5"
version = "4.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22baf7d7f56656d23ebe24f6bb57a5d40d2bce2a5f1c503e692b5b2fa450f965"
checksum = "f0bb1ed4e04fe26c2a2652cad1c6595efaf7196f4445c0d6e13c67154347fb7e"
dependencies = [
"glob",
"once_cell",
"proc-macro2",
"quote",
"rust-i18n-support",
"serde",
"serde_json",
"serde_yaml",
"syn 2.0.114",
]
[[package]]
name = "rust-i18n-support"
version = "3.1.5"
version = "4.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940ed4f52bba4c0152056d771e563b7133ad9607d4384af016a134b58d758f19"
checksum = "ba1c083408a2733180ae0acf2897612f8ceec7b0c0dcd065a0a87103bfb3b1d9"
dependencies = [
"arc-swap",
"base62",
"globwalk",
"itertools 0.11.0",
"lazy_static",
"normpath",
"once_cell",
"proc-macro2",
"regex",
"serde",
"serde-saphyr",
"serde_json",
"serde_yaml",
"siphasher",
"toml",
"triomphe",
@@ -2318,6 +2368,25 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-saphyr"
version = "0.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4"
dependencies = [
"ahash",
"annotate-snippets",
"base64",
"encoding_rs_io",
"getrandom 0.3.4",
"granit-parser",
"nohash-hasher",
"num-traits",
"serde_core",
"smallvec",
"zmij",
]
[[package]]
name = "serde_core"
version = "1.0.228"
+1 -1
View File
@@ -20,7 +20,7 @@ anyhow = "1.0"
thiserror = "2.0"
reqwest = { version = "0.12", features = ["blocking"], optional = true }
icu_normalizer = { version = "2.1", default-features = false, features = ["compiled_data"] }
rust-i18n = "3"
rust-i18n = "4"
[dev-dependencies]
tempfile = "3"
+231
View File
@@ -0,0 +1,231 @@
# keydr
A terminal typing tutor with adaptive learning, a skill-tree progression
system, and live statistics — built in Rust on [ratatui]. Heavily inspired by
[keybr.com], extended for code practice and the terminal.
Warning: still very much a WIP and is heavily vibe-coded so there may be bugs.
Backwards compatibility is not guaranteed in this phase of development.
<img width="1187" height="797" alt="Adaptive drill screen" src="https://github.com/user-attachments/assets/74c358d6-976f-42bc-ae24-efd1feab5579" />
<details>
<summary><h2>Screenshots</h2></summary>
<img width="1187" height="797" alt="Main menu" src="https://github.com/user-attachments/assets/64512852-c379-4e4b-94c6-6e62ef7d0526" />
<img width="1437" height="988" alt="Skill tree" src="https://github.com/user-attachments/assets/d975ac93-ec63-4fff-a275-27a71754d3bb" />
<img width="1437" height="988" alt="Statistics" src="https://github.com/user-attachments/assets/d98e67e8-1f85-46db-a22b-fe8b13cbe3d0" />
<img width="1437" height="988" alt="Keyboard explorer" src="https://github.com/user-attachments/assets/68eb3127-0046-499c-9a11-a641a82debf3" />
<img width="1437" height="988" alt="Code drill (farout theme)" src="https://github.com/user-attachments/assets/b079037b-2ce8-4bba-93b1-38151ebd7b68" />
<img width="1437" height="988" alt="Passage drill (kanagawa lotus theme)" src="https://github.com/user-attachments/assets/78d0eb61-4be0-4c11-aadb-72c355458314" />
<img width="1437" height="988" alt="Settings" src="https://github.com/user-attachments/assets/912b743e-72e3-4a19-84cd-d0ee9267b75e" />
</details>
## What makes it different
Most TUI typing apps either drop you on a fixed word list or randomize from a
dictionary. keydr keeps a model of _your_ typing: per-key timing, per-bigram
error rates, and a confidence score for every key on the keyboard. The text it
generates for you is shaped by that model — words bias toward the keys you're
slowest at, new keys unlock only when the keys you already have are solid, and
the difficulty grows with you.
## Features
### Adaptive engine
- **Per-key confidence model.** Every keystroke updates an exponential moving
average of your per-key time. Your `confidence` for a key is your target
speed divided by that filtered time — a key with `confidence >= 1.0` is at
or above your target speed.
- **Gradual letter unlocking.** You start with 6 letters in English-frequency
order (`e t a o i n …`). A new letter unlocks only when every letter you
already have is confident, so you're never thrown into the full alphabet at
once.
- **Focused-key biasing.** Whichever included key has the lowest confidence
becomes your "focused" key. The generator searches its Markov chain for
states containing the focused letter and starts pseudo-words from there, so
drills naturally pile up practice on your weak spots.
- **Phonetic pseudo-word generation.** Words are generated from a Markov
transition table (the same approach as keybr) so they're pronounceable and
language-shaped, not random consonant noise.
- **N-gram error tracking.** Beyond single keys, keydr tracks bigrams and
trigrams with Laplace-smoothed error rates and a redundancy formula that
separates _genuine_ transition difficulties (e.g. awkward same-finger
bigrams) from errors that are just proxies for a single weak key. Hard
bigrams get folded into drill selection.
### Skill tree
Once you've mastered the lowercase alphabet, you don't just keep grinding
prose — five sibling branches unlock at once and you pick what to drill next:
- **Capitals** (AZ, 3 levels) — sentence-start and proper-noun rules
- **Numbers** (09, 2 levels)
- **Prose punctuation** (`. , ' " ? !` …, 3 levels)
- **Whitespace** (Tab, Enter, …, 2 levels)
- **Code symbols** (`{ } [ ] ( ) ; : = + - * /` …, 4 levels)
Each branch tracks its own current level and its own focused key. You can have
multiple branches "in progress" at once, and drills can be scoped globally
(everything you've unlocked) or to a single branch.
### Three drill modes
- **Adaptive** — the default. Generated text from the phonetic model, biased
by your skill-tree scope and focused key.
- **Code** — language-specific syntax practice. Bundled snippets for Rust,
Python, JavaScript, and Go, with optional opt-in downloads of real code
samples from GitHub for more variety. Configurable per-language, with an
onboarding screen on first use and a download progress screen.
- **Passage** — type real prose from public-domain books (Project
Gutenberg). Opt-in downloads, configurable paragraphs-per-book limit, falls
back to bundled content when offline.
### Statistics dashboard
A multi-tab dashboard with everything the engine has learned about you:
- **Dashboard** — current WPM, accuracy, confident-key count, level/streak
- **History** — recent drills with WPM/accuracy
- **Activity** — a GitHub-style heatmap of drilling activity over time
- **Accuracy** — per-key accuracy heatmap laid out on the keyboard
- **Timing** — per-key timing heatmap
- **N-grams** — your worst bigrams/trigrams, redundancy scores, watchlist of
emerging weak transitions, and what's currently driving drill focus
### Visual keyboard
A live keyboard diagram colors each key by the finger that should press it,
highlights your focused key, and (in terminals that support the Kitty keyboard
protocol) lights up modifier keys as you press them. A separate **Keyboard
Explorer** screen lets you click around the layout to inspect any key's
stats.
### Theming
14 built-in themes: Catppuccin (Mocha/Latte), Dracula, Gruvbox (Dark/Darkest),
Kanagawa (Wave/Dragon/Lotus), Nord, One Dark, Solarized Dark, Tokyo Night,
Farout, plus an ANSI-safe `terminal-default` that respects your terminal's
own colors.
### Internationalization
- **UI translated into 21 languages** (`en`, `de`, `es`, `fr`, `it`, `pt`,
`nl`, `sv`, `da`, `nb`, `fi`, `pl`, `cs`, `ro`, `hr`, `hu`, `lt`, `lv`,
`sl`, `et`, `tr`).
- **Dictionaries for the same 21 languages**, sourced from
[keybr-content-words](https://github.com/aradzie/keybr.com).
- **Keyboard layout profiles**: QWERTY, Dvorak, Colemak, German QWERTZ,
French AZERTY — with per-layout finger maps so the diagram stays accurate.
- Unicode-normalized (NFC) input matching, so composed and decomposed forms
of accented characters compare equal.
### Import / export
Back up everything — config, profile, key stats, ranked stats, drill history
— to a single timestamped JSON file from the Settings page, and restore it on
another machine. Machine-local paths (download directories) are preserved
from the target machine on import. Versioned format with a clear error if
schemas don't match.
### Other niceties
- Atomic JSON writes (temp file → fsync → rename) so a crash mid-save can't
corrupt your stats.
- Auto-continue between drills with a brief input lock so a trailing
keystroke doesn't start the next drill prematurely.
- Mouse support for menus, tabs, and the keyboard explorer.
## Build and run
keydr is a regular Cargo project. You need a recent Rust toolchain (Rust
edition 2024, i.e. a 2026-era stable compiler).
```sh
git clone <repo-url> keydr
cd keydr
cargo run --release
```
Or build once and run the binary:
```sh
cargo build --release
./target/release/keydr
```
### CLI flags
```sh
keydr [--theme <name>] [--layout <layout>] [--words <n>]
```
- `--theme, -t` — pick a theme by name for this run (e.g. `dracula`,
`nord`, `catppuccin-mocha`)
- `--layout, -l` — pick a keyboard layout: `qwerty`, `dvorak`, `colemak`,
`de_qwertz`, `fr_azerty`
- `--words, -w` — number of words per drill
Everything else is set from inside the app, on the Settings screen.
### Features
The `network` Cargo feature (on by default) pulls in `reqwest` and enables the
optional GitHub code download and Project Gutenberg passage download flows. To
build a fully offline binary:
```sh
cargo build --release --no-default-features
```
### Files on disk
- Config: `~/.config/keydr/config.toml`
- Profile, key stats, drill history: `~/.local/share/keydr/*.json`
- Downloaded passages: `~/.local/share/keydr/passages/`
- Downloaded code samples: `~/.local/share/keydr/code/`
(`dirs` is used to resolve these, so the exact paths follow XDG/macOS/Windows
conventions on your platform.)
### Tests and benchmarks
```sh
cargo test
cargo bench # n-gram benchmarks in benches/
```
## Terminal requirements
keydr works in any reasonably modern terminal. For the best experience —
modifier-key highlighting on the keyboard diagram, unambiguous key
disambiguation, and per-key release events — use a terminal that supports the
[Kitty keyboard protocol] (Kitty, WezTerm, foot, recent Ghostty, recent
Alacritty). keydr falls back gracefully on terminals that don't (tmux, mosh,
SSH to older hosts) using timer-based heuristics.
A true-color terminal is recommended so the bundled themes render as
intended; the `terminal-default` theme is provided for ANSI-only setups.
## License and attribution
keydr is licensed under the GNU Affero General Public License v3.0 only
(AGPL-3.0-only) — see [LICENSE](LICENSE).
It incorporates content and ideas from [keybr.com] (dictionaries, the core
adaptive algorithm). See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for
attribution and [docs/license-compliance.md](docs/license-compliance.md) for
the compliance process.
[ratatui]: https://ratatui.rs/
[keybr.com]: https://www.keybr.com/
[Kitty keyboard protocol]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
+209
View File
@@ -0,0 +1,209 @@
# Building keydr
## TL;DR
```bash
cargo build
cargo test
cargo build --release
```
Plain cargo. No wrapper, no memory ceiling, no reduced optimisation.
A clean release build peaks at **1.92 GB** and takes **2m17s** on a
4-core / 7.6 GB VM.
If that's not what you're seeing, read on.
---
## The build used to be unusable — here's what was actually wrong
Release builds consumed 5+ GB and never finished; the VM thrashed so
badly that SSH stopped responding and needed a console reboot.
The obvious explanations were all wrong:
- **Not too many parallel jobs.** The peak came from a *single* rustc
process. A single process's memory is unaffected by `--jobs`.
- **Not insufficient RAM.** 8 GB is fine for a project this size.
- **Not LTO** (though LTO made it worse).
**The cause was a codegen bug in `rust-i18n` v3.** Its macro emitted one
`HashMap::from([...])` construction *and* one `add_translations()` call
**per translation string** — all inside a single function body. With 21
locales and ~9,000 keys that's 8,652 separate HashMap constructions in
one function. LLVM's optimiser scales superlinearly on function size, so
it consumed ~4.5 GB trying to optimise that one initialiser.
Verified by expanding the macro:
| | rust-i18n v3.1.5 | rust-i18n v4.2.1 |
|---|---|---|
| `HashMap::from` constructions | 8,652 | **0** |
| `add_translations` calls | 8,652 | **21** (one per locale) |
| Expanded lines (lib target) | 93,026 | 73,444 |
Upstream fixed this in **v4.0.0**. Reproduce the check yourself:
```bash
cargo install cargo-expand
cargo expand --lib | grep -c 'HashMap::from'
```
### The fix
```bash
cargo add rust-i18n@4
```
That's it. Measured on this box, same machine, clean builds:
| Configuration | Peak | Time |
|---|---|---|
| v3, `opt-level=3`, thin LTO | 5.2 GB | stalled >18 min, never finished |
| v3, `opt-level=3`, no LTO | 4.5 GB | stalled >12 min, never finished |
| v3, `opt-level=1`, no LTO (workaround) | 1.8 GB | 15m30s |
| **v4, `opt-level=3`, thin LTO** | **2.08 GB** | **3m25s** |
| **v4, stock cargo defaults** | **1.92 GB** | **2m17s** |
The earlier workaround — dropping `opt-level` to 1 and disabling LTO —
has been **removed**. It was treating a symptom.
---
## What's still in `.cargo/config.toml`, and why
Nothing there reduces the optimisation of shipped code.
- **`jobs = 3`** — uses 3 of 4 cores so the box stays interactive while
compiling. Purely about responsiveness; delete it if you don't care.
- **`lld` linker** — ships with the Rust toolchain, no install needed.
GNU ld is single-threaded and holds the whole link graph in memory.
- **`debug = 1` for this crate, `debug = 0` for dependencies** (dev/test
profiles only). Debug info is the largest contributor to `target/` size
and link time. Level 1 keeps line numbers, so backtraces still work.
`[profile.release]` is deliberately absent — cargo's defaults are right.
---
## Known remaining inefficiency
`src/main.rs` re-declares the whole module tree (`mod app; mod config;
...`) instead of depending on the `keydr` lib target, so **every module
compiles twice** — once for the lib, once for the bin. The `i18n!` macro
must therefore be invoked in both crate roots (`t!()` expands to
`crate::_rust_i18n_t`, so it must exist in each crate).
Measured with `cargo build --release --timings` on a clean tree, the two
duplicated units are the slowest in the whole graph:
```
33.7s keydr (bin)
26.0s keydr (lib)
1.4s generate_test_profiles (bin)
------
61.0s of 292.2s total unit-seconds (21%)
```
So restructuring `main.rs` to `use keydr::...` would save roughly 26s of
compile work — real, but nowhere near half the build. Wall-clock saving is
smaller still, since those units partly overlap with dependency
compilation across 3 parallel jobs (292s of unit-work compressed into
~2m15s wall). Worth doing for code hygiene; not a build-performance
emergency.
Note the remaining ~79% is dependency compilation (`reqwest` 12.7s,
`clap_builder` 11.4s, `toml_edit` 9.3s, `tokio` 8.0s...), which is
one-time work that incremental builds skip entirely.
---
## If a build ever does go wild again
The machine-level protections below need no per-project changes.
### Keep SSH alive (recommended, needs root once)
```bash
# Reserve memory for the SSH daemon so it can't be swapped out entirely.
sudo systemctl edit ssh # [Service] / MemoryMin=128M
```
**Do not bother with `systemd-oomd` on this box** — it is a separate
package whose only reverse-dependencies are `ubuntu-desktop*`, so Ubuntu
Server never installs it (`systemctl is-enabled systemd-oomd``not-found`
here). Use **earlyoom** instead, which is in `universe` and kills only the
single highest-scoring process rather than a whole cgroup:
```bash
sudo apt install earlyoom
# /etc/default/earlyoom
EARLYOOM_ARGS="-m 8 -s 5 -r 60 \
--avoid '(^|/)(systemd|sshd|mosh-server|tmux.*|bash|fish)$' \
--prefer '(^|/)(rustc|cargo|ld|lld|collect2)$'"
```
`--avoid` protects your shell; `--prefer` points it at the compiler. Fedora
enabled earlyoom by default for exactly this "system becomes completely
unresponsive, user has no choice but to force power off" scenario.
### Cap every build at once, forever (needs root once)
This is the global knob — no wrapper script, no per-project config:
```bash
sudo systemctl set-property user-1000.slice MemoryHigh=5G MemoryMax=6500M
```
Applies to every shell and every build you start, persists across reboots.
Because `sshd` itself lives in `system.slice`, capping the user slice can
never lock you out of a new login. (Ubuntu already ships `TasksMax=33%`
this way via `/usr/lib/systemd/system/user-.slice.d/`, so it's a
distro-blessed pattern.)
Note `MemoryHigh` at the *slice* level is reasonable — it throttles a
sprawling session gradually. Do not put it on a single short-lived build
scope, where it causes the reclaim-stall described below.
### Cap one build ad-hoc (no root)
`memory` is delegated to the user slice on this box, so you can confine a
single command without any wrapper script:
```bash
systemd-run --user --scope -p MemoryMax=4G -p MemorySwapMax=0 \
cargo build --release
```
`MemorySwapMax=0` is the important half. The lockup was never really an
OOM — it was *thrashing*. With swap available the kernel pages `sshd` out
to feed the build and the box goes catatonic while `oom_kill` stays at 0.
Denying the build swap turns a slow total failure into a fast contained one.
**Do not add `MemoryHigh` to a single build scope.** It sounds safer but
traps the process in continuous reclaim so it grinds forever instead of
dying. Measured against an identical 256 MB ceiling: `MemoryMax` alone →
clean kill in seconds; `MemoryMax` + `MemoryHigh` → still spinning after 45
seconds.
### Diagnosing which crate is expensive
```bash
cargo build --release --timings # HTML report in target/cargo-timings/
cargo install cargo-llvm-lines
cargo llvm-lines --release | head -30 # which functions generate the most IR
cargo expand --lib | wc -l # how much code a macro really emits
```
`cargo llvm-lines` and `cargo expand` are what actually found this bug.
Reach for them before touching `opt-level`.
## Upstream context
Cargo has no memory-aware job scheduling; it schedules on core count only.
That's a known gap ([rust-lang/cargo#12912](https://github.com/rust-lang/cargo/issues/12912)),
and maintainers have said they'd rather delegate resource limits to the OS
(cgroups) than build it into cargo. So the systemd approach above isn't a
hack — it's the sanctioned answer. But in this case none of it was needed:
the real fix was a dependency upgrade.
+10
View File
@@ -1,3 +1,13 @@
// The `i18n!` macro must be invoked in each crate root that uses `t!()`,
// because `t!` expands to `crate::_rust_i18n_t`. main.rs re-declares the
// module tree rather than depending on the lib target, so it is a separate
// crate and needs its own invocation.
//
// This means the translation table is generated twice (once here, once in
// lib.rs). With rust-i18n v4 that costs ~21 add_translations calls per
// invocation instead of v3's ~8,650, so the duplication is now cheap.
// Eliminating it entirely would require main.rs to `use keydr::...` instead
// of re-declaring `mod app; mod config; ...` — a larger refactor.
rust_i18n::i18n!("locales", fallback = "en");
mod app;