diff --git a/docs/lessons/16-supervised-app-with-logs.md b/docs/lessons/16-supervised-app-with-logs.md new file mode 100644 index 0000000..c084971 --- /dev/null +++ b/docs/lessons/16-supervised-app-with-logs.md @@ -0,0 +1,97 @@ + + +# PR #16 — Tracer: supervised app with logs + +> **Rust lesson:** To mutate state from an async task that outlives the function that spawned it, hand the task an `Arc>` clone — `Arc` gives shared ownership across threads, the `Mutex` makes the mutation safe. +> **Tags:** `Arc>` · `tokio::spawn` +> **Merged:** 2026-06-08 · +2369/−0 · [View PR](https://github.com/nonrational/adjacent/pull/16) + +## The situation + +This is the tracer-bullet PR that stood the project up: register a dev-server directory, boot it, capture its logs, and report whether it's running, stopped, or crashed. The core is a `Supervisor` that spawns a child process and then needs to know, later and asynchronously, when that child exits — and update its recorded state accordingly. The catch: the code that spawns the child returns immediately, but the exit can happen minutes later. Two different tasks end up touching the same state, and Rust will not let that happen by accident. + +## The Rust idea + +In a garbage-collected language you would capture `this` in a callback and mutate a field whenever the process exits. Nobody checks who else is touching that field at the same time. + +Rust makes the sharing explicit, for two reasons: + +1. **Lifetimes.** A task handed to `tokio::spawn` must be `'static` — it may outlive the function that created it, so it cannot borrow anything on that function's stack (including `&self`). It has to *own* what it uses. +2. **Threads.** Tokio's default runtime is multi-threaded, so the spawned task may run on a different worker thread than the one that spawned it. Anything it captures must be safe to send and share across threads (`Send + Sync`). + +`Arc>` is the idiomatic answer to both: + +- `Arc` — an **a**tomically **r**eference-**c**ounted pointer. Cloning it doesn't copy the data; it bumps a refcount and hands back a second owner of the *same* `T`. Both owners are `'static`, so both can go into spawned tasks. +- `Mutex` — guards the interior so only one task mutates at a time. `tokio::sync::Mutex::lock().await` *yields* the worker thread while waiting instead of blocking it, which is what you want inside async code. + +The single-threaded shortcut `Rc>` won't compile here: `Rc` isn't `Send`, so `tokio::spawn` rejects it. + +## In this PR + +The supervisor holds all app state behind one shared, locked handle: + +```rust +// crates/adj/src/supervisor.rs +#[derive(Default)] +pub struct Supervisor { + inner: Arc>, +} + +#[derive(Default)] +struct Inner { + apps: HashMap, +} +``` + +`up()` spawns the child, records it as `Running`, then detaches a task to wait for the exit: + +```rust +// crates/adj/src/supervisor.rs +pub async fn up(&self, app_dir: PathBuf, cfg: AppConfig) -> Result { + let name = cfg.name.clone(); + let mut inner = self.inner.lock().await; + // ... + let mut child = command + .spawn() + .with_context(|| format!("spawning `{}`", cfg.cmd))?; + + let pid = child.id().ok_or_else(|| anyhow!("spawned child has no pid"))?; + + inner.apps.insert( + name.clone(), + AppRuntime { state: AppState::Running { pid }, intentional_stop: false }, + ); + + // Detach the wait task so the supervisor can observe exit/crash without holding the lock. + let inner_handle = self.inner.clone(); + let watch_name = name.clone(); + tokio::spawn(async move { + let status = child.wait().await; + let mut guard = inner_handle.lock().await; + if let Some(rt) = guard.apps.get_mut(&watch_name) { + // ... record Stopped, or Crashed { exit_code } + } + }); + + Ok(pid) +} +``` + +The load-bearing lines are the two just before `tokio::spawn`: + +- `let inner_handle = self.inner.clone();` clones the **`Arc`**, not the `Inner`. The new handle points at the same `Mutex` the `Supervisor` still owns. +- `async move { ... }` moves `inner_handle`, `watch_name`, and `child` *into* the task. Now the task owns everything it touches — no borrow of `self`, so the `'static` requirement is satisfied. + +When the child eventually exits, the task takes the lock (`inner_handle.lock().await`) and flips the app's `state` from `Running` to `Stopped` or `Crashed`. Meanwhile `state()`, `down()`, and the next `up()` all reach the same `HashMap` through the same `Mutex` — one writer at a time, no data race, enforced at compile time. + +## Why it matters + +Without `Arc`, you can't share the state at all: the spawned task can't borrow `self`, and moving `self.inner` into it would leave the `Supervisor` with nothing. Without the `Mutex`, two tasks writing the same `HashMap` is a data race — which in most languages is a heisenbug you find in production, and in Rust is a compile error you fix before lunch. The pattern is verbose on purpose: it forces you to name, up front, exactly which state is shared and how it's synchronized. + +## Related lessons + +- Later PRs reuse this exact `Arc>` foundation for trickier concurrency — the proxy's lazy-boot single-flight and the idle scanner's re-check-under-lock both build on the supervisor's `Inner` mutex. Those PRs are the place to teach the *variations* (single-flight, closing the request-vs-scanner race), not the base pattern taught here. + +## Dig deeper + +- [The Rust Book, ch. 16.3](https://doc.rust-lang.org/book/ch16-03-shared-state.html) — Shared-State Concurrency (`Mutex` and `Arc`) diff --git a/docs/lessons/17-inject-port-into-child-env.md b/docs/lessons/17-inject-port-into-child-env.md new file mode 100644 index 0000000..07f242a --- /dev/null +++ b/docs/lessons/17-inject-port-into-child-env.md @@ -0,0 +1,87 @@ + + +# PR #17 — Inject PORT into supervised processes + +> **Rust lesson:** `tokio::process::Command` is a builder that inherits the parent process's environment by default; each `&mut self` method chains, and `.env(key, value)` layers one variable on top before `.spawn()` runs anything. +> **Tags:** `tokio::process::Command` · `builder-pattern` · `env-vars` +> **Merged:** 2026-06-08 · +322/−12 · [View PR](https://github.com/nonrational/adjacent/pull/17) + +## The situation + +A supervised dev server has to bind *somewhere*, and the daemon — not the app — picks the port (it allocates a free one via a `:0` bind probe). So the daemon must hand that number to the child before it starts. The classic Unix mechanism is an environment variable: inject `PORT=54321` into the child's environment, and the app reads `$PORT` and binds it. Some apps insist on a different name, so `adjacent.toml` gains a `port_env` knob to rename the injected variable. + +## The Rust idea + +Rust has no keyword arguments, no default parameters, and no function overloading. When a thing has many optional knobs and one final "go" step — spawning a process is the textbook case — the idiomatic shape is the **builder pattern**. + +`Command::new("sh")` returns a mutable `Command`. Every configuration method (`.arg`, `.current_dir`, `.env`, `.stdout`, `.process_group`) takes `&mut self` and returns `&mut Self`. Returning the same mutable borrow is what lets calls chain, and because nothing is *moved*, you can also configure across several statements on the same binding. Nothing actually runs until a terminal method — here `.spawn()`. + +The environment piece has one detail that trips people up: **a freshly built `Command` inherits the parent process's entire environment.** You start with a full copy of whatever the daemon itself was launched with. The env methods then edit that inherited set: + +- `.env(key, value)` — insert or **override** one entry. +- `.envs(iter)` — the same, for many at once. +- `.env_remove(key)` — drop one inherited entry. +- `.env_clear()` — throw the whole inheritance away and start empty. + +"Inherit, then override" is a precedence decision baked into the default: a later `.env` wins over whatever the child would have inherited under the same name. + +## In this PR + +The core is a handful of chained builder calls in `up()`. The port name is resolved first, then layered onto the command: + +```rust +// crates/adj/src/supervisor.rs +let port_env = cfg.port_env.as_deref().unwrap_or("PORT"); +let mut command = Command::new("sh"); +command + .arg("-c") + .arg(&cfg.cmd) + .current_dir(&app_dir) + .env(port_env, port.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(stderr_file)); +``` + +- `cfg.port_env.as_deref().unwrap_or("PORT")` collapses `Option` into a plain `&str`: `as_deref()` borrows the inner `String` as `&str` if present, and `unwrap_or("PORT")` supplies the default when it's `None`. One expression, one default, no branch. +- `.env(port_env, port.to_string())` layers exactly one variable onto the inherited environment. The value has to be an *owned* `String` (`port` is a `u16`), because `.env` keeps the bytes for the eventual spawn — a borrowed slice tied to this stack frame wouldn't outlive it. + +The config field that feeds `port_env` is optional by construction: + +```rust +// crates/adj/src/registry.rs +/// Override the env var name used to inject the assigned port. +/// When unset, Adjacent exports `PORT`. When set, it exports the named variable instead. +#[serde(default)] +pub port_env: Option, +``` + +`#[serde(default)]` means an absent key deserializes to `None` instead of erroring — that's what makes `port_env` opt-in. + +The load-bearing proof that inheritance is the default lives in the test harness: + +```rust +// crates/adj/tests/tracer.rs +// Scrub port-related env vars from the parent shell so the daemon (and any child it +// spawns) starts from a known-clean slate. Without this, `PORT=3000 cargo test` would +// pass `PORT` through to the supervised child and silently break the rename test. +c.env_remove("PORT"); +c.env_remove("BIND_PORT"); +``` + +Read that comment as a demonstration of the semantics: because `Command` copies the parent's environment and `.env` only touches the keys you name, a stray `PORT` in the test runner's own environment flows straight through to the child. The `rename` test asserts the child sees `BIND_PORT` set and `PORT` *unset* — which is only true if the parent isn't leaking a `PORT` of its own. + +## Why it matters + +In a language where you mutate a shared, global `process.env`, injecting a variable is order-dependent and easy to leak between spawns. The `Command` builder gives you an isolated, per-child edit list assembled on a `&mut Command`, so the process can't be spawned half-configured — there's no window where the child exists but `PORT` hasn't been set yet, because `.spawn()` is the last thing you call. + +The trap the builder's default hides is the inheritance itself. It's convenient (the child gets `PATH`, `HOME`, and the rest for free) but it's also a silent channel: if the daemon happened to run with `PORT` in its own environment, every child would inherit it, and injection would be masking rather than setting. That's exactly why the test scrubs `PORT`/`BIND_PORT` before starting — not paranoia, but a direct consequence of "inherit, then override" being the default you signed up for. + +## Related lessons + +- PR #16 stands up the `Supervisor` and the `up()` method this `.env()` call lives inside — the `Arc>` state and the wait-for-exit task are the foundation this port injection slots into. +- PR #76 generalizes this single-variable mechanic into a whole daemon-owned `ADJ_*` namespace (`ADJ_NAME`, `ADJ_HOST`, `ADJ_URL`, …) injected *after* `env_file` and `[env]` so the daemon's values win — that's where the full env-layering-and-precedence story gets taught. #17 is the one-variable version; #76 is the layered namespace. + +## Dig deeper + +- [`std::process::Command`](https://doc.rust-lang.org/std/process/struct.Command.html) — tokio's `Command` mirrors this API. The docs for `.env`, `.envs`, `.env_remove`, and `.env_clear` spell out the inherit-then-edit model, and the struct-level intro is a clean example of the builder pattern. diff --git a/docs/lessons/19-optional-fields-with-serde.md b/docs/lessons/19-optional-fields-with-serde.md new file mode 100644 index 0000000..8fd5632 --- /dev/null +++ b/docs/lessons/19-optional-fields-with-serde.md @@ -0,0 +1,101 @@ + + +# PR #19 — Env loading: [env] table and env_file pointer + +> **Rust lesson:** `Option` is how a struct field says "this may be absent" — and serde's derive fills a missing `Option` field with `None` for you, while `#[serde(default)]` is what saves any *non*-`Option` field from erroring when its key is missing. +> **Tags:** `option` · `serde` · `deserialize` · `config` +> **Merged:** 2026-06-08 · +509/−1 · [View PR](https://github.com/nonrational/adjacent/pull/19) + +## The situation + +`adjacent.toml` grew two new, entirely optional fields: an `[env]` table of committed-safe `KEY = "value"` pairs, and `env_file`, a pointer to a dotenv file. Most apps set neither. The config struct has to parse a TOML file where these keys are usually just *not there* — and it must not treat "absent" as an error. That is a modeling question before it is a parsing one: how does a Rust struct field say "maybe nothing"? + +## The Rust idea + +Rust has no `null`. A `String` field always holds a real string; there is no back-door "empty" value that means "unset." When absence is a legitimate state, you say so in the type with `Option` — an enum with exactly two variants: + +```rust +enum Option { + None, // absent + Some(T), // present, carrying a T +} +``` + +The payoff is that the compiler forces every reader to handle both cases before they can touch the inner `T`. "I forgot the field might be missing" stops being a runtime surprise and becomes a compile error. + +serde bridges this to the data on disk. When you `#[derive(Deserialize)]`, serde generates code that walks the TOML and fills each struct field. Its default stance is strict: **a field whose key is missing from the input is an error** ("missing field `port`"). That is usually what you want — a required field that silently vanished is a bug. + +Two things soften that strictness, and they are easy to conflate: + +- `#[serde(default)]` — "if this key is missing, use the type's `Default` instead of erroring." This is what makes a field genuinely optional at the data level. +- `Option` — a *special case* serde already knows about. A missing `Option` field deserializes to `None` on its own, without `#[serde(default)]`, because serde special-cases the "no value" path for options. + +So for the `Option` fields in this PR, the `#[serde(default)]` attribute is technically redundant — they would parse to `None` regardless. It is kept for consistency with the existing `port_env` field above them, and because the intent reads clearly at a glance. Drop `Option` for a required-but-defaulted field (say `port: u64` defaulting to `0`) and the attribute stops being optional itself: without it, a missing `port` is a hard error. + +## In this PR + +The whole change to the config type is four lines of field declarations: + +```rust +// crates/adj/src/registry.rs +pub struct AppConfig { + pub name: String, + pub cmd: String, + #[serde(default)] + pub port_env: Option, + /// Committed-safe environment variables merged into the spawned process env. + /// On conflict with `env_file`, this table wins. PORT injection always wins over both. + #[serde(default)] + pub env: Option>, + /// Path to a dotenv-format file, resolved relative to the registered app directory. + /// Missing files are a startup error. + #[serde(default)] + pub env_file: Option, +} +``` + +`name` and `cmd` are bare `String` — required, and a manifest missing either one is rightly rejected. The two new fields are `Option`, so `None` is the normal, no-error resting state. + +Notice `env` is `Option>`. A TOML `[env]` table maps straight onto a map type, and serde recurses: it deserializes each `KEY = "value"` entry into a `(String, String)` pair. `BTreeMap` (not `HashMap`) keeps the keys sorted, so iterating it later to set env vars is deterministic — the same manifest always applies its overrides in the same order. + +The consumer side then reads these as ordinary `Option`s, and the `if let Some(...)` is the compiler-enforced "handle both cases": + +```rust +// crates/adj/src/supervisor.rs +// Resolve env layers before any port allocation so a missing `env_file` or unreadable +// file fails fast with a clear error (and doesn't leak a port reservation). +let env_file_values = if let Some(rel) = cfg.env_file.as_deref() { + let resolved = app_dir.join(rel); + Some(load_env_file(&resolved)?) +} else { + None +}; +``` + +```rust +// crates/adj/src/supervisor.rs +if let Some(values) = &cfg.env { + for (k, v) in values { + command.env(k, v); + } +} +command.env(port_env, port.to_string()); +``` + +`None` means "no `[env]` table, do nothing"; `Some(values)` unwraps the map so you can iterate it. There is no way to iterate the map without first proving it exists — that is `Option` doing its job. + +One honest footnote: `env_file` points at a *dotenv* file, which is not TOML, so serde doesn't parse its contents. The PR hand-rolls a tiny `parse_dotenv` in `crates/adj/src/env.rs`. serde's reach ends at the `adjacent.toml` boundary; the file it points to is parsed by hand. + +## Why it matters + +In a language with `null`, "this field is optional" and "I forgot to set this field" look identical at the type level — both are `null` — and the difference only shows up as a `NullPointerException` at 2am. Rust splits them: absence is `Option`, and the compiler won't let you read through it without checking. Pair that with serde's default strictness and you get a config loader where *required* fields are enforced for free (a missing `cmd` is rejected at parse time) and *optional* fields are spelled out deliberately, one `Option` at a time. The trap this avoids is the silent empty-string or zero-value that a laxer parser would hand you for a field the user never wrote. + +## Related lessons + +- PR #48 stays in this same `AppConfig` neighborhood but on the `Result` side — not swallowing a parse `Err` when a *present* field is malformed. #19 is about a field being absent; #48 is about a field being present-but-wrong. +- PR #22 covers the mirror-image direction, serde `Serialize` for the JSON DTOs; PR #75 covers *writing* an `adjacent.toml`. This lesson stays strictly on the `Deserialize` + `Option` reading side. + +## Dig deeper + +- [The Rust Book, ch. 6.1](https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html#the-option-enum-and-its-advantages-over-null-values) — the `Option` enum and its advantages over null. +- [serde: field attributes](https://serde.rs/field-attrs.html) — `#[serde(default)]` and how missing fields are handled. diff --git a/docs/lessons/22-serialize-wire-dto-contract.md b/docs/lessons/22-serialize-wire-dto-contract.md new file mode 100644 index 0000000..2a59b61 --- /dev/null +++ b/docs/lessons/22-serialize-wire-dto-contract.md @@ -0,0 +1,125 @@ + + +# PR #22 — Agent JSON output on read commands + +> **Rust lesson:** Emit your public JSON through dedicated DTO structs, not your internal types — `#[derive(Serialize)]` when the type's natural shape already matches the contract, and hand-write `impl Serialize` when you need to pin an exact shape the derive won't give you. +> **Tags:** `serde` · `Serialize` · `wire-contract` +> **Merged:** 2026-06-08 · +1071/−38 · [View PR](https://github.com/nonrational/adjacent/pull/22) + +## The situation + +Every read command (`adj list`, `status`, `logs`) grew a `--json` flag, and the output is a *documented, stable* schema — `crates/adj/JSON.md` is the contract, and the test suite asserts the exact keys. The catch: the internal `AppState` enum already crosses the daemon's IPC socket as serde JSON, but the shape serde gives that enum is *not* the flat shape the public `--json` contract promises. The PR had to publish an exact documented shape without letting serde's internal enum encoding leak out as the public API. + +## The Rust idea + +`#[derive(Serialize)]` is a procedural macro. At compile time it reads a type's fields and *writes* an `impl Serialize` for you — attribute-driven code generation. You never see the generated code, but it's ordinary Rust, and `#[serde(...)]` attributes steer what it emits: + +- `#[serde(rename_all = "snake_case")]` — `Stdout` becomes `"stdout"` on the wire. +- `#[serde(tag = "kind")]` on an enum — each variant serializes as an object carrying a `kind` discriminator, e.g. `{"kind":"running","pid":…,"port":…}`. +- `#[serde(skip_serializing_if = "Option::is_none")]` — omit the field entirely when the check passes. + +That derived encoding is faithful and *reversible* — it round-trips cleanly back through `Deserialize`, which is exactly what you want for a type you both send and receive (the daemon's IPC). But it is welded to the type's internal structure. Rename a variant, add a field, switch the tag representation, and the JSON moves with it. That's fine for an internal wire format both ends rebuild from the same crate. It's a landmine for a *public* contract that outside `jq` scripts depend on. + +The discipline is a **DTO** — a data-transfer object, a struct whose only job is the wire shape, separate from the type it borrows from. Two cases: + +1. When the DTO's natural derived shape already equals the contract, derive it and move on. +2. When it doesn't — you need a flat object, a hoisted field, an enum rendered as a bare string — remember `Serialize` is just a trait. The derive is one implementation; you can write your own and emit exactly the keys the contract documents. + +This PR does both, side by side. + +## In this PR + +The internal type is a tagged enum. It derives `Serialize`/`Deserialize` because it round-trips over IPC, and the new `started_at` is optional so old serialized state still deserializes: + +```rust +// crates/adj-protocol/src/lib.rs +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AppState { + Stopped, + Running { + pid: u32, + port: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + started_at: Option, + }, + Crashed { exit_code: i32 }, +} +``` + +Serialized directly, an `AppState::Running` is `{"kind":"running","pid":…,"port":…}` — the `kind` tag, port nested inside the state object. But `JSON.md` documents `list` entries as *flat*: `{ "name": …, "path": …, "state": "running", "port": 53412 }`. `state` is a plain string; `port` sits at the top level and appears only when running. The derive can't produce that from `AppState` — so the PR introduces a DTO with a hand-written impl: + +```rust +// crates/adj-protocol/src/lib.rs +#[derive(Debug, Clone)] +pub struct ListEntryDto<'a> { + pub name: &'a str, + pub path: &'a str, + pub state: &'a AppState, +} + +impl<'a> Serialize for ListEntryDto<'a> { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(None)?; + map.serialize_entry("name", self.name)?; + map.serialize_entry("path", self.path)?; + map.serialize_entry("state", state_tag(self.state))?; + if let AppState::Running { port, .. } = self.state { + map.serialize_entry("port", port)?; + } + map.end() + } +} +``` + +Every documented key is written by hand, in order. `state` is flattened to a bare string by a small helper instead of the enum's tagged object: + +```rust +// crates/adj-protocol/src/lib.rs +fn state_tag(state: &AppState) -> &'static str { + match state { + AppState::Stopped => "stopped", + AppState::Running { .. } => "running", + AppState::Crashed { .. } => "crashed", + } +} +``` + +The `if let` is where "`port` present iff running" lives — it's a fact about the *contract*, expressed in the serializer, not a property serde could infer from the enum. The DTO borrows (`&'a str`, `&'a AppState`) rather than cloning, so producing the wire view costs nothing but the JSON text. `StatusDto` does the same for `status --json`, adding `pid` / `started_at` / `exit_code` per state. + +Contrast the log record. Its natural flat shape *is* the contract, so the derive is enough — no hand-written impl: + +```rust +// crates/adj-protocol/src/lib.rs +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogRecord { + pub ts: String, + pub stream: LogStream, + pub line: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum LogStream { + Stdout, + Stderr, +} +``` + +That's the whole judgment call in one file: derive `LogRecord` (shape matches, and it round-trips back off disk), hand-write `ListEntryDto` (shape doesn't match, and it's write-only). + +## Why it matters + +Slap `#[derive(Serialize)]` on `AppState` and hand it to `println!` and you've quietly published serde's `{"kind":"running",…}` nesting as your public API. It works, the tests pass, everyone's happy — until you rename a variant or restructure the enum six months later and every downstream `jq` pipeline breaks, with nothing in the type to warn you the shape was load-bearing. The DTO is the seam that decouples the contract from the type: `AppState` stays free to change, `ListEntryDto`'s impl stays pinned to `JSON.md`, and `json_output.rs` asserts the exact keys so drift shows up as a red test, not a support ticket. In a dynamically-typed language you'd assemble that dict by hand anyway; Rust's derive is convenient enough to tempt you into exposing the internal shape by accident, and the DTO is the deliberate "no, this is the wire format" boundary. + +## Related lessons + +- **PR #16** (`Arc>`) — this same PR reuses that pattern to write the JSONL: the `LogWriter` wraps the file in `Arc>` so the stdout and stderr reader tasks can append `LogRecord`s without racing. +- **PR #19** teaches the mirror image on the read side — `#[derive(Deserialize)]` and `Option` fields. Here `LogRecord` round-trips both ways; #22 is the `Serialize` half, #19 the `Deserialize` half. +- **PR #40** (generics) — the hand-written `fn serialize` is generic over the output format; that's the trait-bound machinery #40 covers, applied to serde's serializer. + +## Dig deeper + +- [serde.rs — Using derive](https://serde.rs/derive.html) — how `#[derive(Serialize, Deserialize)]` and the `#[serde(...)]` attributes drive codegen. +- [serde.rs — Implementing Serialize](https://serde.rs/impl-serialize.html) — writing the trait by hand with `SerializeMap`, for the shapes the derive won't give you. diff --git a/docs/lessons/24-single-flight-boot-gate.md b/docs/lessons/24-single-flight-boot-gate.md new file mode 100644 index 0000000..5b84b56 --- /dev/null +++ b/docs/lessons/24-single-flight-boot-gate.md @@ -0,0 +1,92 @@ + + +# PR #24 — Reverse proxy + lazy-boot + hostname routing + +> **Rust lesson:** To boot a resource exactly once under concurrent demand, gate it with a *per-key* lock and re-check the state after you acquire it — a short-lived map lock hands out the per-name lock, which one waiter holds across the slow work while the rest re-check and skip it. +> **Tags:** `single-flight` · `per-key-mutex` +> **Merged:** 2026-06-08 · +905/−0 · [View PR](https://github.com/nonrational/adjacent/pull/24) + +## The situation + +The proxy lazy-boots an app on its first request. But a browser opening a page fires many requests at once — HTML, then a dozen assets — and an agent might hit the same host in parallel too. If each first-request naively booted the app, three concurrent hits would spawn three dev servers fighting over one port. That's a *thundering herd*: N callers all racing to create the same thing. The fix is **single-flight** — one boot runs, everyone else waits for it and reuses the result. + +## The Rust idea + +Single-flight is a lock plus a **double check**. You check the state, and if the resource already exists you're done. If not, you take a lock, then check *again* — because another task may have finished the boot while you were waiting for the lock. Only if it's still missing do you do the work. + +Two design choices make this fast: + +1. **Per-key locking, not a global lock.** One `Mutex` guarding all boots would serialize *unrelated* apps: booting `site` would block the first request to `blog`. Instead we keep a `HashMap>>` — one lock per app name. Booting `site` and `blog` proceed independently; only concurrent boots of the *same* name serialize. + +2. **Two lock granularities.** The map itself needs a lock (two tasks might insert a new name at once). But you only hold the map lock long enough to look up or create the per-name lock, then you `.clone()` the `Arc` and release the map. The slow part — the actual boot — is held under the per-name lock, not the map lock. If you held the map lock across the boot, you'd be back to a global lock and lose the whole point. + +The `Mutex<()>` guards *nothing* — its value is the empty tuple. It's a pure mutual-exclusion token; the thing being protected is the boot side effect, not any data inside the lock. + +## In this PR + +The gate is a lock-of-locks. The map is behind one `Mutex`; each entry is an `Arc>` you can clone out and hold on its own: + +```rust +// crates/adj/src/proxy.rs +#[derive(Default)] +pub struct BootGate { + locks: Mutex>>>, +} + +impl BootGate { + async fn lock_for(&self, name: &str) -> Arc> { + let mut map = self.locks.lock().await; + map.entry(name.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } +} +``` + +`lock_for` takes the map lock, gets-or-creates the per-name lock, clones the `Arc`, and returns — the map guard drops at the end of the function, so the map is free again immediately. The caller now holds a clone of the per-name lock and can hold *it* for as long as the boot takes. + +`ensure_running` is the double check. A fast path avoids the lock entirely when the app is already hot, then the slow path serializes and re-checks: + +```rust +// crates/adj/src/proxy.rs +async fn ensure_running(/* ... */) -> Result { + // Fast path: already running. Skip taking the per-name lock to avoid head-of-line blocking + // when the app is hot. + if let AppState::Running { port, .. } = supervisor.state(name).await { + return Ok(port); + } + + // ... look up the registry entry + config once, outside the lock ... + + // Single-flight: serialize concurrent boot attempts for this name. The first holder runs the + // boot; later holders re-check state under the lock and find Running. + let name_lock = gate.lock_for(name).await; + let _guard = name_lock.lock().await; + + if let AppState::Running { port, .. } = supervisor.state(name).await { + return Ok(port); + } + + supervisor + .up(entry.path.clone(), cfg.clone()) + .await + .map_err(ProxyError::Other)?; + // ... then poll until the child binds its port ... +} +``` + +Three requests for `flight.adj.ac` arrive together. All three miss the fast path. All three call `lock_for` and get the *same* `Arc>`. One wins `name_lock.lock().await` and boots; the other two block on `.await` (yielding their worker threads, not spinning). When the winner drops `_guard`, the next task acquires the lock, hits the re-check, sees `Running`, and returns the port without booting. The PR's integration test asserts exactly this: three concurrent first-requests, `assert_eq!(spawns, 1)`. + +The `let _guard = ...` binding matters. The guard is held until the end of the function scope; naming it `_guard` (not `_`) keeps it alive. Bind it to a bare `_` and Rust drops it *immediately*, releasing the lock before the boot even starts — silently defeating the whole mechanism. + +## Why it matters + +In a garbage-collected language you'd reach for a `ConcurrentHashMap.computeIfAbsent` or a library `singleflight` helper and mostly not think about the re-check. Do it by hand and the classic bug is checking state only *before* the lock: two tasks both see "stopped", both wait, both acquire the lock in turn, and both boot — the lock made them polite but didn't make the boot single. The second check, *inside* the lock, is the load-bearing line. The other trap is reaching for one global lock for simplicity, which quietly turns independent app boots into a queue. Per-key locking keeps unrelated work parallel; the double-check keeps related work single. + +## Related lessons + +- PR #16 introduces the base `Arc>` pattern this builds on — shared ownership across `tokio::spawn`ed tasks. Read that first; this lesson is the single-flight *variation* layered on top. + +## Dig deeper + +- [`tokio::sync::Mutex`](https://docs.rs/tokio/latest/tokio/sync/struct.Mutex.html) — the async mutex used here; note the docs' guidance on holding a guard across `.await`, which is exactly what the boot lock does. diff --git a/docs/lessons/29-deadlines-and-failure-enums.md b/docs/lessons/29-deadlines-and-failure-enums.md new file mode 100644 index 0000000..270e92b --- /dev/null +++ b/docs/lessons/29-deadlines-and-failure-enums.md @@ -0,0 +1,117 @@ + + +# PR #29 — Readiness URL, wait-ready, and idle shutdown + +> **Rust lesson:** Bound async work two ways — thread a `deadline: Instant` through a poll loop, and wrap a single hang-prone `await` in `tokio::time::timeout` — then model the outcomes as a custom enum so each caller can react to *why* the wait ended, not just *that* it did. +> **Tags:** `tokio-timeout` · `deadlines` · `error-enum` +> **Merged:** 2026-06-08 · +1102/−56 · [View PR](https://github.com/nonrational/adjacent/pull/29) + +## The situation + +When a request arrives for a stopped app, the proxy lazy-boots it and holds the request until the app answers. That "wait until ready" is unbounded by nature: the app might take two seconds, or crash on startup, or accept the TCP connection but never write a byte. This PR pulled the wait into `readiness.rs`, added an optional `health_check_url` (poll for a 2xx instead of a bare TCP open), and shared the same code with the new `adj wait-ready` command. Both callers need the same thing: a wait that *always* ends, and an answer that says *how* it ended. + +## The Rust idea + +Two distinct tools, because there are two distinct hangs to bound. + +**Bounding the whole operation — a deadline.** An `Instant` is an absolute point on the monotonic clock. Compute it once (`now() + timeout`), thread it into the loop, and each pass checks `now() >= deadline`. An absolute deadline stays fixed no matter how many times you re-enter the check; a relative "time remaining" would have to be recomputed on every iteration. This bounds the *sum* of many poll attempts. + +**Bounding one attempt — `tokio::time::timeout`.** A single `.await` can block forever — an HTTP GET to a socket that accepts but never responds never resolves. `tokio::time::timeout(dur, fut)` races `fut` against a timer and returns `Result`: `Ok(value)` if the future finished first, `Err(Elapsed)` if the clock won. This bounds one attempt so a single hung probe can't stall the whole poll cadence. + +**Modeling the outcome — a custom enum.** You could return `Result`, but then "crashed" and "timed out" are both just strings, and a caller who wants to treat them differently is stuck matching on message text. Instead, define an enum with one variant per outcome. Now the caller `match`es on the variant, and — the payoff — a crash can *short-circuit* the poll loop instead of waiting out the full deadline. + +## In this PR + +The outcome type. Note it's a plain `#[derive(Debug)]` enum — no `thiserror`, no `Display`, no `std::error::Error` impl. It never needs one: it's an internal signal that each caller matches immediately and translates into its own error surface. + +```rust +// crates/adj/src/readiness.rs +#[derive(Debug)] +pub enum ReadinessError { + /// The supervisor reports the app is not running and the deadline passed before it bound + /// its port (or the configured health URL never returned 2xx). + Timeout, + /// The supervised process exited non-zero while we were waiting. + Crashed { exit_code: i32 }, +} +``` + +The wait itself takes the `deadline` as a parameter and polls. The `Crashed` arm returns *immediately* — that's the whole reason the enum earns its keep: + +```rust +// crates/adj/src/readiness.rs +pub async fn wait_ready( + name: &str, + supervisor: &Supervisor, + cfg: &AppConfig, + deadline: tokio::time::Instant, +) -> Result { + loop { + match supervisor.state(name).await { + AppState::Running { port, .. } => { + if probe_once(port, cfg.health_check_url.as_deref()).await { + return Ok(port); + } + if tokio::time::Instant::now() >= deadline { + return Err(ReadinessError::Timeout); + } + tokio::time::sleep(READY_POLL_INTERVAL).await; + } + AppState::Crashed { exit_code } => { + return Err(ReadinessError::Crashed { exit_code }); + } + AppState::Stopped => { + if tokio::time::Instant::now() >= deadline { + return Err(ReadinessError::Timeout); + } + tokio::time::sleep(READY_POLL_INTERVAL).await; + } + } + } +} +``` + +Inside a single probe, `tokio::time::timeout` bounds the one `await` that could hang forever — an app that completes the TCP handshake but never writes a response: + +```rust +// crates/adj/src/readiness.rs + tokio::time::timeout(Duration::from_millis(500), attempt) + .await + .ok() + .flatten() + .unwrap_or(false) +``` + +`timeout(...)` yields `Result, Elapsed>`. `.ok()` drops the `Elapsed` into `Option>`, `.flatten()` collapses the nesting, and `.unwrap_or(false)` maps *both* failure shapes — the timer fired, or the request errored — to "not ready, poll again." A timed-out probe is not fatal; it just means try once more before the outer deadline. + +The payoff lands at the callers, where the *same two variants* fan out to *different* surfaces. In the daemon's `wait-ready` handler each becomes a distinct CLI-facing message: + +```rust +// crates/adj/src/daemon.rs + match readiness_wait(&name, supervisor.as_ref(), &cfg, deadline).await { + Ok(_) => Ok(Response::Ok), + Err(ReadinessError::Timeout) => Err(anyhow!( + "app `{name}` did not become ready within {timeout:?}" + )), + Err(ReadinessError::Crashed { exit_code }) => Err(anyhow!( + "app `{name}` crashed while waiting for ready (exit {exit_code})" + )), + } +``` + +The proxy's boot path matches the identical enum but maps `Timeout` to an HTTP 504 and `Crashed` to a 502. One outcome type, two error surfaces, zero string-sniffing. + +## Why it matters + +Collapse `ReadinessError` back into a single `anyhow::Error` string and two things break. First, the boot path loses the short-circuit: a crash would fall through as "still not ready," and the proxy would poll a dead process until the 60-second deadline before finally giving up — a full minute of a user staring at a hung request for an app that died in the first 200ms. The `Crashed` variant is what lets the loop bail the instant the supervisor reports the exit. Second, without either bound — the outer deadline or the inner `timeout` — a never-ready app pins the request (or the `adj wait-ready` process) forever. A language with exceptions would let you `throw` on timeout, but you'd still be hand-rolling the "which failure was it" dispatch on message strings; the enum makes the answer a type the compiler checks you handled. + +## Related lessons + +- PR #48 revisits the combinator move in this very file — `.ok().flatten().unwrap_or(false)` deliberately swallows the `Elapsed` and error cases because a failed probe is a *recovery*, not a mistake. #48 is about the opposite call: when swallowing an error is the wrong default. +- PR #16 introduced the `Arc` that `wait_ready` borrows as `&Supervisor` — the shared, lock-guarded state the poll loop reads on every pass. +- PR #52 later hardens the inner probe: the diff here relied on hyper's drop behavior to tear down the connection after a timed-out attempt, and #52 replaces that with an explicit task `abort()` — the cancellation counterpart to this lesson's timeout. + +## Dig deeper + +- [`tokio::time::timeout`](https://docs.rs/tokio/latest/tokio/time/fn.timeout.html) — the future-vs-timer race and its `Result` return. +- [`thiserror`](https://docs.rs/thiserror/latest/thiserror/) — reach for it the day `ReadinessError` needs a `Display` impl to cross an API boundary; here a plain `Debug` enum matched at the call site was enough. diff --git a/docs/lessons/34-fix-landing-page-redirect.md b/docs/lessons/34-fix-landing-page-redirect.md new file mode 100644 index 0000000..610fc6e --- /dev/null +++ b/docs/lessons/34-fix-landing-page-redirect.md @@ -0,0 +1,99 @@ + + +# PR #34 — Fix landing-page redirect (adj.ac/ent) + +> **Rust lesson:** None — this PR ships no Rust. It fixes a broken client-side redirect on the static apex page (`/cent` → `/ent/`) and teaches how a zero-config `` bounce works, plus why the `/` in `adj.ac/ent` is a real URL path boundary rather than decoration. +> **Tags:** `redirects` · `static-site` · `html` +> **Merged:** 2026-06-08 · +96/−5 · [View PR](https://github.com/nonrational/adjacent/pull/34) + +## The situation + +The apex domain `adj.ac` serves a bare `index.html` whose only job is to bounce +visitors to the real landing page. It redirected to `/cent` — a path that exists +nowhere in the repo. One stray `c`, and the bounce dead-ended on a 404. This PR +retargets the redirect to `/ent/` and fills in the real page underneath it. + +## The idea (no Rust this time) + +A static site has no server code at the apex to emit an HTTP `301`/`302` +redirect — issuing a real 3xx needs host-specific config (`.htaccess`, a +Cloudflare `_redirects` file, etc.). The option that works on *any* dumb static +host with zero config is a **meta refresh**: a tag in the HTML `` that the +browser parses and acts on. + +```html + + + +``` + +`content="0; URL='/ent/'"` reads as *wait 0 seconds, then navigate to `/ent/`*. +The `0` makes it immediate. Two details carry weight: + +- **Root-relative, trailing slash.** The leading `/` resolves from the domain + root regardless of where the visitor entered. The trailing slash points at the + *directory* `/ent/`, whose index (`ent/index.html`) the host serves. The old + `/cent` was just a typo for `/ent` — but a redirect target is a plain string, + so nothing flagged that the path didn't exist. +- **`rel="canonical"`.** Meta-refresh shells look like duplicate content or a + sneaky redirect to a crawler. The canonical link tells search engines "the + real address is `/ent/` — index *that*, not this empty bouncer." + +## The pun is load-bearing on the path + +`adj.ac/ent` read aloud is "adjacent." That only works because it is a *real* +URL: `adj.ac` is the registered domain, `/` is the literal path separator, and +`ent` is a genuine path beneath it. So the landing content has to live at +`/ent/` for the joke to resolve — the brand depends on the file layout. The +wordmark markup encodes the same three-part split: + +```html + +

adj.ac/ent

+``` + +Domain in paper-white, slash in rule-gray, `ent` in accent — the styling makes +the `/` read as a boundary, not a hyphen. (CLAUDE.md locks this: the split is +`adj.ac` + `ent`, *never* `adj` + `ac.ent`, and "the `/` is the URL path +boundary.") + +## The one tooling detail + +`.tool-versions` gains a line: + +``` +# .tool-versions +rust 1.92.0 +nodejs 26.2.0 +``` + +The site is tooled through Node (`npx live-server`, per the `justfile`), so asdf +pins a Node version alongside the Rust toolchain. Anyone building the landing +page now gets the same runtime — the pin lives beside the compiler pin even +though Cargo never touches the web frontend. + +## Why it matters + +A meta refresh is the cheapest redirect that runs on any static host — no server, +no config file. The cost is an extra round trip (fetch the shell, then fetch the +target) and a weaker signal to search engines than a real `301`, which the +canonical link partially repairs. + +The failure this PR fixes is the one meta refresh makes easy: the target is a +*string*, not a checked reference. `/cent` ships fine and silently dead-ends — +nothing validates that the path exists until a human loads the page. Contrast a +router written in code, where a route pointing at a missing handler often won't +compile or will trip a test. A redirect string has no such safety net, so the +typo survives all the way to production. + +## Related lessons + +- **PR #46** also ships no Rust and covers this same `ent/` landing page — but + from the repo-layout angle: why the site sits *beside* the Cargo workspace so + `cargo build` never sees it. This lesson is the redirect that *points at* that + directory; #46 is where the directory lives. + +## Dig deeper + +- [MDN — ``, the `refresh` value](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#http-equiv) — syntax, and why the spec discourages non-zero timers for accessibility. +- [Google Search Central — Consolidate duplicate URLs (`rel=canonical`)](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls) — what the canonical link buys you on a bouncer page. diff --git a/docs/lessons/36-docs-as-config-contract.md b/docs/lessons/36-docs-as-config-contract.md new file mode 100644 index 0000000..e76e214 --- /dev/null +++ b/docs/lessons/36-docs-as-config-contract.md @@ -0,0 +1,85 @@ + + +# PR #36 — Document `health_check_url`, `idle_timeout`, and `adj wait-ready` in README + +> **Rust lesson:** None — this PR is pure README prose (+8/−1), no Rust code. The lesson is that user-facing docs *are* the config contract, and the Rust-ecosystem angle worth knowing is **doctests**: `cargo test` compiles and runs the ```rust``` examples in your doc comments, so documented examples can't silently rot. +> **Tags:** `docs-as-contract` · `doctests` +> **Merged:** 2026-06-08 · +8/−1 · [View PR](https://github.com/nonrational/adjacent/pull/36) + +## The situation + +Three features already shipped — readiness probing, idle shutdown, and `adj +wait-ready` — but the README didn't mention them. If a config key works in the +code and isn't in the docs, nobody outside the codebase knows it exists. This PR +closes that gap: three bullets plus two new lines in the example `adjacent.toml`. + +## The idea (no Rust this time) + +For a config-driven tool, the README *is* the contract. A user reads the example +`adjacent.toml`, copies it, and trusts that `idle_timeout = "30m"` does what the +comment says. The docs aren't decoration on top of the behavior — for anyone who +doesn't read the source, they *are* the behavior. + +Which raises the perennial problem: prose drifts. Rename a key, change a default, +and the README quietly lies until someone notices. Plain Markdown like this +README has no defense against that — a human has to catch it. + +## In this PR + +The diff adds the optional fields to the example config, with defaults called out +in comments: + +```toml +# README.md +name = "site" +cmd = "npm run dev" # must bind to $PORT + +# Optional: +health_check_url = "/healthz" # poll for 2xx instead of TCP-open +idle_timeout = "30m" # stop after no requests (default "15m", or "off") +``` + +And a bullet stating the contract in prose: + +```markdown +- Idle shutdown. Apps stop after no proxied requests for `idle_timeout` + (default `"15m"`, accepts `"30s"` / `"1h"` / `"off"`). +``` + +Nothing here is compiled or tested. If someone later changes the default from +`"15m"` to `"20m"` in the Rust code, this Markdown keeps claiming `"15m"` and no +tool complains. + +## Why it matters — the Rust angle + +Rust's answer to doc drift is the **doctest**. When you put a fenced ```rust``` +block in a `///` doc comment, `cargo test` extracts it, compiles it, and runs it +as a real test: + +```rust +/// Parses an idle-timeout string into a `Duration`. +/// +/// ``` +/// # use adj::parse_timeout; +/// assert_eq!(parse_timeout("30s").unwrap().as_secs(), 30); +/// ``` +pub fn parse_timeout(s: &str) -> Result { /* ... */ } +``` + +If the function signature changes or the behavior drifts, that example stops +compiling or fails its assertion, and CI goes red. The documented example +*cannot* silently rot, because it's also a test. + +This README is plain Markdown, so it gets none of that — the diff doesn't +demonstrate doctests, and honesty demands saying so. But the contrast is the +lesson: a config table in prose is only as correct as the last human who read it; +a doctest is correct as long as CI is green. + +## Related lessons + +- PR #16 documents the supervised-app-with-logs flow — same "docs describe a + contract the code enforces" shape, from the other direction. + +## Dig deeper + +- [rustdoc — Documentation tests](https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html) — how `cargo test` runs the examples in your docs, and the `#`-hidden-line trick shown above. diff --git a/docs/lessons/37-signals-bypass-drop-cleanup.md b/docs/lessons/37-signals-bypass-drop-cleanup.md new file mode 100644 index 0000000..de68eb9 --- /dev/null +++ b/docs/lessons/37-signals-bypass-drop-cleanup.md @@ -0,0 +1,74 @@ + + +# PR #37 — Handle SIGTERM in daemon socket cleanup + +> **Rust lesson:** `Drop` runs on normal scope exit and panic unwinding, but a process killed by a signal skips destructors entirely — so external cleanup like unlinking a Unix socket has to be done explicitly in a `tokio::signal` handler. +> **Tags:** `tokio-signal` · `drop` · `graceful-shutdown` +> **Merged:** 2026-06-08 · +15/−4 · [View PR](https://github.com/nonrational/adjacent/pull/37) + +## The situation + +The daemon binds a Unix socket at `~/.adjacent/sock`. That's a real file on disk, and a stale one blocks the next boot — `UnixListener::bind` fails if the path already exists. The daemon already cleaned it up on Ctrl-C. But `brew services stop` and launchd don't send Ctrl-C (SIGINT); they send **SIGTERM**. So a service-managed shutdown left the socket behind, and the next start had to notice the corpse and remove it. This PR handles SIGTERM too, so the socket goes away cleanly on the shutdown path that actually ships. + +## The Rust idea + +Rust's usual answer to "clean up a resource" is RAII: give the resource a type, put the teardown in a `Drop` impl, and the compiler runs it for you when the value leaves scope. A `File` closes itself; a `MutexGuard` unlocks itself. You don't write cleanup at every early return, because `Drop` is wired to *scope exit*. + +Here's the catch that trips people coming from garbage-collected or exception-based languages: **`Drop` only runs when Rust is in control of the exit.** There are exactly two paths that run destructors — a value going out of scope normally, and stack unwinding during a `panic!`. A signal is neither. When the kernel delivers SIGTERM (or SIGINT) and the process takes the default action, it just *stops*. No stack unwinds. No `Drop` fires. Whatever was living on the stack — including a `UnixListener` — is abandoned, and the OS reclaims memory and file descriptors, but it does **not** run your teardown logic or unlink a socket path you created. + +So RAII cannot save you here. Even if you wrote a `Drop` impl that removed the socket file, SIGTERM would walk right past it. The only way to react to a signal is to *ask* the runtime to tell you when one arrives, then do the cleanup yourself before you exit. + +That's what `tokio::signal` is for. Two shapes matter: + +- `tokio::signal::ctrl_c()` returns a **future** that resolves once, when SIGINT arrives. +- `tokio::signal::unix::signal(SignalKind::terminate())` returns a `Result` — a **stream** you poll with `.recv()`, because a signal can fire many times over a process's life. + +You want to react to *whichever* comes first, so you race them with `tokio::select!` — the async "wait on several things, wake on the first to complete" primitive. + +## In this PR + +```rust +// crates/adj/src/daemon.rs + +// Best-effort cleanup of the socket on shutdown signals so subsequent boots aren't blocked. +// SIGTERM matters specifically for `brew services stop` / launchd-driven shutdown; SIGINT +// covers interactive Ctrl-C in the foreground. Either signal triggers the same cleanup. +let socket_for_signal = socket.clone(); +tokio::spawn(async move { + let mut sigterm = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(s) => s, + Err(err) => { + tracing::warn!("failed to install SIGTERM handler: {err}"); + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {} + _ = sigterm.recv() => {} + } + let _ = std::fs::remove_file(&socket_for_signal); + std::process::exit(0); +}); +``` + +Walking the lines that matter: + +- **Installing the handler can fail**, so `signal(...)` returns a `Result`. The `match` binds the `Signal` on success; on failure it warns and `return`s out of the task. That's deliberately best-effort — a daemon that can't install a SIGTERM handler should still serve requests, it just won't self-clean on that signal. (See PR #48 for the "warn, don't swallow" reflex.) +- **`tokio::select!`** races the two sources. Both arms have empty bodies (`{}`) — we don't care *which* signal woke us, only that one did. Whichever future resolves first, `select!` drops the other and control falls through to the shared cleanup below it. Without `select!` you'd `.await` one source and be deaf to the other. +- **The cleanup is explicit and ordered**: remove the socket file first, *then* exit. `let _ =` discards the `io::Result` from `remove_file` — if the file's already gone, there's nothing to fix. +- **`std::process::exit(0)`** is the punchline that proves the whole point. `process::exit` terminates immediately and **also does not run destructors** — it's the same "Rust isn't unwinding" situation the signal put us in. That's exactly why the `remove_file` call sits *above* it. If we were relying on `Drop` for the socket, `exit(0)` would skip it just like SIGTERM would. Cleanup has to happen by hand, before the process is gone. + +## Why it matters + +The trap is assuming RAII is a universal cleanup guarantee. In a GC language you'd reach for a shutdown hook and hit the same wall from the other side; in Rust it's tempting to think "I'll put it in `Drop`" and move on. But `Drop` is a contract about *scopes*, not about *process lifetime*. Signals, `abort()`, `std::process::exit`, and a hard `SIGKILL` (which you can't catch at all) all end the process without unwinding. Any resource that outlives the process's memory — a file on disk, a lock file, a socket path, a row in a database marked "in use" — needs an explicit teardown on the paths where `Drop` won't run. `tokio::signal` is how you get a chance to run that teardown for the signals you *can* catch. + +## Related lessons + +- **PR #52** is the exact converse of this one: it uses `Drop` deliberately, as an RAII guard that aborts a background task when a value goes out of scope. Read them together — #52 shows `Drop`'s reach (any normal scope exit, guaranteed), #37 shows its limit (a signal walks right past it). +- **PR #16** covers the `Arc>` sharing pattern; the same `socket.clone()` + `move` closure shape shows up here to hand the path into the spawned task. +- **PR #48** — the `match ... Err(err) => warn; return` arm is the "surface the error, don't swallow it" habit applied to handler installation. + +## Dig deeper + +- [`tokio::signal` module docs](https://docs.rs/tokio/latest/tokio/signal/index.html) — `ctrl_c`, the Unix `SignalKind` / `Signal::recv` stream API, and the caveats about async-signal-safety. +- [The Rust Book, ch. 15.3](https://doc.rust-lang.org/book/ch15-03-drop.html) — Running Code on Cleanup with the `Drop` Trait (what `Drop` guarantees — and, by omission, what it doesn't). diff --git a/docs/lessons/38-deterministic-async-tests.md b/docs/lessons/38-deterministic-async-tests.md new file mode 100644 index 0000000..d1bc68e --- /dev/null +++ b/docs/lessons/38-deterministic-async-tests.md @@ -0,0 +1,102 @@ + + +# PR #38 — Fix flaky proxy single-flight test and add CI workflow + +> **Rust lesson:** A deterministic async test drives real concurrency and asserts on *observable state* you can count, not on timing — and when such a test still flakes, the flake is a real race in the code, not test noise. +> **Tags:** `#[tokio::test]` · `test-determinism` +> **Merged:** 2026-06-08 · +45/−16 · [View PR](https://github.com/nonrational/adjacent/pull/38) + +## The situation + +A test fired three concurrent first-requests at a cold app and asserted two things: +every request got `200`, and the app booted exactly once. It passed locally, then +flaked in CI with a `502`. The obvious "fix" — sprinkle a `sleep`, bump a timeout — +would have buried the problem. The real fix was one deletion in the proxy. The PR also +added `.github/workflows/ci.yml` (build + test on every PR, toolchain pinned to 1.92.0, +cargo cache) so flakes surface on a clean machine instead of only on someone's laptop. + +## The Rust idea + +`#[tokio::test]` is the async cousin of `#[test]`. A plain `#[test]` can't `.await` +anything — there's no runtime to drive the futures. The macro wraps your `async fn` so +it spins up a Tokio runtime, runs the test to completion, and tears the runtime down. +That's all it does; the *discipline* is on you. + +To test concurrency deterministically, two rules: + +1. **Drive real concurrent work.** `tokio::task::spawn_blocking` hands each closure to a + real OS thread, so three of them race for genuine. Don't simulate the race — cause it. +2. **Assert on observable state, never on timing.** A `sleep(100ms)` is a *guess* that the + system finished within 100ms. On a loaded CI box it didn't, and the test flakes. A + count of lines in a file, or an HTTP status code, is a *fact*. Assert the fact. + +The test already did both. It spawns three real requests, then checks a spawn-counter file: + +```rust +// crates/adj/tests/proxy.rs (existing test, unchanged by this PR) +for h in handles { + let (status_line, body) = h.await.expect("join").expect("http_get"); + assert!(status_line.contains(" 200 "), "status: {status_line}"); +} +// ... +assert_eq!(spawns, 1, "expected single boot, saw {spawns} spawns"); +``` + +So why flake? Because a correctly-written deterministic test that flakes is telling you +the *code under test* has a race. Here it did. + +## In this PR + +The proxy had a "fast path" that returned a port the instant the supervisor reported +`Running` — skipping the readiness wait. But the supervisor flips to `Running` the moment +it spawns the child shell, *before* the child has bound its port. A second concurrent +request that hit the fast path in that window forwarded to a dead socket: `502`. + +```rust +// crates/adj/src/proxy.rs — removed (the flaky fast path) +if let AppState::Running { port, .. } = supervisor.state(name).await { + return Ok(port); +} +``` + +The fix routes *every* request through the per-name lock and then `wait_ready`, which +polls the app's real readiness (a TCP probe that actually connects) before returning: + +```rust +// crates/adj/src/proxy.rs — the fix +// We intentionally do NOT short-circuit on a Running state outside the lock. The supervisor +// flips state to Running the moment it spawns the child — before the child has bound its +// port. [...] would forward to a port that wasn't accepting yet and get back a spurious 502. +let name_lock = gate.lock_for(name).await; +let _guard = name_lock.lock().await; + +if !matches!(supervisor.state(name).await, AppState::Running { .. }) { + supervisor.up(entry.path.clone(), cfg.clone()).await.map_err(ProxyError::Other)?; +} +// ...falls through to wait_ready before forwarding +``` + +Notice the symmetry. The *test* waits on observable state (a spawn count), and the *fix* +makes the proxy wait on observable state (a live TCP connection) instead of trusting a +`Running` enum that leads reality by a few microseconds. Same principle on both sides: +don't trust a signal that fires before the thing it signals is true. + +## Why it matters + +In most languages the reflex for a flaky concurrency test is to add a delay until it goes +green. That doesn't remove the race — it just widens the window so the race loses *most* +of the time, and the `502` returns under production load when no test is watching. Rust +doesn't save you from this; the discipline does. Assert facts you can count, drive the +real race, and when a deterministic test flakes, hunt the bug in the code rather than +padding the test. The single-line deletion here is the whole fix — the test was right all +along. + +## Related lessons + +- **PR #24** built the per-name single-flight `BootGate` this test exercises — the `lock_for(name)` + re-check pattern is the code under test here. +- **PR #16** covers the `Arc` sharing that lets the supervisor state and boot gate be read from many concurrent request tasks at once. + +## Dig deeper + +- [Tokio docs — Testing](https://docs.rs/tokio/latest/tokio/attr.test.html) — what `#[tokio::test]` actually expands to, and its runtime flavors. +- [The Rust Book, ch. 11](https://doc.rust-lang.org/book/ch11-00-testing.html) — writing and running tests. diff --git a/docs/lessons/40-serve-https-local-ca.md b/docs/lessons/40-serve-https-local-ca.md new file mode 100644 index 0000000..c31cc6f --- /dev/null +++ b/docs/lessons/40-serve-https-local-ca.md @@ -0,0 +1,121 @@ + + +# PR #40 — Serve HTTPS on :8443 via opt-in local CA + +> **Rust lesson:** A generic function bounded by traits is compiled into a specialized copy per concrete type (monomorphization), so one serve loop drives both a plain `TcpStream` and a TLS-wrapped stream at zero runtime cost. +> **Tags:** `generics` · `trait-bounds` +> **Merged:** 2026-06-08 · +1242/−49 · [View PR](https://github.com/nonrational/adjacent/pull/40) + +## The situation + +The daemon already ran an HTTP reverse proxy. This PR adds an HTTPS listener on `:8443` +that terminates TLS with a locally-issued cert, then routes requests exactly like the HTTP +path. The two listeners differ only at accept time: one hands back a raw TCP stream, the +other a TLS stream wrapped around one. Everything after — read the request, look up the app, +proxy it — is identical. The problem: how do you write that shared "everything after" once +when the two streams are different concrete types? + +## The Rust idea + +A **generic function** with **trait bounds** is Rust's answer. You write the body once against +a type parameter `S`, and you constrain `S` to exactly the capabilities the body uses. The +compiler then does **monomorphization**: for every concrete type you actually call the function +with, it stamps out a separate, specialized copy with `S` replaced. `serve_plain::` +and `serve_plain::>` become two real functions in the binary. + +This is *static dispatch*. In Java or Go you'd take an interface parameter and pay a vtable +lookup on every method call through it. Rust's monomorphized copies call the concrete methods +directly — inlinable, no indirection, no per-call cost. You trade a little binary size (two +copies) for speed. (Rust also offers `dyn Trait` for real dynamic dispatch when you *want* one +function and are willing to pay the vtable; here, static is the right call.) + +The bounds are not decoration — each one is load-bearing: + +- `AsyncRead + AsyncWrite` — the serve loop reads request bytes off the stream and writes the + response back. Without both, `hyper`'s `serve_connection` won't accept the stream. +- `Unpin` — async I/O is poll-based and the runtime needs to move the stream around; `Unpin` + promises it's safe to move even after polling has begun. +- `Send` — the connection is driven inside `tokio::spawn`, which may run it on a different + worker thread, so the stream must be safe to send across threads. +- `'static` — the spawned task can outlive the stack frame that launched it, so `S` may not + borrow anything shorter-lived. + +Drop any one bound and the code that *needs* that capability fails to compile. The bound list +is the exact contract the body demands, checked at the call site, not at runtime. + +## In this PR + +The shared loop, written once against a type parameter: + +```rust +// crates/adj/src/proxy.rs +/// Run one HTTP/1.1 connection against the proxy's per-request handler. Parameterized over the +/// underlying stream so the HTTP and HTTPS listeners share the same serve loop — the difference +/// between them is purely accept-time framing. +async fn serve_plain(stream: S, sup: Arc, gate: Arc) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + let io = TokioIo::new(stream); + let service = service_fn(move |req: Request| { + // ... clone sup + gate, dispatch to handle(req, ...) + }); + if let Err(err) = server_http1::Builder::new() + .serve_connection(io, service) + .with_upgrades() + .await + { + tracing::debug!("proxy connection ended: {err}"); + } +} +``` + +The HTTP listener calls it with the raw accepted stream: + +```rust +// crates/adj/src/proxy.rs — inside run() +tokio::spawn(async move { + serve_plain(stream, sup, gate).await; // S = TcpStream +}); +``` + +The HTTPS listener does the TLS handshake first, then hands the *wrapped* stream to the same +function: + +```rust +// crates/adj/src/proxy.rs — inside run_https() +let tls_stream = match acceptor.accept(stream).await { + Ok(s) => s, + Err(err) => { + tracing::debug!("tls handshake failed: {err}"); + return; + } +}; +serve_plain(tls_stream, sup, gate).await; // S = TlsStream +``` + +Note there are no turbofish annotations at the call sites — the compiler infers `S` from the +argument's type. The `TlsStream` that `tokio_rustls` produces already implements `AsyncRead + +AsyncWrite + Unpin + Send`, so it satisfies the bounds and the identical body just works. + +## Why it matters + +The alternative is copy-paste: a second serve loop for the TLS stream, byte-for-byte the same +except the parameter type. Two copies drift. A bug fix or a `with_upgrades()` tweak lands in +one and gets forgotten in the other. Generics collapse them into a single source of truth that +the type system re-checks against every concrete stream you feed it. + +A dynamically-typed language would let you pass either stream into one function and only find +out at runtime — mid-request, on a live connection — that one of them can't do something the +body assumed. Rust's bounds move that check to compile time: if a stream type is missing +`Send`, the `tokio::spawn` call site refuses to build. You cannot ship the broken combination. + +## Related lessons + +- The `run_https` task is spawned best-effort (logs at `error!` and exits if the CA is missing, + leaving HTTP and the control plane serving) — a small lesson in fault-isolated `tokio` tasks. + +## Dig deeper + +- [The Rust Book, ch. 10.1](https://doc.rust-lang.org/book/ch10-01-syntax.html) — Generic Data Types +- [The Rust Book, ch. 10.2](https://doc.rust-lang.org/book/ch10-02-traits.html) — Traits: Defining Shared Behavior (see "Using Trait Bounds" and the note on monomorphization in ch. 10.1) diff --git a/docs/lessons/41-raw-string-templated-doc.md b/docs/lessons/41-raw-string-templated-doc.md new file mode 100644 index 0000000..1700ae9 --- /dev/null +++ b/docs/lessons/41-raw-string-templated-doc.md @@ -0,0 +1,117 @@ + + +# PR #41 — Add `adj agent-instructions` command + +> **Rust lesson:** A raw string literal (`r#"…"#`) bakes a whole multi-line document into the binary verbatim — no escaping, no external file — and `format!`'s in-scope capture (`{name}`) splices in the dynamic fields, so the template ships *inside* the binary and only the two live values are read at run time. +> **Tags:** `raw-string-literals` · `format-macro` · `string-templating` +> **Merged:** 2026-06-08 · +783/−0 · [View PR](https://github.com/nonrational/adjacent/pull/41) + +## The situation + +`adj agent-instructions` prints a markdown steering doc telling a coding agent to +drive the app through `adj` instead of running the dev command itself. The doc is +mostly fixed prose with two holes: the app's `name` and its `cmd`, both read from +`adjacent.toml`. The whole thing prints to stdout — no daemon, no template file on +disk. So where does the boilerplate live, and how do the two fields get spliced in? + +## The Rust idea + +**Raw string literals.** An ordinary `"..."` string processes escapes: `\n` is a +newline, `\t` a tab, and to put a literal `"` or `\` inside you have to escape it. +That gets painful fast for a multi-line markdown doc full of backticks and `#` +headers. A *raw* string turns escape processing off — `r"..."` treats every +character between the quotes literally, backslashes and newlines included. + +The `#` delimiters guard the ends: `r#"..."#` only terminates at the `"#` sequence, +so a bare `"` inside the content won't close it early. Need to embed the sequence +`"#` itself? Add more hashes — `r##"..."##`. This is the same tool you'd reach for +with a regex or a Windows path, here holding a page of markdown exactly as it +renders. + +Because the literal is written directly in the source, it compiles into the +binary's read-only data. The boilerplate is *self-contained* — there is no +`template.md` to ship alongside `adj` and no file read at run time for the fixed +text. + +**Templating with `format!`.** The two dynamic fields are `{name}` and `{cmd}`. +Since Rust 2021, `format!` captures identifiers that are already in scope: `{name}` +means "the local variable `name`," with no trailing `name = name` argument. That's +why `render`'s parameters are *named* `name` and `cmd` to match the placeholders, +and the `format!` call has no arguments after the string. + +## In this PR + +The template is a raw string literal; `format!` fills the two holes (trimmed): + +```rust +// crates/adj/src/agent_docs.rs +fn render(name: &str, cmd: &str) -> String { + format!( + r#"# Working with `{name}` via Adjacent + +This project's dev server is supervised by **Adjacent** (`adj`). The agent does not +start the server directly — `adj` lazy-boots it on the first proxied request, captures +stdout/stderr to `~/.adjacent/logs/{name}.log`, and stops it on idle. + +## Don't run the dev command yourself + +Don't run `{cmd}` directly. Adjacent owns the process. Running it directly +double-binds the port and Adjacent loses visibility into the log stream. +// ... (adj status / logs / restart / wait-ready, verify loop) ... +"# + ) +} +``` + +The only thing read from disk at run time is the manifest, and only to pull `name` +and `cmd` out of it — everything else is already in the binary: + +```rust +// crates/adj/src/agent_docs.rs +pub fn emit(path: Option) -> Result<()> { + let dir: PathBuf = match path { + Some(p) => PathBuf::from(p), + None => std::env::current_dir().context("resolving current directory")?, + }; + let cfg = registry::read_app_config(&dir)?; + print!("{}", render(&cfg.name, &cfg.cmd)); + Ok(()) +} +``` + +One subtlety: `format!` still treats `{` and `}` specially even inside a raw string, +so a *literal* brace in the doc would have to be written `{{` or `}}`. This template +has none, which is part of why the plain `format!` stays readable. + +## Why it matters + +Reach for a run-time read instead — `std::fs::read_to_string("template.md")` — and +the binary grows a hidden dependency: the file has to exist at the right path when +`adj` runs. Ship the binary on its own and the command breaks at run time with a +"file not found," not at build time. Baking the text into the source removes that +whole failure mode: if it compiled, the content is there. + +The sibling technique for the *same* self-contained benefit is `include_str!`, which +pulls an entire separate file's bytes in **at compile time** — best when the asset is +large or edited by someone who shouldn't touch `.rs` files. This repo does exactly +that for the dashboard HTML (`const HTML: &str = include_str!("../assets/status.html")` +in `status.rs`). For a two-field template a few dozen lines long, an inline raw string +keeps the text next to the one function that formats it — no second file to keep in +sync. Same "it's in the binary" guarantee, different call on where the source text +lives. + +## Related lessons + +- PR #48 covers the `Result` / `?` / `anyhow::Context` machinery visible in `emit` + here — the `.context(...)?` that turns a failed `current_dir()` into a labeled + error instead of a silent one. +- The `include_str!` route to a self-contained binary — embedding a *whole file* at + compile time — shows up in the dashboard work (PR #45); this PR deliberately inlines + a literal instead, so the two make a clean before/after on "when is the asset big + enough to earn its own file?" + +## Dig deeper + +- [Rust Reference — Raw string literals](https://doc.rust-lang.org/reference/tokens.html#raw-string-literals) — the `r#"…"#` grammar and the multi-hash rule. +- [`std::fmt`](https://doc.rust-lang.org/std/fmt/) — `format!` syntax, including captured identifiers (`{name}`) and escaping braces with `{{` / `}}`. +- [`std::include_str!`](https://doc.rust-lang.org/std/macro.include_str.html) — the sibling: embed an external file's contents into the binary at compile time. diff --git a/docs/lessons/43-trait-as-extension-point.md b/docs/lessons/43-trait-as-extension-point.md new file mode 100644 index 0000000..dd0970f --- /dev/null +++ b/docs/lessons/43-trait-as-extension-point.md @@ -0,0 +1,145 @@ + + +# PR #43 — Move CA private key into the macOS Keychain and add nameConstraints + +> **Rust lesson:** Implementing a trait a *library* defines lets you plug your own behavior into that library's machinery — here, `KeychainKey` implements rcgen's `RemoteKeyPair` so certificate signing calls out to the macOS Keychain and the private key never enters process memory. +> **Tags:** `trait-impl` · `ffi` +> **Merged:** 2026-06-09 · +682/−38 · [View PR](https://github.com/nonrational/adjacent/pull/43) + +## The situation + +The local CA's private key used to sit at `~/.adjacent/ca.key` — a PEM file any backup tool +or process running as the user could copy. This PR moves the key into the macOS login keychain, +marked non-extractable, so the bytes never leave the OS. But rcgen — the crate that builds and +signs the CA cert — normally expects to *hold* the private key and sign with it directly. How do +you let rcgen produce a signed certificate when it can't have the key? + +## The Rust idea + +rcgen anticipated exactly this and left a seam: a trait, `RemoteKeyPair`. A library that exposes +a trait is saying "I don't need to own this behavior; give me anything that can do these three +things and I'll call back into it." That's a trait used as an **extension point**. You don't +subclass rcgen or fork it — you write a struct of your own and *implement its trait* for that +struct. rcgen then drives your code without ever knowing what's behind it: an HSM, a cloud KMS, +or in our case the macOS Keychain. + +`RemoteKeyPair` requires three methods — hand me the public key, tell me the signature +algorithm, and sign these bytes. Fill all three and rcgen treats your type as a signer. The +`sign` method is where the magic happens: rcgen calls it, and *your* implementation decides how +the signature gets made. Ours forwards the request to the OS. + +This is the mirror image of [PR #40](40-serve-https-local-ca.md). There, a +trait was a **bound** on a generic — "I accept any `S` that can read and write" — resolved at +compile time into a specialized copy per type (static dispatch). Here we go the other way: we +*implement* a trait and hand rcgen a `Box`, a **trait object**. rcgen holds +one boxed value and dispatches through a vtable at runtime, because it's compiled once and must +work with signers it's never heard of. Same feature — traits — two opposite uses: constraining a +caller vs. supplying a plug-in. + +## In this PR + +The trait implementation. rcgen defines the three method signatures; we fill each with +keychain-backed behavior: + +```rust +// crates/adj/src/tls/keychain.rs +impl RemoteKeyPair for KeychainKey { + fn public_key(&self) -> &[u8] { + &self.public_bytes + } + + fn algorithm(&self) -> &'static SignatureAlgorithm { + &PKCS_ECDSA_P256_SHA256 + } + + fn sign(&self, msg: &[u8]) -> std::result::Result, RcgenError> { + self.private + .create_signature(Algorithm::ECDSASignatureMessageX962SHA256, msg) + .map_err(|cf_err| { + // rcgen's RemoteKeyError has no payload — log the CFError so it's not lost. + tracing::error!(error = %cf_err, "Keychain signing failed"); + RcgenError::RemoteKeyError + }) + } +} +``` + +Note `sign` returns rcgen's `Result, RcgenError>`, not our own error type — the trait +dictates the shape, so we `map_err` the Keychain's error into the variant rcgen expects. + +Handing the implementation to rcgen is one call. `Box::new(self)` puts our value on the heap as a +trait object so rcgen can store it behind `dyn RemoteKeyPair`: + +```rust +// crates/adj/src/tls/keychain.rs +pub fn into_rcgen_keypair(self) -> Result { + KeyPair::from_remote(Box::new(self)) + .map_err(|e| anyhow!("constructing rcgen KeyPair from Keychain handle: {e}")) +} +``` + +From rcgen's side the key is now indistinguishable from any other. The cert-building code is +untouched — `self_signed` reaches through the trait and calls our `sign` when it needs a +signature: + +```rust +// crates/adj/src/tls.rs — generate_ca() +let ca_handle = keychain::generate().context("creating Keychain CA key")?; +let ca_key = ca_handle.into_rcgen_keypair()?; +let params = build_ca_params(); +let signed = params + .self_signed(&ca_key) // rcgen calls KeychainKey::sign() in here + .context("self-signing CA cert")?; +``` + +### Supporting detail: the `unsafe` FFI seam + +Our `sign` bottoms out in Apple's C Security framework, and generating the key does too. That +crossing is where `unsafe` shows up. A C constant the Rust bindings didn't expose gets linked +directly: + +```rust +// crates/adj/src/tls/keychain.rs +extern "C" { + static kSecAttrIsExtractable: core_foundation_sys::string::CFStringRef; +} +``` + +And the raw call into the framework is wrapped in `unsafe`: + +```rust +// crates/adj/src/tls/keychain.rs — create_random_key() +let key_ref: SecKeyRef = + unsafe { SecKeyCreateRandomKey(params.as_concrete_TypeRef(), &mut err) }; +``` + +`unsafe` here doesn't mean "dangerous" — it means *the compiler can't verify this*. Rust's +guarantees (valid pointers, correct ownership, no data races) stop at the C boundary. The C +framework might hand back a null pointer, or expect a specific retain/release discipline the +borrow checker knows nothing about. `unsafe` is you signing off: "I've read the framework's +contract and I'm upholding it." It marks the exact lines where that promise lives, so a reader +knows precisely where Rust's safety net ends and manual care begins. + +## Why it matters + +The extension-point pattern is how you get library code to do something its authors never +imagined — sign with a key they can't see — without touching their source. rcgen stays a +dependency you pull from crates.io; the keychain integration lives entirely in your crate. The +trait is the contract between them, and the compiler enforces that you honor it: leave out +`sign`, or return the wrong error type, and it won't build. + +A language without this seam would force a worse trade. You'd either fork rcgen to teach it about +keychains, or pull the private key into memory so rcgen could sign the ordinary way — which is +the exact thing this PR set out to prevent. The trait lets the key stay in the OS while rcgen +does its job none the wiser. + +## Related lessons + +- [PR #40](40-serve-https-local-ca.md) — the *other* face of traits: as a + **bound** on a generic (static dispatch, monomorphized per type). Read side by side with this + one to see constraining-a-caller vs. supplying-a-plug-in. + +## Dig deeper + +- [The Rust Book, ch. 10.2](https://doc.rust-lang.org/book/ch10-02-traits.html) — Traits: Defining Shared Behavior (implementing a trait on your own type; trait objects and `Box` are in ch. 18.2) +- [The Rustonomicon, ch. on FFI](https://doc.rust-lang.org/nomicon/ffi.html) — calling C, `extern` blocks, and why the boundary is `unsafe` diff --git a/docs/lessons/45-iterator-map-collect-rows.md b/docs/lessons/45-iterator-map-collect-rows.md new file mode 100644 index 0000000..40625c3 --- /dev/null +++ b/docs/lessons/45-iterator-map-collect-rows.md @@ -0,0 +1,108 @@ + + +# PR #45 — Add built-in dashboard at status.adj.ac + +> **Rust lesson:** `iter().map(f).collect()` is how you turn one collection into another — a lazy pipeline that does nothing until `collect` drives it. +> **Tags:** `iterators` · `map-collect` +> **Merged:** 2026-06-09 · +817/−0 · [View PR](https://github.com/nonrational/adjacent/pull/45) + +## The situation + +The dashboard at `status.adj.ac` polls `GET /apps.json` and gets back one JSON +object per registered app. The daemon holds each app as an `AppSummary` (name, +path, live state). The handler's job: take that list and produce a list of +wire-shaped `DashboardEntry` objects to serialize. Classic "transform a +collection into rendered rows." + +## The Rust idea + +In many languages you'd write a `for` loop, build an empty array, and `push` +each transformed item. Rust can do that too — but the idiomatic move is an +**iterator chain**: `.iter()` to walk the collection, `.map(f)` to transform +each element, `.collect()` to gather the results into a new collection. + +Two things make this worth learning: + +**Iterators are lazy.** `.map(f)` doesn't run `f` on anything. It returns a +`Map` value that *remembers* what to do. Nothing happens until something pulls +items through — here, `.collect()`. That's why you can chain ten adapters and +pay for zero of them until the final consumer runs. (In a test below, `.find()` +is a consumer that stops early — it walks only as far as the first match.) + +**`.collect()` builds the target type.** The type you're collecting *into* +drives it. Annotate the binding as `Vec` and `collect` fills a +`Vec`. The compiler infers the rest. + +## In this PR + +The transform is one line — a pure, synchronous mapping: + +```rust +// crates/adj/src/status.rs +let dtos: Vec = entries.iter().map(dashboard_entry).collect(); +// ... then serde_json::to_vec(&dtos) turns the Vec into the JSON body +``` + +`entries.iter()` yields `&AppSummary`. `dashboard_entry` takes exactly that and +returns a `DashboardEntry`, so it drops straight in as the map function — no +closure needed. `.collect()` gathers them into the `Vec` the annotation asks +for. Read it left to right: "for each summary, make a dashboard entry, gather +them up." + +Contrast that with the function that *builds* `entries` a few lines down: + +```rust +// crates/adj/src/status.rs +async fn snapshot(supervisor: Arc) -> anyhow::Result> { + let reg = Registry::load()?; + let mut entries = Vec::with_capacity(reg.apps.len()); + for (name, entry) in ®.apps { + let state = supervisor.state(name).await; + entries.push(AppSummary { + name: name.clone(), + path: entry.path.display().to_string(), + state, + }); + } + Ok(entries) +} +``` + +Why a plain `for` loop here and a `.map().collect()` there? Because this loop +`.await`s `supervisor.state(name)` on every pass. A plain `.map()` closure can't +`await` — its output would be a future, not a value — so an async transform +falls back to the loop. The rule of thumb: **synchronous, side-effect-free +transform → iterator chain; anything that awaits or fails per-item → loop.** +The two idioms sit side by side in the same file, each used where it fits. + +One efficiency note in the loop: `Vec::with_capacity(reg.apps.len())` +pre-allocates for the known count, so the `push`es never trigger a reallocation ++ copy as the vector grows. + +## Why it matters + +`.iter().map(f).collect()` reads as a single intention — "these become those" — +where a hand-rolled loop makes you track a mutable accumulator and read three +lines to confirm it only ever appends. The laziness means chains stay cheap: no +intermediate collection is materialized between adapters. And because +`dashboard_entry` returns a `DashboardEntry<'_>` that *borrows* (`&'a str`) from +each `AppSummary` rather than cloning strings, the whole render is references +until `serde_json::to_vec` walks them — the `entries` vector just has to outlive +the serialize call. + +The trap the chain sidesteps: reaching for it when the per-item work can fail or +await. A `.map()` that returns `Result` leaves you holding a +`Vec>` and no clean error path; that's exactly why `snapshot` stays +a loop with `?`. Match the tool to the shape of the work. + +## Related lessons + +- PR #41 owns `include_str!` — the static HTML shell (`const HTML`) is embedded + that way; this lesson is only about the *dynamic* JSON the iterator chain builds. +- PR #16 — the `Arc` threaded through `snapshot` and `handle` is the + `Arc` sharing pattern. + +## Dig deeper + +- [The Rust Book, ch. 13.2](https://doc.rust-lang.org/book/ch13-02-iterators.html) — Processing a Series of Items with Iterators (laziness, `map`, `collect`). +- [`std::fmt::Write`](https://doc.rust-lang.org/std/fmt/trait.Write.html) — the string-building cousin: when you *do* assemble text by hand, `write!`/`writeln!` append into one `String` instead of allocating a fresh string per `format!` + `+`. diff --git a/docs/lessons/46-revamp-landing-positioning-page.md b/docs/lessons/46-revamp-landing-positioning-page.md new file mode 100644 index 0000000..98c8623 --- /dev/null +++ b/docs/lessons/46-revamp-landing-positioning-page.md @@ -0,0 +1,131 @@ + + +# PR #46 — Revamp landing page into a positioning page + +> **Rust lesson:** None — this PR has no Rust code. It's the `ent/` landing page (HTML/CSS). The lesson is how a single-binary Rust project carries a web frontend *beside* the Cargo workspace so the two never touch, and the one Rust-ecosystem convention the diff does hit: dual `MIT OR Apache-2.0` licensing. +> **Tags:** `repo-layout` · `web-frontend` · `dual-license` +> **Merged:** 2026-06-09 · +341/−24 · [View PR](https://github.com/nonrational/adjacent/pull/46) + +## The situation + +Adjacent needed a real landing page — not just a wordmark, but a *positioning +page*: hero, problem statement, the four-command flow, design principles, status. +A "positioning page" answers "what is this and why do I care" for a first-time +visitor, versus a "coming soon" placeholder. The whole change lives in `ent/`, +the project's static web frontend. No crate was rebuilt. + +## The idea (no Rust this time) + +Adjacent is one Rust binary, `adj`. But the project also ships a website. The +question every polyglot repo faces: *where do the web assets live so they don't +leak into the build of the thing you actually compile?* + +Cargo's answer is the **workspace membership list**. Cargo only sees, compiles, +and lints what `members` names. Anything else in the tree is invisible to it — +a directory of HTML is just files on disk as far as `cargo build` is concerned. +So the convention is: put the frontend in a **sibling directory** to `crates/`, +and leave it out of `members`. It gets served or deployed by something else +(here, eventually Cloudflare Pages), never bundled into the binary. + +## In this PR + +The workspace lists two crates and nothing web-facing: + +```toml +# Cargo.toml +[workspace] +members = [ + "crates/adj", + "crates/adj-protocol", +] +``` + +The site lives one level up from those crates, entirely outside that list: + +``` +adjacent/ +├── crates/ # the Cargo workspace — what `adj` compiles +│ ├── adj/ +│ └── adj-protocol/ +├── ent/ # the web frontend — Cargo never sees this +│ ├── index.html +│ └── favicon.svg +└── index.html # root redirect → /ent/ +``` + +`ent/index.html` is where the 341 lines land — plain HTML with an inline +`