From ee61830a4e4c8e1f2109169b0b537eb8c33eb5f9 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Sat, 11 Jul 2026 14:44:34 -0700 Subject: [PATCH 01/19] Add design spec for the interactive lessons surface Generated per-lesson HTML under ent/lessons/ with clickable glossary terms and one-level prerequisite chains, built by a dependency-free Node generator from docs/lessons/*.md as the single source of truth. Engine/theme seam keeps the eventual reusable skill a lift, not a rewrite. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ej8GZ5gZEaB2nsXG7aMxqn --- .../2026-07-11-interactive-lessons-design.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-interactive-lessons-design.md diff --git a/docs/superpowers/specs/2026-07-11-interactive-lessons-design.md b/docs/superpowers/specs/2026-07-11-interactive-lessons-design.md new file mode 100644 index 0000000..406a08d --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-interactive-lessons-design.md @@ -0,0 +1,248 @@ +# Interactive surface for the Rust lessons series + +**Date:** 2026-07-11\ +**Status:** Approved design, pre-implementation + +## Problem + +`docs/lessons/` holds a 31-file teaching series: one Rust lesson per merged PR, each +grounded in the real diff. The files are excellent as prose, but a newcomer hits +foundational terms they don't yet own. A lesson says "hand the task an `Arc>` +clone" and assumes the reader can already climb ownership → move → `Send`/`Sync` to +appreciate why. Today that climb is on the reader. + +We want an interactive surface where a reader can click a foundational term and get a +plain-language definition, why it matters, and — the distinctive ask — **what they'd need +to learn first**. The prerequisite chain is the novel part, not an afterthought. + +## Goal + +Generate an interactive HTML page per lesson, served from `ent/lessons/`, where: + +- Foundational terms in the prose are clickable. +- Clicking a term opens a side panel with a definition, a "why it matters" line, and a + row of clickable **prerequisite** terms ("learn these first"). +- The lesson markdown in `docs/lessons/` stays the single source of truth. The interactive + layer is generated *from* it, never a fork of it. + +The ultimate goal (see "Where it's headed") is a reusable skill that does this for any +repo, annotating its PRs with lesson plans and building this same interactive site. This +v1 builds the concrete Adjacent instance, with the code seamed so that extraction is a +lift, not a rewrite. + +## Design + +### Data model — the glossary is the "learn more" layer + +One checked-in file, `docs/lessons/glossary.json`, sits next to the lessons it explains. +Each entry: + +```json +{ + "arc": { + "term": "Arc", + "aliases": ["Arc", "Arc>", "Arc>"], + "short": "Atomically reference-counted shared pointer. Cloning it bumps a refcount and hands back a second owner of the same value, not a copy.", + "why": "Lets two threads or tasks own one value at once. It is how a detached task can touch state the spawner still holds.", + "prereqs": ["ownership", "move", "send-sync"], + "link": { "label": "Rust Book 16.3", "url": "https://doc.rust-lang.org/book/ch16-03-shared-state.html" } + } +} +``` + +- **`aliases`** are the exact strings that appear in the prose (mostly inside backticks) + that resolve to this entry. +- **`prereqs`** are other glossary keys. This array *is* the dependency graph; there is no + separate graph file. It is a directed graph encoded as adjacency lists. +- The side panel shows a term's **direct** prereqs (one level) as clickable chips. Clicking + one swaps the panel to that term. One level plus click-to-descend gives the "climb the + ladder" experience with zero cycle risk and no rendered node diagram. + +The glossary is an authored data file, drafted by the agent and reviewed by a human before +publish, because teaching accuracy is the whole point. Framing note for reusability: the +glossary is an **output** the tooling produces for a repo, not an asset baked into the +tooling. A Python project would get a Python glossary. + +### How a prose term becomes clickable + +The lessons already wrap Rust terms in backticks (inline code). The generator walks each +lesson's inline-code spans; any span whose text matches a glossary alias becomes an +annotated, clickable term. The **Tags** line in each lesson header (already a controlled +vocabulary) also becomes clickable into the glossary. + +Two consequences, stated plainly: + +- The lesson markdown is never modified. It stays the single source of truth. +- Only backticked code spans and tag chips are annotation candidates, so an English prose + word never false-matches. The word "move" in a sentence stays plain; only `move` in + backticks lights up. The cost: a multi-word prose phrase that is not backticked (for + example "automatic reference counting" written out) does not annotate in v1. This is the + honest limitation, recorded in Out of scope and dialed up later. + +Every matching code span is annotated (not just the first per page); code spans are already +visually distinct, so a subtle dotted underline on the glossary matches is a light touch. +If it reads as noisy in review, the dial is "first occurrence per page." + +### Generation pipeline — `ent/lessons/build.mjs` + +A dependency-free Node ESM script. Because every lesson has the identical template +structure, it parses with targeted logic instead of pulling in a CommonMark library, so +`ent/` stays dependency-free. + +Parser scope (the bounded set the lessons actually use, confirmed by survey): + +- ATX headings `#` / `##` / `###` +- The three-line blockquote header (lesson / tags / merged) +- Paragraphs, bold `**...**`, italic `*...*`, inline code `` `...` ``, links `[t](u)` +- Fenced code blocks ` ```lang ` for any language (bash, html, make, markdown, rust, sh, + toml, yaml). **Fenced content is literal**: a ` ```markdown ` block is rendered as code, + never re-parsed, and inline-code annotation does not descend into fences. +- Unordered lists with one level of nesting; ordered lists (`1.`) + +Explicitly **not** in the lesson bodies (so not in scope): tables, images, raw HTML in +prose. (The one table and the reading-path lists live in `README.md`; see the index below.) + +Steps: + +1. Read and validate `glossary.json`: every `prereqs` key resolves to a real entry, aliases + are unique across entries, and emit a **coverage report** of backticked terms found + across all lessons that are not in the glossary (so gaps are visible). +2. For each `docs/lessons/-.md`: parse the leading comment, `# PR #NN — title`, + the three-line blockquote, and the `##` sections; render the bounded markdown to HTML; + annotate glossary matches in inline-code spans and the tags line; rewrite cross-lesson + links (`40-...md` → `40-...html`). +3. Emit `ent/lessons/-.html` per lesson, plus `ent/lessons/index.html`. +4. Every generated file carries a `` + header comment. + +Shared assets, emitted once into `ent/lessons/` and referenced by every page: +`lessons.css`, `drawer.js`, and `glossary.js` (which assigns `window.__GLOSSARY__ = {...}` +so the drawer needs no `fetch` and works over `file://`). A multi-page set shares assets +rather than inlining 31 copies; this is a deliberate departure from the single landing +page's fully-inlined approach, chosen for maintainability. (If fully self-contained pages +are preferred, the generator can inline instead.) + +The index page is built by parsing `README.md`'s table rows and reading-path lines with +targeted regex (same known-structure philosophy as the lesson parser, no general table +renderer). This keeps `README.md` the source of truth for the curated takeaways, the ◦ +honesty markers, and the themed reading paths. + +### The engine / theme seam (for reusability) + +`build.mjs` is structured as a neutral **engine** plus a thin **theme + config** layer, so +the eventual skill reuses the engine and swaps the theme: + +- **Engine (portable, no Adjacent identifiers):** parse a lesson of the known template + shape; render the bounded markdown; match and annotate glossary aliases; build the drawer + data; emit a page and the shared assets; parse an index from a README of the known shape. +- **Theme + config (repo-specific):** a single `THEME` object at the top of the file — + palette, font stack, wordmark parts, site title, repo URL, and the input/output paths. + v1 fills it with Adjacent's values (`--ink`/`--paper`/`--accent #d4a574`, JetBrains Mono, + the `adj.ac/ent` wordmark, `docs/lessons` → `ent/lessons`). + +This is one file with a clear internal boundary, not a premature multi-package split +(YAGNI). The boundary is the documented extraction line. + +### Page anatomy and interaction + +Reuse `ent/index.html`'s design language exactly: `--ink` background, `--paper` text, +`--accent` `#d4a574`, JetBrains Mono, the dotted-underline link treatment. Dark-only, to +match the landing page. + +- **Reading column** centered, roughly 72ch, article-style. +- **Term affordance:** glossary-matched code spans get a dotted underline in the accent + color and a pointer cursor. Plain code stays plain. +- **Side panel** slides in from the right (about 360px) on term click, showing: the term in + monospace accent, the short definition, "Why it matters", a "Learn first" row of clickable + prereq chips, and the external link. Descending into a prereq swaps the content with a + back affordance. Closes on Esc or click-away. +- **Progressive enhancement:** like the landing page's copy buttons, the drawer is built in + JS. With JS off the lesson is fully readable, code stays code, links still work. +- **Index page** carries the chronological table, the ◦ markers, and the themed reading + paths, each linking into the generated pages. + +### The two surfaces (postable markdown vs hosted drawer) + +The source markdown was written to double as a `gh pr comment` body. A GitHub comment +strips JS and scoped CSS, so the interactive drawer cannot live inside a PR comment. The +tooling therefore produces two distinct surfaces from one source: the plain-markdown lesson +(postable to the PR) and the hosted interactive site (where the drawer lives). The design +keeps these separate and never assumes the annotations survive inside a comment. + +### justfile + +Add a `build-lessons` recipe and make `serve` depend on it, so the site is always fresh in +dev: + +```make +build-lessons: + node ent/lessons/build.mjs + +serve: build-lessons + npx live-server --port=8081 +``` + +Generated pages are committed (consistent with the repo committing other generated +artifacts, and so `ent/` stays directly serveable with no build step for a future `adj.ac` +deploy). The `generated by build.mjs` header makes provenance clear. This is the one small +choice worth a human nod at review time. + +## Testing + +Every normal build runs the validations below and fails fast (non-zero exit) on an +integrity error, so a broken glossary or dangling link can never emit a page. `--check` +runs the same validations without emitting files, for use as a pre-commit or CI gate. + +**Generator self-check (`node ent/lessons/build.mjs --check`):** + +- Glossary integrity: every `prereqs` key resolves; aliases are unique across entries. + Exits non-zero on failure so it can gate. +- Link integrity: every rewritten cross-lesson link resolves to an emitted file. +- Coverage report: backticked terms across all lessons that are absent from the glossary, + printed (not fatal) so the gap is visible. + +**Manual verification (per the `verify` skill at implementation time):** + +- `just serve` (after checking `lsof -nP -iTCP:8080 -sTCP:LISTEN` and `:8081`), open a + lesson, click `Arc>`, confirm the drawer content, click a prereq chip to descend, + Esc to close. +- Load a page over `file://` to confirm `glossary.js` works without a server. +- View a page with JS disabled to confirm the lesson is fully readable. + +## Where it's headed — the reusable skill + +The end state is a skill that runs against any repo, drafts a per-PR lesson plan and a +per-repo glossary, and builds this interactive site. Four disciplines in this v1 keep that +path a lift rather than a rewrite: + +1. **Engine / theme seam.** The generic engine carries no Adjacent identifiers; everything + repo-specific lives in the `THEME` object. +2. **Glossary and lessons are generated outputs**, not assets baked into the tooling. The + skill's job is to produce them per repo. +3. **Two surfaces, kept separate:** postable markdown lesson vs hosted interactive site. +4. **Build one, then extract.** Ship the concrete Adjacent instance first; you cannot design + good portable seams against repos that do not exist yet. Extraction happens once this + instance is real, and must obey the portable-tooling rule (no project-specific + identifiers survive into the skill). + +## Out of scope (v1) + +- **Highlight-any-phrase "go deeper."** The north-star interaction: select arbitrary prose + and ask for a deeper explanation. The side-panel drawer is deliberately the surface it + would plug into later. +- **Rendered visual dependency-graph diagram.** v1 surfaces the graph as one-level prereq + chains with click-to-descend, not as a drawn node/edge view. +- **Multi-word prose-phrase annotation.** Only backticked code spans and tag chips annotate + in v1. +- **Full-text search across lessons.** +- **Light/dark toggle.** Dark-only, matching the landing page. + +## Seed glossary terms + +Drafted by the agent, reviewed by a human before publish. From the tags and concepts across +the 31 lessons: ownership, borrowing, move, lifetime, `'static`, reference counting / `Arc`, +`Rc`, `Weak`, `Mutex`, interior mutability, `Send`/`Sync`, trait, trait bound, `dyn` vs +generic (dynamic vs static dispatch), monomorphization, `Option`, `Result`, `?`, +combinators, closure, `Drop`/RAII, iterator / laziness, `collect`, newtype, derive macro, +`Serialize`/`Deserialize`, FFI / `unsafe`, `Instant` vs `SystemTime`, `HashMap::retain`, +`&str` vs `String`, `split_once`, `include_str!`, `build.rs`, `env!` / `option_env!`. From 37244c1d3b62fceadf006ca87c7e77d1a8876031 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Sat, 11 Jul 2026 15:23:38 -0700 Subject: [PATCH 02/19] Add implementation plan for the interactive lessons surface Nine TDD tasks building ent/lessons/build.mjs: inline + block markdown renderers, lesson parser, glossary validation/index/annotation, page and index templates, the browser drawer, orchestration with a --check gate, and the full glossary authoring pass. renderInline verified against the Task 1 suite before landing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ej8GZ5gZEaB2nsXG7aMxqn --- .../plans/2026-07-11-interactive-lessons.md | 1385 +++++++++++++++++ 1 file changed, 1385 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-interactive-lessons.md diff --git a/docs/superpowers/plans/2026-07-11-interactive-lessons.md b/docs/superpowers/plans/2026-07-11-interactive-lessons.md new file mode 100644 index 0000000..3b6479d --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-interactive-lessons.md @@ -0,0 +1,1385 @@ +# Interactive Lessons Surface Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Generate an interactive HTML page per Rust lesson under `ent/lessons/`, where backticked foundational terms are clickable and open a side panel with a definition, a why-it-matters line, and clickable one-level prerequisite chains. + +**Architecture:** A single dependency-free Node ESM generator, `ent/lessons/build.mjs`, parses `docs/lessons/*.md` (the single source of truth) plus `docs/lessons/glossary.json`, and emits static HTML pages, an index, and shared assets (`lessons.css`, `drawer.js`, `glossary.js`) into `ent/lessons/`. The generator is written as a neutral engine plus a thin `THEME` object at the top of the file, so the eventual reusable skill swaps the theme without touching the engine. Pure render/parse functions are unit-tested with Node's built-in test runner; the browser drawer is verified manually. + +**Tech Stack:** Node.js 26.2.0 (ESM, built-ins only: `node:fs`, `node:path`, `node:url`, `node:test`, `node:assert`). No npm packages. Plain HTML/CSS/JS output. `just` recipes for build + serve. + +## Global Constraints + +- **Node 26.2.0**, pinned in `.tool-versions`. Use only Node built-in modules. **Zero npm dependencies** — `ent/` stays dependency-free and outside the Cargo workspace. +- **Never modify any `docs/lessons/*.md` file.** The markdown is the single source of truth; the site is generated *from* it. +- **Never modify `ent/index.html`.** The landing page is out of scope; only read it for the palette. +- Every generated output file begins with `` (HTML) or `/* generated by build.mjs; edit docs/lessons/*.md */` (CSS/JS). +- Generated files **are committed** (so `ent/` stays directly serveable). Regeneration diffs are expected in review. +- All Adjacent-specific values (palette `#d4a574` etc., the `adj.ac/ent` wordmark, paths) live **only** in the `THEME` object. Engine functions take theme/glossary as parameters and carry no hard-coded Adjacent identifiers. (The portable-tooling "no project identifiers" rule applies to the *future* extracted skill, not to this v1; v1's identifiers are confined to `THEME`.) +- Dark-only styling, reusing `ent/index.html`'s palette. +- **Commit messages:** plain, descriptive, **no** Conventional-Commit prefixes (`feat:`/`fix:` are forbidden). Append the session's `Co-Authored-By` and `Claude-Session` trailers to each commit. Stage explicit paths (`git add `), never `git commit -a`. +- Run the generator and tests from the **repo root** (`node ent/lessons/build.mjs`, `node --test ent/lessons/`); paths resolve against `process.cwd()`. +- `import.meta.main` guards `main()` so importing `build.mjs` in tests has no side effects. + +--- + +### Task 1: Inline markdown renderer + +The smallest engine primitive: turn a run of inline markdown text into HTML. Handles inline code, links (rewriting `NN-slug.md` cross-links to `.html`), bold, and italic, with correct HTML escaping. Accepts an optional glossary index whose behavior is exercised in Task 4; here it defaults to unused. + +**Files:** +- Create: `ent/lessons/build.mjs` +- Test: `ent/lessons/build.test.mjs` + +**Interfaces:** +- Consumes: nothing (first task). +- Produces: + - `escapeHtml(s: string): string` — escapes `& < >`. + - `escapeAttr(s: string): string` — escapes `& < > "`. + - `renderInline(text: string, opts?: { glossaryIndex?: Map }): string` — inline markdown to HTML. + +- [ ] **Step 1: Write the failing test** + +Create `ent/lessons/build.test.mjs`: + +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { escapeHtml, escapeAttr, renderInline } from './build.mjs'; + +test('escapeHtml escapes angle brackets and ampersands', () => { + assert.equal(escapeHtml('a < b & c > d'), 'a < b & c > d'); +}); + +test('escapeAttr also escapes double quotes', () => { + assert.equal(escapeAttr('say "hi" & '), 'say "hi" & <go>'); +}); + +test('renderInline: inline code is escaped and wrapped in ', () => { + assert.equal(renderInline('use `Vec` here'), 'use Vec<T> here'); +}); + +test('renderInline: bold and italic', () => { + assert.equal(renderInline('**bold** and *soft*'), 'bold and soft'); +}); + +test('renderInline: content inside inline code is not treated as bold/italic', () => { + assert.equal(renderInline('`a * b * c`'), 'a * b * c'); +}); + +test('renderInline: external link passes through', () => { + assert.equal( + renderInline('[View PR](https://example.com/pull/16)'), + 'View PR', + ); +}); + +test('renderInline: cross-lesson .md link is rewritten to .html', () => { + assert.equal( + renderInline('[PR #40](40-serve-https-local-ca.md)'), + 'PR #40', + ); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: FAIL — `Cannot find module './build.mjs'` (or "export not found"). + +- [ ] **Step 3: Write minimal implementation** + +Create `ent/lessons/build.mjs`: + +```js +// Dependency-free generator for the interactive Rust lessons site. +// Reads docs/lessons/*.md (source of truth) + docs/lessons/glossary.json, +// emits static HTML + shared assets into ent/lessons/. Run from repo root. + +export function escapeHtml(s) { + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +export function escapeAttr(s) { + return escapeHtml(s).replace(/"/g, '"'); +} + +// Render one run of inline markdown. Inline code is extracted first so its +// contents are never reinterpreted as emphasis; links are extracted before +// escaping so their hrefs stay clean. A glossary index (alias -> key), when +// supplied, turns a matching code span into a clickable term button (Task 4). +export function renderInline(text, { glossaryIndex } = {}) { + const slots = []; + // NUL-delimited placeholders: NUL never appears in lesson prose, so restoring + // stashed HTML can never collide with real text. + const stash = (html) => { slots.push(html); return `\u0000${slots.length - 1}\u0000`; }; + + let s = String(text).replace(/`([^`]+)`/g, (_, code) => { + const inner = `${escapeHtml(code)}`; + const key = glossaryIndex && glossaryIndex.get(code); + return stash( + key + ? `` + : inner, + ); + }); + + s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => { + const h = /^\d+-[a-z0-9-]+\.md$/.test(href) ? href.replace(/\.md$/, '.html') : href; + return stash(`${renderInline(label, { glossaryIndex })}`); + }); + + s = escapeHtml(s) + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/\*([^*]+)\*/g, '$1'); + + return s.replace(/\u0000(\d+)\u0000/g, (_, i) => slots[Number(i)]); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: PASS — 7 passing, 0 failing. + +- [ ] **Step 5: Commit** + +```bash +git add ent/lessons/build.mjs ent/lessons/build.test.mjs +git commit -m "Add inline markdown renderer for the lessons generator" +``` + +--- + +### Task 2: Block markdown renderer + +Turns a lesson body (headings, paragraphs, fenced code, lists) into HTML, delegating inline runs to `renderInline`. Fenced code content is literal: it is escaped, never inline-rendered, and never annotated. Supports one level of nested unordered lists (only #65 uses it) and ordered lists (used by 7 lessons). + +**Files:** +- Modify: `ent/lessons/build.mjs` +- Test: `ent/lessons/build.test.mjs` + +**Interfaces:** +- Consumes: `renderInline`, `escapeHtml` (Task 1). +- Produces: `renderMarkdown(body: string, opts?: { glossaryIndex?: Map }): string` — block markdown to HTML. + +- [ ] **Step 1: Write the failing test** + +Append to `ent/lessons/build.test.mjs`: + +```js +import { renderMarkdown } from './build.mjs'; + +test('renderMarkdown: heading levels', () => { + assert.equal(renderMarkdown('## The Rust idea'), '

The Rust idea

'); + assert.equal(renderMarkdown('### Where this is headed'), '

Where this is headed

'); +}); + +test('renderMarkdown: paragraph joins wrapped lines and renders inline', () => { + assert.equal( + renderMarkdown('Clone an `Arc`\nto share it.'), + '

Clone an Arc to share it.

', + ); +}); + +test('renderMarkdown: fenced code is literal, escaped, not inline-rendered', () => { + const md = '```rust\nlet x: Vec = *ptr; // **not bold**\n```'; + assert.equal( + renderMarkdown(md), + '
let x: Vec<u8> = *ptr; // **not bold**\n
', + ); +}); + +test('renderMarkdown: unordered list with one nested level', () => { + const md = '- top one\n- top two\n - nested a'; + assert.equal( + renderMarkdown(md), + '
  • top one
  • top two
    • nested a
', + ); +}); + +test('renderMarkdown: ordered list', () => { + assert.equal(renderMarkdown('1. first\n2. second'), '
  1. first
  2. second
'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: FAIL — `renderMarkdown` is not exported. + +- [ ] **Step 3: Write minimal implementation** + +Append to `ent/lessons/build.mjs`: + +```js +// Block-level renderer for the bounded markdown the lessons use: ATX headings, +// paragraphs, fenced code (literal), and unordered/ordered lists with one nesting +// level. Blockquotes are handled by parseLesson (the header) and do not appear in +// bodies, so they are intentionally unsupported here. +export function renderMarkdown(body, { glossaryIndex } = {}) { + const lines = String(body).replace(/\r\n/g, '\n').split('\n'); + const out = []; + let i = 0; + + const listItemHtml = (text) => renderInline(text, { glossaryIndex }); + + while (i < lines.length) { + const line = lines[i]; + + if (/^```/.test(line)) { + const lang = line.slice(3).trim(); + const buf = []; + i++; + while (i < lines.length && !/^```/.test(lines[i])) buf.push(lines[i++]); + i++; // consume closing fence + const cls = lang ? ` class="language-${escapeAttr(lang)}"` : ''; + out.push(`
${escapeHtml(buf.join('\n') + '\n')}
`); + continue; + } + + const heading = line.match(/^(#{1,3})\s+(.*)$/); + if (heading) { + const level = heading[1].length; + out.push(`${renderInline(heading[2].trim(), { glossaryIndex })}`); + i++; + continue; + } + + if (/^\s*([-*]|\d+\.)\s+/.test(line)) { + const ordered = /^\s*\d+\.\s+/.test(line); + const tag = ordered ? 'ol' : 'ul'; + const items = []; + while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) { + const m = lines[i].match(/^(\s*)([-*]|\d+\.)\s+(.*)$/); + const indented = m[1].length >= 2; + const text = listItemHtml(m[3]); + if (indented && items.length) { + const last = items.length - 1; + items[last] = items[last].replace(/<\/li>$/, '') + `
  • ${text}
`; + } else { + items.push(`
  • ${text}
  • `); + } + i++; + } + out.push(`<${tag}>${items.join('')}`); + continue; + } + + if (line.trim() === '') { + i++; + continue; + } + + const para = []; + while (i < lines.length && lines[i].trim() !== '' && !/^(#{1,3}\s|```|\s*([-*]|\d+\.)\s)/.test(lines[i])) { + para.push(lines[i]); + i++; + } + out.push(`

    ${renderInline(para.join(' ').replace(/\s+/g, ' ').trim(), { glossaryIndex })}

    `); + } + + return out.join(''); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: PASS — 12 passing, 0 failing. + +- [ ] **Step 5: Commit** + +```bash +git add ent/lessons/build.mjs ent/lessons/build.test.mjs +git commit -m "Add block markdown renderer for the lessons generator" +``` + +--- + +### Task 3: Lesson parser + +Splits a lesson file into structured metadata (PR number, slug, title, the headline lesson sentence, tags, merged info) and the body markdown. This is the contract between the fixed lesson template and every downstream renderer. + +**Files:** +- Modify: `ent/lessons/build.mjs` +- Test: `ent/lessons/build.test.mjs` + +**Interfaces:** +- Consumes: nothing new. +- Produces: `parseLesson(filename: string, md: string): { pr: number, slug: string, title: string, lesson: string, tags: string[], merged: { date: string, delta: string, url: string }, bodyMarkdown: string }`. + +- [ ] **Step 1: Write the failing test** + +Append to `ent/lessons/build.test.mjs`: + +```js +import { parseLesson } from './build.mjs'; + +const SAMPLE = ` + +# PR #16 — Tracer: supervised app with logs + +> **Rust lesson:** Hand the task an \`Arc>\` clone. +> **Tags:** \`Arc>\` · \`tokio::spawn\` +> **Merged:** 2026-06-08 · +2369/−0 · [View PR](https://github.com/nonrational/adjacent/pull/16) + +## The situation + +First paragraph. +`; + +test('parseLesson extracts pr, slug, and title', () => { + const p = parseLesson('16-supervised-app-with-logs.md', SAMPLE); + assert.equal(p.pr, 16); + assert.equal(p.slug, 'supervised-app-with-logs'); + assert.equal(p.title, 'PR #16 — Tracer: supervised app with logs'); +}); + +test('parseLesson extracts the lesson sentence and tags', () => { + const p = parseLesson('16-supervised-app-with-logs.md', SAMPLE); + assert.equal(p.lesson, 'Hand the task an `Arc>` clone.'); + assert.deepEqual(p.tags, ['Arc>', 'tokio::spawn']); +}); + +test('parseLesson extracts merged date, delta, and url', () => { + const p = parseLesson('16-supervised-app-with-logs.md', SAMPLE); + assert.equal(p.merged.date, '2026-06-08'); + assert.equal(p.merged.delta, '+2369/−0'); + assert.equal(p.merged.url, 'https://github.com/nonrational/adjacent/pull/16'); +}); + +test('parseLesson body starts after the blockquote header', () => { + const p = parseLesson('16-supervised-app-with-logs.md', SAMPLE); + assert.ok(p.bodyMarkdown.startsWith('## The situation')); + assert.ok(!p.bodyMarkdown.includes('Rust lesson:')); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: FAIL — `parseLesson` is not exported. + +- [ ] **Step 3: Write minimal implementation** + +Append to `ent/lessons/build.mjs`: + +```js +// Parse a lesson file of the fixed template shape into structured parts. +export function parseLesson(filename, md) { + const fm = filename.match(/^(\d+)-([a-z0-9-]+)\.md$/); + if (!fm) throw new Error(`not a lesson filename: ${filename}`); + const pr = Number(fm[1]); + const slug = fm[2]; + + let rest = String(md).replace(/^/, '').replace(/^\s*/, ''); + + const titleMatch = rest.match(/^#\s+(.*)\n?/); + const title = titleMatch ? titleMatch[1].trim() : ''; + if (titleMatch) rest = rest.slice(titleMatch[0].length); + + const lines = rest.split('\n'); + let i = 0; + while (i < lines.length && lines[i].trim() === '') i++; + const quote = []; + while (i < lines.length && lines[i].startsWith('>')) { + quote.push(lines[i].replace(/^>\s?/, '')); + i++; + } + const bodyMarkdown = lines.slice(i).join('\n').trim(); + + const field = (label) => (quote.find((l) => l.startsWith(`**${label}:**`)) || '') + .replace(new RegExp(`^\\*\\*${label}:\\*\\*\\s*`), '') + .trim(); + + const lesson = field('Rust lesson'); + const tags = field('Tags').split('·').map((t) => t.replace(/`/g, '').trim()).filter(Boolean); + const mergedRaw = field('Merged'); + const mm = mergedRaw.match(/^([0-9-]+)\s*·\s*(.+?)\s*·\s*\[[^\]]*\]\(([^)]+)\)/); + const merged = mm + ? { date: mm[1].trim(), delta: mm[2].trim(), url: mm[3].trim() } + : { date: '', delta: '', url: '' }; + + return { pr, slug, title, lesson, tags, merged, bodyMarkdown }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: PASS — 16 passing, 0 failing. + +- [ ] **Step 5: Commit** + +```bash +git add ent/lessons/build.mjs ent/lessons/build.test.mjs +git commit -m "Add lesson parser for the fixed lesson template" +``` + +--- + +### Task 4: Glossary loading, validation, index, coverage, and term annotation + +Adds the glossary layer: load/validate the JSON, build the alias→key index, report backticked terms missing from the glossary, and (by feeding the index into `renderInline`) make matching code spans clickable. Creates a small but real seed `glossary.json`; Task 9 expands it to the full set. + +**Files:** +- Create: `docs/lessons/glossary.json` +- Modify: `ent/lessons/build.mjs` +- Test: `ent/lessons/build.test.mjs` + +**Interfaces:** +- Consumes: `renderInline` (Task 1), `parseLesson` (Task 3). +- Produces: + - `validateGlossary(glossary: object): { errors: string[], warnings: string[] }` + - `buildGlossaryIndex(glossary: object): Map` — every term and alias maps to its key. + - `coverageReport(lessons: ParsedLesson[], glossaryIndex: Map): { term: string, count: number }[]` — backticked terms (outside fenced code) absent from the glossary, most frequent first. + +- [ ] **Step 1: Write the failing test** + +Append to `ent/lessons/build.test.mjs`: + +```js +import { validateGlossary, buildGlossaryIndex, coverageReport } from './build.mjs'; + +const GLOSSARY = { + arc: { term: 'Arc', aliases: ['Arc', 'Arc>'], short: 's', why: 'w', prereqs: ['ownership'], link: { label: 'x', url: 'y' } }, + ownership: { term: 'ownership', aliases: [], short: 's', why: 'w', prereqs: [], link: { label: 'x', url: 'y' } }, +}; + +test('validateGlossary: clean glossary has no errors', () => { + assert.deepEqual(validateGlossary(GLOSSARY).errors, []); +}); + +test('validateGlossary: dangling prereq is an error', () => { + const bad = { arc: { term: 'Arc', aliases: [], prereqs: ['nope'] } }; + assert.equal(validateGlossary(bad).errors.length, 1); + assert.match(validateGlossary(bad).errors[0], /nope/); +}); + +test('validateGlossary: duplicate alias across entries is an error', () => { + const bad = { + a: { term: 'A', aliases: ['shared'], prereqs: [] }, + b: { term: 'B', aliases: ['shared'], prereqs: [] }, + }; + assert.match(validateGlossary(bad).errors[0], /shared/); +}); + +test('buildGlossaryIndex maps every term and alias to its key', () => { + const idx = buildGlossaryIndex(GLOSSARY); + assert.equal(idx.get('Arc'), 'arc'); + assert.equal(idx.get('Arc>'), 'arc'); + assert.equal(idx.get('Arc'), 'arc'); + assert.equal(idx.get('ownership'), 'ownership'); +}); + +test('renderInline wraps a glossary-matched code span as a term button', () => { + const idx = buildGlossaryIndex(GLOSSARY); + assert.equal( + renderInline('use `Arc` now', { glossaryIndex: idx }), + 'use now', + ); +}); + +test('coverageReport lists backticked terms missing from the glossary, not those present', () => { + const idx = buildGlossaryIndex(GLOSSARY); + const lessons = [{ bodyMarkdown: 'see `Arc` and `Weak`\n```rust\n`Mutex` in code\n```' }]; + const rep = coverageReport(lessons, idx); + assert.deepEqual(rep, [{ term: 'Weak', count: 1 }]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: FAIL — `validateGlossary` not exported. + +- [ ] **Step 3: Write minimal implementation** + +Append to `ent/lessons/build.mjs`: + +```js +// Every prereq must resolve to a real entry; every alias/term must be unique +// across entries (an ambiguous alias could not annotate deterministically). +export function validateGlossary(glossary) { + const errors = []; + const warnings = []; + const keys = new Set(Object.keys(glossary)); + const seen = new Map(); + for (const [key, e] of Object.entries(glossary)) { + for (const p of e.prereqs || []) { + if (!keys.has(p)) errors.push(`entry '${key}': prereq '${p}' has no glossary entry`); + } + for (const name of [e.term, ...(e.aliases || [])]) { + if (!name) continue; + if (seen.has(name) && seen.get(name) !== key) { + errors.push(`alias '${name}' maps to both '${seen.get(name)}' and '${key}'`); + } else { + seen.set(name, key); + } + } + } + return { errors, warnings }; +} + +export function buildGlossaryIndex(glossary) { + const idx = new Map(); + for (const [key, e] of Object.entries(glossary)) { + for (const name of [e.term, ...(e.aliases || [])]) { + if (name) idx.set(name, key); + } + } + return idx; +} + +export function coverageReport(lessons, glossaryIndex) { + const counts = new Map(); + for (const l of lessons) { + const noFences = String(l.bodyMarkdown).replace(/```[\s\S]*?```/g, ''); + for (const m of noFences.matchAll(/`([^`]+)`/g)) { + const t = m[1]; + if (!glossaryIndex.has(t)) counts.set(t, (counts.get(t) || 0) + 1); + } + } + return [...counts.entries()] + .map(([term, count]) => ({ term, count })) + .sort((a, b) => b.count - a.count || a.term.localeCompare(b.term)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: PASS — 22 passing, 0 failing. + +- [ ] **Step 5: Create the seed glossary** + +Create `docs/lessons/glossary.json` with real entries for PR #16's terms (expanded in Task 9): + +```json +{ + "arc": { + "term": "Arc", + "aliases": ["Arc", "Arc>", "Arc>"], + "short": "Atomically reference-counted shared pointer. Cloning it bumps a refcount and hands back a second owner of the same value, not a copy.", + "why": "Lets two threads or tasks own one value at once. It is how a detached task can touch state the spawner still holds.", + "prereqs": ["ownership", "move", "send-sync"], + "link": { "label": "Rust Book 16.3", "url": "https://doc.rust-lang.org/book/ch16-03-shared-state.html" } + }, + "mutex": { + "term": "Mutex", + "aliases": ["Mutex"], + "short": "A lock guarding interior data so only one holder mutates at a time. tokio's async Mutex yields the thread while waiting instead of blocking it.", + "why": "Shared ownership alone still races on writes. The Mutex is what makes the shared mutation safe, checked at compile time.", + "prereqs": ["ownership", "send-sync"], + "link": { "label": "Rust Book 16.3", "url": "https://doc.rust-lang.org/book/ch16-03-shared-state.html" } + }, + "ownership": { + "term": "ownership", + "aliases": [], + "short": "Every value has exactly one owner; when the owner goes out of scope the value is dropped. No garbage collector, no manual free.", + "why": "The single-owner rule is the root of Rust's memory safety and the reason sharing has to be made explicit.", + "prereqs": [], + "link": { "label": "Rust Book 4.1", "url": "https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html" } + }, + "move": { + "term": "move", + "aliases": ["move semantics"], + "short": "Assigning or passing a non-Copy value transfers ownership; the source can no longer be used. `move` on a closure forces it to own its captures.", + "why": "A spawned task must own what it touches. `move` is how you hand ownership into it so it can outlive the caller.", + "prereqs": ["ownership"], + "link": { "label": "Rust Book 4.1", "url": "https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html#variables-and-data-interacting-with-move" } + }, + "send-sync": { + "term": "Send / Sync", + "aliases": ["Send", "Sync", "Send + Sync"], + "short": "Marker traits the compiler infers: Send means a value can move to another thread; Sync means it can be shared by reference across threads.", + "why": "They are what let the compiler reject data races before the program runs. Arc is Send/Sync; Rc is not.", + "prereqs": ["ownership"], + "link": { "label": "Rust Book 16.4", "url": "https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html" } + } +} +``` + +- [ ] **Step 6: Verify the seed glossary validates** + +Run: +```bash +node -e "import('./ent/lessons/build.mjs').then(m => { const g = JSON.parse(require('fs').readFileSync('docs/lessons/glossary.json')); console.log(m.validateGlossary(g)); })" +``` +Expected: `{ errors: [], warnings: [] }`. + +- [ ] **Step 7: Commit** + +```bash +git add ent/lessons/build.mjs ent/lessons/build.test.mjs docs/lessons/glossary.json +git commit -m "Add glossary loading, validation, index, coverage, and term annotation" +``` + +--- + +### Task 5: Lesson page template and shared static assets + +Pure render functions that produce a full lesson HTML page and the shared assets (`lessons.css` from the theme palette, `glossary.js` from the glossary data). No file writes yet — those live in `main()` (Task 8), so these stay unit-testable. + +**Files:** +- Modify: `ent/lessons/build.mjs` +- Test: `ent/lessons/build.test.mjs` + +**Interfaces:** +- Consumes: `renderMarkdown`, `renderInline`, `buildGlossaryIndex`, `escapeHtml`, `escapeAttr`. +- Produces: + - `THEME` (object) — palette, font, wordmark, siteTitle, repoUrl, paths. + - `renderTagChips(tags: string[], glossaryIndex: Map): string` + - `renderLessonPage(parsed, opts: { glossaryIndex: Map, theme?: object }): string` + - `renderStylesheet(theme?: object): string` + - `renderGlossaryScript(glossary: object): string` + +- [ ] **Step 1: Write the failing test** + +Append to `ent/lessons/build.test.mjs`: + +```js +import { + THEME, renderTagChips, renderLessonPage, renderStylesheet, renderGlossaryScript, +} from './build.mjs'; + +const PARSED = { + pr: 16, slug: 'supervised-app-with-logs', + title: 'PR #16 — Tracer: supervised app with logs', + lesson: 'Hand the task an `Arc>` clone.', + tags: ['Arc>', 'tokio::spawn'], + merged: { date: '2026-06-08', delta: '+2369/−0', url: 'https://github.com/nonrational/adjacent/pull/16' }, + bodyMarkdown: '## The situation\n\nBoot it with an `Arc`.', +}; +const IDX = buildGlossaryIndex({ + arc: { term: 'Arc', aliases: ['Arc', 'Arc>'], prereqs: [] }, +}); + +test('renderTagChips: a glossary tag becomes a term button, others plain', () => { + const html = renderTagChips(['Arc>', 'tokio::spawn'], IDX); + assert.match(html, /data-term="arc"[^>]*>[^<]*Arc<Mutex<T>><\/code>/); + assert.match(html, /tokio::spawn/); +}); + +test('renderLessonPage: full document with provenance header and asset links', () => { + const html = renderLessonPage(PARSED, { glossaryIndex: IDX }); + assert.ok(html.startsWith(''; +const ASSET_HEADER = '/* generated by build.mjs; edit docs/lessons/*.md */'; + +export function renderTagChips(tags, glossaryIndex) { + return tags + .map((t) => { + const key = glossaryIndex.get(t); + const code = `${escapeHtml(t)}`; + return key + ? `` + : `${code}`; + }) + .join(''); +} + +export function renderLessonPage(parsed, { glossaryIndex, theme = THEME }) { + const { a, slash, b } = theme.wordmark; + const body = renderMarkdown(parsed.bodyMarkdown, { glossaryIndex }); + const lesson = renderInline(parsed.lesson, { glossaryIndex }); + const chips = renderTagChips(parsed.tags, glossaryIndex); + const prLink = parsed.merged.url + ? `View PR` + : ''; + return `${HTML_HEADER} + + + + + + ${escapeHtml(parsed.title)} + ${theme.fontLinks} + + + +
    +
    + ${a}${slash}${b} + all lessons +
    +
    +

    ${escapeHtml(parsed.title)}

    +
    +

    ${lesson}

    +

    ${chips}

    +

    ${escapeHtml(parsed.merged.date)} · ${escapeHtml(parsed.merged.delta)} · ${prLink}

    +
    + ${body} +
    +
    + + + + +`; +} + +export function renderStylesheet(theme = THEME) { + const p = theme.palette; + return `${ASSET_HEADER} +:root { + --ink: ${p.ink}; --paper: ${p.paper}; --soft: ${p.soft}; --dim: ${p.dim}; + --rule: ${p.rule}; --accent: ${p.accent}; --accent-dim: ${p.accentDim}; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { background: var(--ink); color: var(--paper); font-family: ${theme.fontStack}; font-size: 15px; line-height: 1.65; -webkit-font-smoothing: antialiased; } +body { min-height: 100vh; padding: 3rem clamp(1.25rem, 6vw, 5rem); } +.frame { max-width: 760px; margin: 0 auto; } +header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 3rem; } +.wordmark { font-weight: 700; font-size: 1.1rem; letter-spacing: -0.03em; text-decoration: none; } +.wordmark .a { color: var(--paper); } .wordmark .b { color: var(--accent); } .wordmark .slash { color: var(--rule); font-weight: 300; margin: 0 0.1em; } +.back { font-size: 12px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; text-decoration: none; border-bottom: 1px dotted var(--dim); } +.back:hover { color: var(--accent); border-color: var(--accent); } +article h1 { font-size: clamp(1.6rem, 4vw, 2.3rem); font-weight: 700; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; } +.lesson-header { border-left: 2px solid var(--accent-dim); padding-left: 1.1rem; margin-bottom: 2.5rem; } +.lesson-header .lesson { font-size: 1.15rem; font-weight: 300; margin-bottom: 0.75rem; } +.lesson-header .tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.6rem; } +.lesson-header .meta { font-size: 12px; color: var(--dim); letter-spacing: 0.04em; } +article h2 { font-size: 1.25rem; margin: 2.5rem 0 1rem; padding-top: 1.25rem; border-top: 1px solid var(--rule); } +article h3 { font-size: 1.05rem; margin: 1.75rem 0 0.75rem; } +article p { margin-bottom: 1rem; color: var(--paper); } +article ul, article ol { margin: 0 0 1rem 1.4rem; } +article li { margin-bottom: 0.35rem; } +article a { color: var(--paper); text-decoration: none; border-bottom: 1px solid var(--accent-dim); } +article a:hover { color: var(--accent); border-color: var(--accent); } +code { font-family: inherit; color: var(--accent); font-size: 0.95em; } +pre { background: rgba(255,255,255,0.02); border: 1px solid var(--rule); border-left: 2px solid var(--accent-dim); padding: 1rem 1.15rem; margin-bottom: 1.25rem; overflow-x: auto; } +pre code { color: var(--paper); font-size: 13px; line-height: 1.5; } +.term { font: inherit; background: none; border: none; padding: 0; cursor: pointer; border-bottom: 1px dotted var(--accent); } +.term:hover, .term:focus-visible { background: rgba(212,165,116,0.1); } +.tag code { font-size: 0.85em; } +span.tag { border-bottom: 1px dotted var(--rule); } +/* Drawer (built by drawer.js) */ +.drawer-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.15s; z-index: 10; } +.drawer-scrim.open { opacity: 1; } +.drawer { position: fixed; top: 0; right: 0; height: 100%; width: min(360px, 90vw); background: var(--ink); border-left: 1px solid var(--rule); transform: translateX(100%); transition: transform 0.18s ease-out; z-index: 11; padding: 1.75rem 1.5rem; overflow-y: auto; } +.drawer.open { transform: none; } +.drawer .close { position: absolute; top: 1rem; right: 1.1rem; background: none; border: none; color: var(--dim); font: inherit; font-size: 1.2rem; cursor: pointer; } +.drawer .close:hover { color: var(--accent); } +.drawer .back-link { background: none; border: none; color: var(--dim); font: inherit; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; margin-bottom: 1rem; display: none; } +.drawer.has-history .back-link { display: inline-block; } +.drawer .d-term { color: var(--accent); font-weight: 700; font-size: 1.1rem; margin-bottom: 0.9rem; word-break: break-word; } +.drawer .d-label { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim); margin: 1.2rem 0 0.4rem; } +.drawer .d-short, .drawer .d-why { color: var(--paper); font-size: 0.95rem; } +.drawer .d-prereqs { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.drawer .d-prereqs .term { border: 1px solid var(--accent-dim); border-radius: 2px; padding: 0.2rem 0.55rem; } +.drawer .d-link a { color: var(--accent); font-size: 0.9rem; } +@media (max-width: 540px) { body { padding: 2rem 1.1rem; } } +`; +} + +export function renderGlossaryScript(glossary) { + return `${ASSET_HEADER}\nwindow.__GLOSSARY__ = ${JSON.stringify(glossary, null, 0)};\n`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: PASS — 27 passing, 0 failing. + +- [ ] **Step 5: Commit** + +```bash +git add ent/lessons/build.mjs ent/lessons/build.test.mjs +git commit -m "Add lesson page template and themed shared assets" +``` + +--- + +### Task 6: The drawer interaction (`drawer.js`) + +The browser-side behavior: clicking a `.term` opens the side panel with the glossary entry, prereq chips descend into their own entries with a back affordance, and Esc / scrim / close button dismiss it. This is a static JS string constant emitted by the generator; because its behavior is browser-side, it is verified by driving the page, not by a Node unit test. + +**Files:** +- Modify: `ent/lessons/build.mjs` +- Test: `ent/lessons/build.test.mjs` (presence check only) + +**Interfaces:** +- Consumes: `window.__GLOSSARY__` (from `glossary.js`), `.term[data-term]` buttons (from the page). +- Produces: `DRAWER_JS` (string constant) and `renderDrawerScript(): string`. + +- [ ] **Step 1: Write the failing test** + +Append to `ent/lessons/build.test.mjs`: + +```js +import { renderDrawerScript } from './build.mjs'; + +test('renderDrawerScript emits provenance header and wires term clicks', () => { + const js = renderDrawerScript(); + assert.ok(js.startsWith('/* generated by build.mjs')); + assert.match(js, /addEventListener\('click'/); + assert.match(js, /__GLOSSARY__/); + assert.match(js, /Escape/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: FAIL — `renderDrawerScript` not exported. + +- [ ] **Step 3: Write minimal implementation** + +Append to `ent/lessons/build.mjs`: + +```js +// Browser-side drawer. Built as a string so the generator can emit it verbatim. +// Progressive enhancement: with JS off, .term buttons are inert and the lesson +// stays fully readable. +const DRAWER_JS = String.raw`(() => { + const G = window.__GLOSSARY__ || {}; + const scrim = document.createElement('div'); + scrim.className = 'drawer-scrim'; + const drawer = document.createElement('aside'); + drawer.className = 'drawer'; + drawer.setAttribute('role', 'dialog'); + drawer.setAttribute('aria-label', 'Term explanation'); + drawer.innerHTML = + '' + + '' + + '
    '; + document.body.append(scrim, drawer); + const body = drawer.querySelector('.d-body'); + const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); + let history = []; + + function render(key) { + const e = G[key]; + if (!e) return; + const prereqs = (e.prereqs || []) + .filter((k) => G[k]) + .map((k) => '') + .join(''); + const link = e.link ? '

    Dig deeper

    ' : ''; + body.innerHTML = + '

    ' + esc(e.term) + '

    ' + + '

    ' + esc(e.short || '') + '

    ' + + (e.why ? '

    Why it matters

    ' + esc(e.why) + '

    ' : '') + + (prereqs ? '

    Learn first

    ' + prereqs + '
    ' : '') + + link; + // Back is shown only once we've descended past the root term. + drawer.classList.toggle('has-history', history.length > 1); + } + + function open(key) { + history = [key]; + render(key); + scrim.classList.add('open'); + drawer.classList.add('open'); + } + function close() { + scrim.classList.remove('open'); + drawer.classList.remove('open'); + history = []; + drawer.classList.remove('has-history'); + } + + document.addEventListener('click', (ev) => { + const term = ev.target.closest('.term'); + if (term && term.dataset.term) { + ev.preventDefault(); + if (ev.target.closest('.drawer')) { + history.push(term.dataset.term); // descend into a prereq + render(term.dataset.term); + } else { + open(term.dataset.term); // open from the lesson prose + } + return; + } + if (ev.target.closest('.drawer .close')) { close(); } + }); + drawer.querySelector('.back-link').addEventListener('click', () => { + history.pop(); + const prev = history[history.length - 1]; + if (prev) { render(prev); } else { close(); } + }); + scrim.addEventListener('click', close); + document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') close(); }); +})(); +`; + +export function renderDrawerScript() { + return `${ASSET_HEADER}\n${DRAWER_JS}`; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `node --test ent/lessons/build.test.mjs` +Expected: PASS — 28 passing, 0 failing. + +- [ ] **Step 5: Commit** + +```bash +git add ent/lessons/build.mjs ent/lessons/build.test.mjs +git commit -m "Add browser-side drawer interaction for term annotations" +``` + +(Browser verification of the drawer happens in Task 8 once `main()` can emit a real page.) + +--- + +### Task 7: Index page from README + +Parse `README.md`'s lesson table and reading-path lists (targeted regex, same known-structure approach as the lesson parser) into `index.html`, preserving the curated takeaways, the ◦ honesty markers, and the themed reading paths. + +**Files:** +- Modify: `ent/lessons/build.mjs` +- Test: `ent/lessons/build.test.mjs` + +**Interfaces:** +- Consumes: `renderInline`, `escapeHtml`, `THEME`. +- Produces: `parseReadmeIndex(readmeMd: string): { rows: { pr: number, href: string, lesson: string, takeaway: string, sparse: boolean }[], paths: { theme: string, html: string }[] }` and `renderIndexPage(readmeMd: string, opts?: { theme?: object }): string`. + +- [ ] **Step 1: Write the failing test** + +Append to `ent/lessons/build.test.mjs`: + +```js +import { parseReadmeIndex, renderIndexPage } from './build.mjs'; + +const README = `# Rust, one PR at a time + +Intro prose. + +## The lessons + +| PR | Lesson | The one-line takeaway | +|----|--------|-----------------------| +| [#16](16-supervised-app-with-logs.md) | \`Arc>\` + \`tokio::spawn\` | Clone an \`Arc\` to share. | +| [#34](34-fix-landing-page-redirect.md) ◦ | Client-side redirects | How a meta refresh works. | + +◦ = little or no Rust. + +## Reading paths by theme + +- **Error handling** — [#29](29-deadlines-and-failure-enums.md) → [#48](48-warn-dont-swallow-errors.md) +`; + +test('parseReadmeIndex extracts rows including the sparse marker', () => { + const { rows } = parseReadmeIndex(README); + assert.equal(rows.length, 2); + assert.equal(rows[0].pr, 16); + assert.equal(rows[0].href, '16-supervised-app-with-logs.html'); + assert.equal(rows[0].sparse, false); + assert.equal(rows[1].sparse, true); +}); + +test('parseReadmeIndex extracts reading paths', () => { + const { paths } = parseReadmeIndex(README); + assert.equal(paths.length, 1); + assert.match(paths[0].theme, /Error handling/); + assert.match(paths[0].html, /29-deadlines-and-failure-enums\.html/); +}); + +test('renderIndexPage produces a document linking to lesson pages', () => { + const html = renderIndexPage(README); + assert.ok(html.startsWith('\s*/, ''); + + const titleMatch = rest.match(/^#\s+(.*)\n?/); + const title = titleMatch ? titleMatch[1].trim() : ''; + if (titleMatch) rest = rest.slice(titleMatch[0].length); + + const lines = rest.split('\n'); + let i = 0; + while (i < lines.length && lines[i].trim() === '') i++; + const quote = []; + while (i < lines.length && lines[i].startsWith('>')) { + quote.push(lines[i].replace(/^>\s?/, '')); + i++; + } + const bodyMarkdown = lines.slice(i).join('\n').trim(); + + const field = (label) => (quote.find((l) => l.startsWith(`**${label}:**`)) || '') + .replace(new RegExp(`^\\*\\*${label}:\\*\\*\\s*`), '') + .trim(); + + const lesson = field('Rust lesson'); + const tags = field('Tags').split('·').map((t) => t.replace(/`/g, '').trim()).filter(Boolean); + const mergedRaw = field('Merged'); + const mm = mergedRaw.match(/^([0-9-]+)\s*·\s*(.+?)\s*·\s*\[[^\]]*\]\(([^)]+)\)/); + const merged = mm + ? { date: mm[1].trim(), delta: mm[2].trim(), url: mm[3].trim() } + : { date: '', delta: '', url: '' }; + + return { pr, slug, title, lesson, tags, merged, bodyMarkdown }; +} diff --git a/ent/lessons/build.test.mjs b/ent/lessons/build.test.mjs index a90953c..58c9cc9 100644 --- a/ent/lessons/build.test.mjs +++ b/ent/lessons/build.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { escapeHtml, escapeAttr, renderInline, renderMarkdown } from './build.mjs'; +import { escapeHtml, escapeAttr, renderInline, renderMarkdown, parseLesson } from './build.mjs'; test('escapeHtml escapes angle brackets and ampersands', () => { assert.equal(escapeHtml('a < b & c > d'), 'a < b & c > d'); @@ -116,3 +116,42 @@ test('renderMarkdown: real wrapped bullets stay one list with no stray paragraph assert.equal((html.match(/
  • /g) || []).length, 2); assert.equal((html.match(/

    /g) || []).length, 0); }); + +const SAMPLE = ` + +# PR #16 — Tracer: supervised app with logs + +> **Rust lesson:** Hand the task an \`Arc>\` clone. +> **Tags:** \`Arc>\` · \`tokio::spawn\` +> **Merged:** 2026-06-08 · +2369/−0 · [View PR](https://github.com/nonrational/adjacent/pull/16) + +## The situation + +First paragraph. +`; + +test('parseLesson extracts pr, slug, and title', () => { + const p = parseLesson('16-supervised-app-with-logs.md', SAMPLE); + assert.equal(p.pr, 16); + assert.equal(p.slug, 'supervised-app-with-logs'); + assert.equal(p.title, 'PR #16 — Tracer: supervised app with logs'); +}); + +test('parseLesson extracts the lesson sentence and tags', () => { + const p = parseLesson('16-supervised-app-with-logs.md', SAMPLE); + assert.equal(p.lesson, 'Hand the task an `Arc>` clone.'); + assert.deepEqual(p.tags, ['Arc>', 'tokio::spawn']); +}); + +test('parseLesson extracts merged date, delta, and url', () => { + const p = parseLesson('16-supervised-app-with-logs.md', SAMPLE); + assert.equal(p.merged.date, '2026-06-08'); + assert.equal(p.merged.delta, '+2369/−0'); + assert.equal(p.merged.url, 'https://github.com/nonrational/adjacent/pull/16'); +}); + +test('parseLesson body starts after the blockquote header', () => { + const p = parseLesson('16-supervised-app-with-logs.md', SAMPLE); + assert.ok(p.bodyMarkdown.startsWith('## The situation')); + assert.ok(!p.bodyMarkdown.includes('Rust lesson:')); +}); From 1be64fbbf5056e9f1e6372b6df68d522ab78bd73 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Sat, 11 Jul 2026 16:09:07 -0700 Subject: [PATCH 08/19] Add glossary loading, validation, index, coverage, and term annotation Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ej8GZ5gZEaB2nsXG7aMxqn --- docs/lessons/glossary.json | 42 +++++++++++++++++++++++++++++++++ ent/lessons/build.mjs | 47 +++++++++++++++++++++++++++++++++++++ ent/lessons/build.test.mjs | 48 +++++++++++++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 docs/lessons/glossary.json diff --git a/docs/lessons/glossary.json b/docs/lessons/glossary.json new file mode 100644 index 0000000..5b0406e --- /dev/null +++ b/docs/lessons/glossary.json @@ -0,0 +1,42 @@ +{ + "arc": { + "term": "Arc", + "aliases": ["Arc", "Arc>", "Arc>"], + "short": "Atomically reference-counted shared pointer. Cloning it bumps a refcount and hands back a second owner of the same value, not a copy.", + "why": "Lets two threads or tasks own one value at once. It is how a detached task can touch state the spawner still holds.", + "prereqs": ["ownership", "move", "send-sync"], + "link": { "label": "Rust Book 16.3", "url": "https://doc.rust-lang.org/book/ch16-03-shared-state.html" } + }, + "mutex": { + "term": "Mutex", + "aliases": ["Mutex"], + "short": "A lock guarding interior data so only one holder mutates at a time. tokio's async Mutex yields the thread while waiting instead of blocking it.", + "why": "Shared ownership alone still races on writes. The Mutex is what makes the shared mutation safe, checked at compile time.", + "prereqs": ["ownership", "send-sync"], + "link": { "label": "Rust Book 16.3", "url": "https://doc.rust-lang.org/book/ch16-03-shared-state.html" } + }, + "ownership": { + "term": "ownership", + "aliases": [], + "short": "Every value has exactly one owner; when the owner goes out of scope the value is dropped. No garbage collector, no manual free.", + "why": "The single-owner rule is the root of Rust's memory safety and the reason sharing has to be made explicit.", + "prereqs": [], + "link": { "label": "Rust Book 4.1", "url": "https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html" } + }, + "move": { + "term": "move", + "aliases": ["move semantics"], + "short": "Assigning or passing a non-Copy value transfers ownership; the source can no longer be used. `move` on a closure forces it to own its captures.", + "why": "A spawned task must own what it touches. `move` is how you hand ownership into it so it can outlive the caller.", + "prereqs": ["ownership"], + "link": { "label": "Rust Book 4.1", "url": "https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html#variables-and-data-interacting-with-move" } + }, + "send-sync": { + "term": "Send / Sync", + "aliases": ["Send", "Sync", "Send + Sync"], + "short": "Marker traits the compiler infers: Send means a value can move to another thread; Sync means it can be shared by reference across threads.", + "why": "They are what let the compiler reject data races before the program runs. Arc is Send/Sync; Rc is not.", + "prereqs": ["ownership"], + "link": { "label": "Rust Book 16.4", "url": "https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html" } + } +} diff --git a/ent/lessons/build.mjs b/ent/lessons/build.mjs index 285a794..3958c0d 100644 --- a/ent/lessons/build.mjs +++ b/ent/lessons/build.mjs @@ -202,3 +202,50 @@ export function parseLesson(filename, md) { return { pr, slug, title, lesson, tags, merged, bodyMarkdown }; } + +// Every prereq must resolve to a real entry; every alias/term must be unique +// across entries (an ambiguous alias could not annotate deterministically). +export function validateGlossary(glossary) { + const errors = []; + const warnings = []; + const keys = new Set(Object.keys(glossary)); + const seen = new Map(); + for (const [key, e] of Object.entries(glossary)) { + for (const p of e.prereqs || []) { + if (!keys.has(p)) errors.push(`entry '${key}': prereq '${p}' has no glossary entry`); + } + for (const name of [e.term, ...(e.aliases || [])]) { + if (!name) continue; + if (seen.has(name) && seen.get(name) !== key) { + errors.push(`alias '${name}' maps to both '${seen.get(name)}' and '${key}'`); + } else { + seen.set(name, key); + } + } + } + return { errors, warnings }; +} + +export function buildGlossaryIndex(glossary) { + const idx = new Map(); + for (const [key, e] of Object.entries(glossary)) { + for (const name of [e.term, ...(e.aliases || [])]) { + if (name) idx.set(name, key); + } + } + return idx; +} + +export function coverageReport(lessons, glossaryIndex) { + const counts = new Map(); + for (const l of lessons) { + const noFences = String(l.bodyMarkdown).replace(/```[\s\S]*?```/g, ''); + for (const m of noFences.matchAll(/`([^`]+)`/g)) { + const t = m[1]; + if (!glossaryIndex.has(t)) counts.set(t, (counts.get(t) || 0) + 1); + } + } + return [...counts.entries()] + .map(([term, count]) => ({ term, count })) + .sort((a, b) => b.count - a.count || a.term.localeCompare(b.term)); +} diff --git a/ent/lessons/build.test.mjs b/ent/lessons/build.test.mjs index 58c9cc9..a855b0c 100644 --- a/ent/lessons/build.test.mjs +++ b/ent/lessons/build.test.mjs @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { escapeHtml, escapeAttr, renderInline, renderMarkdown, parseLesson } from './build.mjs'; +import { escapeHtml, escapeAttr, renderInline, renderMarkdown, parseLesson, validateGlossary, buildGlossaryIndex, coverageReport } from './build.mjs'; test('escapeHtml escapes angle brackets and ampersands', () => { assert.equal(escapeHtml('a < b & c > d'), 'a < b & c > d'); @@ -155,3 +155,49 @@ test('parseLesson body starts after the blockquote header', () => { assert.ok(p.bodyMarkdown.startsWith('## The situation')); assert.ok(!p.bodyMarkdown.includes('Rust lesson:')); }); + +const GLOSSARY = { + arc: { term: 'Arc', aliases: ['Arc', 'Arc>'], short: 's', why: 'w', prereqs: ['ownership'], link: { label: 'x', url: 'y' } }, + ownership: { term: 'ownership', aliases: [], short: 's', why: 'w', prereqs: [], link: { label: 'x', url: 'y' } }, +}; + +test('validateGlossary: clean glossary has no errors', () => { + assert.deepEqual(validateGlossary(GLOSSARY).errors, []); +}); + +test('validateGlossary: dangling prereq is an error', () => { + const bad = { arc: { term: 'Arc', aliases: [], prereqs: ['nope'] } }; + assert.equal(validateGlossary(bad).errors.length, 1); + assert.match(validateGlossary(bad).errors[0], /nope/); +}); + +test('validateGlossary: duplicate alias across entries is an error', () => { + const bad = { + a: { term: 'A', aliases: ['shared'], prereqs: [] }, + b: { term: 'B', aliases: ['shared'], prereqs: [] }, + }; + assert.match(validateGlossary(bad).errors[0], /shared/); +}); + +test('buildGlossaryIndex maps every term and alias to its key', () => { + const idx = buildGlossaryIndex(GLOSSARY); + assert.equal(idx.get('Arc'), 'arc'); + assert.equal(idx.get('Arc>'), 'arc'); + assert.equal(idx.get('Arc'), 'arc'); + assert.equal(idx.get('ownership'), 'ownership'); +}); + +test('renderInline wraps a glossary-matched code span as a term button', () => { + const idx = buildGlossaryIndex(GLOSSARY); + assert.equal( + renderInline('use `Arc` now', { glossaryIndex: idx }), + 'use now', + ); +}); + +test('coverageReport lists backticked terms missing from the glossary, not those present', () => { + const idx = buildGlossaryIndex(GLOSSARY); + const lessons = [{ bodyMarkdown: 'see `Arc` and `Weak`\n```rust\n`Mutex` in code\n```' }]; + const rep = coverageReport(lessons, idx); + assert.deepEqual(rep, [{ term: 'Weak', count: 1 }]); +}); From d22a4d072551364ecaf56c964764f1db5411b17a Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Sat, 11 Jul 2026 16:13:43 -0700 Subject: [PATCH 09/19] Add lesson page template and themed shared assets Appends THEME, renderTagChips, renderLessonPage, renderStylesheet, and renderGlossaryScript to build.mjs. All exports consume existing functions and pass unit tests. CSS stylesheet generated from theme palette; HTML page template includes provenance header and asset links (lessons.css, glossary.js, drawer.js). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ej8GZ5gZEaB2nsXG7aMxqn --- ent/lessons/build.mjs | 133 +++++++++++++++++++++++++++++++++++++ ent/lessons/build.test.mjs | 44 ++++++++++++ 2 files changed, 177 insertions(+) diff --git a/ent/lessons/build.mjs b/ent/lessons/build.mjs index 3958c0d..43c39cf 100644 --- a/ent/lessons/build.mjs +++ b/ent/lessons/build.mjs @@ -249,3 +249,136 @@ export function coverageReport(lessons, glossaryIndex) { .map(([term, count]) => ({ term, count })) .sort((a, b) => b.count - a.count || a.term.localeCompare(b.term)); } + +// ---- Theme + config (the repo-specific seam; swap this to retarget) ---- +export const THEME = { + name: 'adjacent', + palette: { + ink: '#0a0a0a', paper: '#ededea', soft: '#b5b5af', dim: '#7a7a74', + rule: '#1f1f1d', accent: '#d4a574', accentDim: '#8a6a44', + }, + fontLinks: [ + '', + '', + '', + ].join('\n '), + fontStack: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace", + wordmark: { a: 'adj.ac', slash: '/', b: 'ent' }, + siteTitle: 'adj.ac/ent — Rust, one PR at a time', + repoUrl: 'https://github.com/nonrational/adjacent', + paths: { lessonsDir: 'docs/lessons', readme: 'docs/lessons/README.md', outDir: 'ent/lessons' }, +}; + +const HTML_HEADER = ''; +const ASSET_HEADER = '/* generated by build.mjs; edit docs/lessons/*.md */'; + +export function renderTagChips(tags, glossaryIndex) { + return tags + .map((t) => { + const key = glossaryIndex.get(t); + const code = `${escapeHtml(t)}`; + return key + ? `` + : `${code}`; + }) + .join(''); +} + +export function renderLessonPage(parsed, { glossaryIndex, theme = THEME }) { + const { a, slash, b } = theme.wordmark; + const body = renderMarkdown(parsed.bodyMarkdown, { glossaryIndex }); + const lesson = renderInline(parsed.lesson, { glossaryIndex }); + const chips = renderTagChips(parsed.tags, glossaryIndex); + const prLink = parsed.merged.url + ? `View PR` + : ''; + return `${HTML_HEADER} + + + + + + ${escapeHtml(parsed.title)} + ${theme.fontLinks} + + + +

    +
    + ${a}${slash}${b} + all lessons +
    +
    +

    ${escapeHtml(parsed.title)}

    +
    +

    ${lesson}

    +

    ${chips}

    +

    ${escapeHtml(parsed.merged.date)} · ${escapeHtml(parsed.merged.delta)} · ${prLink}

    +
    + ${body} +
    +
    + + + + +`; +} + +export function renderStylesheet(theme = THEME) { + const p = theme.palette; + return `${ASSET_HEADER} +:root { + --ink: ${p.ink}; --paper: ${p.paper}; --soft: ${p.soft}; --dim: ${p.dim}; + --rule: ${p.rule}; --accent: ${p.accent}; --accent-dim: ${p.accentDim}; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { background: var(--ink); color: var(--paper); font-family: ${theme.fontStack}; font-size: 15px; line-height: 1.65; -webkit-font-smoothing: antialiased; } +body { min-height: 100vh; padding: 3rem clamp(1.25rem, 6vw, 5rem); } +.frame { max-width: 760px; margin: 0 auto; } +header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 3rem; } +.wordmark { font-weight: 700; font-size: 1.1rem; letter-spacing: -0.03em; text-decoration: none; } +.wordmark .a { color: var(--paper); } .wordmark .b { color: var(--accent); } .wordmark .slash { color: var(--rule); font-weight: 300; margin: 0 0.1em; } +.back { font-size: 12px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; text-decoration: none; border-bottom: 1px dotted var(--dim); } +.back:hover { color: var(--accent); border-color: var(--accent); } +article h1 { font-size: clamp(1.6rem, 4vw, 2.3rem); font-weight: 700; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; } +.lesson-header { border-left: 2px solid var(--accent-dim); padding-left: 1.1rem; margin-bottom: 2.5rem; } +.lesson-header .lesson { font-size: 1.15rem; font-weight: 300; margin-bottom: 0.75rem; } +.lesson-header .tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.6rem; } +.lesson-header .meta { font-size: 12px; color: var(--dim); letter-spacing: 0.04em; } +article h2 { font-size: 1.25rem; margin: 2.5rem 0 1rem; padding-top: 1.25rem; border-top: 1px solid var(--rule); } +article h3 { font-size: 1.05rem; margin: 1.75rem 0 0.75rem; } +article p { margin-bottom: 1rem; color: var(--paper); } +article ul, article ol { margin: 0 0 1rem 1.4rem; } +article li { margin-bottom: 0.35rem; } +article a { color: var(--paper); text-decoration: none; border-bottom: 1px solid var(--accent-dim); } +article a:hover { color: var(--accent); border-color: var(--accent); } +code { font-family: inherit; color: var(--accent); font-size: 0.95em; } +pre { background: rgba(255,255,255,0.02); border: 1px solid var(--rule); border-left: 2px solid var(--accent-dim); padding: 1rem 1.15rem; margin-bottom: 1.25rem; overflow-x: auto; } +pre code { color: var(--paper); font-size: 13px; line-height: 1.5; } +.term { font: inherit; background: none; border: none; padding: 0; cursor: pointer; border-bottom: 1px dotted var(--accent); } +.term:hover, .term:focus-visible { background: rgba(212,165,116,0.1); } +.tag code { font-size: 0.85em; } +span.tag { border-bottom: 1px dotted var(--rule); } +/* Drawer (built by drawer.js) */ +.drawer-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.15s; z-index: 10; } +.drawer-scrim.open { opacity: 1; } +.drawer { position: fixed; top: 0; right: 0; height: 100%; width: min(360px, 90vw); background: var(--ink); border-left: 1px solid var(--rule); transform: translateX(100%); transition: transform 0.18s ease-out; z-index: 11; padding: 1.75rem 1.5rem; overflow-y: auto; } +.drawer.open { transform: none; } +.drawer .close { position: absolute; top: 1rem; right: 1.1rem; background: none; border: none; color: var(--dim); font: inherit; font-size: 1.2rem; cursor: pointer; } +.drawer .close:hover { color: var(--accent); } +.drawer .back-link { background: none; border: none; color: var(--dim); font: inherit; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; margin-bottom: 1rem; display: none; } +.drawer.has-history .back-link { display: inline-block; } +.drawer .d-term { color: var(--accent); font-weight: 700; font-size: 1.1rem; margin-bottom: 0.9rem; word-break: break-word; } +.drawer .d-label { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim); margin: 1.2rem 0 0.4rem; } +.drawer .d-short, .drawer .d-why { color: var(--paper); font-size: 0.95rem; } +.drawer .d-prereqs { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.drawer .d-prereqs .term { border: 1px solid var(--accent-dim); border-radius: 2px; padding: 0.2rem 0.55rem; } +.drawer .d-link a { color: var(--accent); font-size: 0.9rem; } +@media (max-width: 540px) { body { padding: 2rem 1.1rem; } } +`; +} + +export function renderGlossaryScript(glossary) { + return `${ASSET_HEADER}\nwindow.__GLOSSARY__ = ${JSON.stringify(glossary, null, 0)};\n`; +} diff --git a/ent/lessons/build.test.mjs b/ent/lessons/build.test.mjs index a855b0c..fbfe436 100644 --- a/ent/lessons/build.test.mjs +++ b/ent/lessons/build.test.mjs @@ -201,3 +201,47 @@ test('coverageReport lists backticked terms missing from the glossary, not those const rep = coverageReport(lessons, idx); assert.deepEqual(rep, [{ term: 'Weak', count: 1 }]); }); + +import { + THEME, renderTagChips, renderLessonPage, renderStylesheet, renderGlossaryScript, +} from './build.mjs'; + +const PARSED = { + pr: 16, slug: 'supervised-app-with-logs', + title: 'PR #16 — Tracer: supervised app with logs', + lesson: 'Hand the task an `Arc>` clone.', + tags: ['Arc>', 'tokio::spawn'], + merged: { date: '2026-06-08', delta: '+2369/−0', url: 'https://github.com/nonrational/adjacent/pull/16' }, + bodyMarkdown: '## The situation\n\nBoot it with an `Arc`.', +}; +const IDX = buildGlossaryIndex({ + arc: { term: 'Arc', aliases: ['Arc', 'Arc>'], prereqs: [] }, +}); + +test('renderTagChips: a glossary tag becomes a term button, others plain', () => { + const html = renderTagChips(['Arc>', 'tokio::spawn'], IDX); + assert.match(html, /data-term="arc"[^>]*>[^<]*Arc<Mutex<T>><\/code>/); + assert.match(html, /tokio::spawn/); +}); + +test('renderLessonPage: full document with provenance header and asset links', () => { + const html = renderLessonPage(PARSED, { glossaryIndex: IDX }); + assert.ok(html.startsWith(' + + + + + + PR #16 — Tracer: supervised app with logs + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #16 — Tracer: supervised app with logs

    +
    +

    To mutate state from an async task that outlives the function that spawned it, hand the task an clone — gives shared ownership across threads, the makes the mutation safe.

    +

    tokio::spawn

    +

    2026-06-08 · +2369/−0 · View PR

    +
    +

    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 ().

    is the idiomatic answer to both:

    • — an atomically reference-counted 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.
    • — 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<RefCell<T>> won't compile here: Rc isn't , so tokio::spawn rejects it.

    In this PR

    The supervisor holds all app state behind one shared, locked handle:

    // crates/adj/src/supervisor.rs
    +#[derive(Default)]
    +pub struct Supervisor {
    +    inner: Arc<Mutex<Inner>>,
    +}
    +
    +#[derive(Default)]
    +struct Inner {
    +    apps: HashMap<String, AppRuntime>,
    +}
    +

    up() spawns the child, records it as Running, then detaches a task to wait for the exit:

    // crates/adj/src/supervisor.rs
    +pub async fn up(&self, app_dir: PathBuf, cfg: AppConfig) -> Result<u32> {
    +    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 , not the Inner. The new handle points at the same Mutex<Inner> 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 — one writer at a time, no data race, enforced at compile time.

    Why it matters

    Without , 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 , 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<Mutex<...>> 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

    +
    +
    + + + + diff --git a/ent/lessons/17-inject-port-into-child-env.html b/ent/lessons/17-inject-port-into-child-env.html new file mode 100644 index 0000000..daa51c8 --- /dev/null +++ b/ent/lessons/17-inject-port-into-child-env.html @@ -0,0 +1,54 @@ + + + + + + + PR #17 — Inject PORT into supervised processes + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #17 — Inject PORT into supervised processes

    +
    +

    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.

    +

    tokio::process::Commandbuilder-patternenv-vars

    +

    2026-06-08 · +322/−12 · View PR

    +
    +

    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:

    // 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<String> 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:

    // 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<String>,
    +

    #[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:

    // 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 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 — 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/ent/lessons/19-optional-fields-with-serde.html b/ent/lessons/19-optional-fields-with-serde.html new file mode 100644 index 0000000..c03f9ae --- /dev/null +++ b/ent/lessons/19-optional-fields-with-serde.html @@ -0,0 +1,67 @@ + + + + + + + PR #19 — Env loading: [env] table and env_file pointer + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #19 — Env loading: [env] table and env_file pointer

    +
    +

    Option<T> 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.

    +

    optionserdedeserializeconfig

    +

    2026-06-08 · +509/−1 · View PR

    +
    +

    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<T> — an enum with exactly two variants:

    enum Option<T> {
    +    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<T> — a special case serde already knows about. A missing Option<T> 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:

    // crates/adj/src/registry.rs
    +pub struct AppConfig {
    +    pub name: String,
    +    pub cmd: String,
    +    #[serde(default)]
    +    pub port_env: Option<String>,
    +    /// 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<BTreeMap<String, String>>,
    +    /// 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<String>,
    +}
    +

    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<BTreeMap<String, String>>. 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 Options, and the if let Some(...) is the compiler-enforced "handle both cases":

    // 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
    +};
    +
    // 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

    +
    +
    + + + + diff --git a/ent/lessons/22-serialize-wire-dto-contract.html b/ent/lessons/22-serialize-wire-dto-contract.html new file mode 100644 index 0000000..41841dc --- /dev/null +++ b/ent/lessons/22-serialize-wire-dto-contract.html @@ -0,0 +1,88 @@ + + + + + + + PR #22 — Agent JSON output on read commands + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #22 — Agent JSON output on read commands

    +
    +

    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.

    +

    serdeSerializewire-contract

    +

    2026-06-08 · +1071/−38 · View PR

    +
    +

    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:

    // 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<String>,
    +    },
    +    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:

    // 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<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
    +        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:

    // 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:

    // 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 () — this same PR reuses that pattern to write the JSONL: the LogWriter wraps the file in Arc<Mutex<tokio::fs::File>> so the stdout and stderr reader tasks can append LogRecords 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<S: Serializer> is generic over the output format; that's the trait-bound machinery #40 covers, applied to serde's serializer.

    Dig deeper

    +
    +
    + + + + diff --git a/ent/lessons/24-single-flight-boot-gate.html b/ent/lessons/24-single-flight-boot-gate.html new file mode 100644 index 0000000..4018171 --- /dev/null +++ b/ent/lessons/24-single-flight-boot-gate.html @@ -0,0 +1,71 @@ + + + + + + + PR #24 — Reverse proxy + lazy-boot + hostname routing + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #24 — Reverse proxy + lazy-boot + hostname routing

    +
    +

    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.

    +

    single-flightper-key-mutex

    +

    2026-06-08 · +905/−0 · View PR

    +
    +

    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 guarding all boots would serialize unrelated apps: booting site would block the first request to blog. Instead we keep a HashMap<name, Arc<Mutex<()>>> — one lock per app name. Booting site and blog proceed independently; only concurrent boots of the same name serialize.
    1. 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 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 ; each entry is an Arc<Mutex<()>> you can clone out and hold on its own:

    // crates/adj/src/proxy.rs
    +#[derive(Default)]
    +pub struct BootGate {
    +    locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
    +}
    +
    +impl BootGate {
    +    async fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
    +        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 , 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:

    // crates/adj/src/proxy.rs
    +async fn ensure_running(/* ... */) -> Result<u16, ProxyError> {
    +    // 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<Mutex<()>>. 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 pattern this builds on — shared ownership across tokio::spawned tasks. Read that first; this lesson is the single-flight variation layered on top.

    Dig deeper

    • tokio::sync::Mutex — 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/ent/lessons/29-deadlines-and-failure-enums.html b/ent/lessons/29-deadlines-and-failure-enums.html new file mode 100644 index 0000000..2ebde07 --- /dev/null +++ b/ent/lessons/29-deadlines-and-failure-enums.html @@ -0,0 +1,87 @@ + + + + + + + PR #29 — Readiness URL, wait-ready, and idle shutdown + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #29 — Readiness URL, wait-ready, and idle shutdown

    +
    +

    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.

    +

    tokio-timeoutdeadlineserror-enum

    +

    2026-06-08 · +1102/−56 · View PR

    +
    +

    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<T, Elapsed>: 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<u16, anyhow::Error>, 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 matches 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.

    // 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:

    // crates/adj/src/readiness.rs
    +pub async fn wait_ready(
    +    name: &str,
    +    supervisor: &Supervisor,
    +    cfg: &AppConfig,
    +    deadline: tokio::time::Instant,
    +) -> Result<u16, ReadinessError> {
    +    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:

    // crates/adj/src/readiness.rs
    +    tokio::time::timeout(Duration::from_millis(500), attempt)
    +        .await
    +        .ok()
    +        .flatten()
    +        .unwrap_or(false)
    +

    timeout(...) yields Result<Option<bool>, Elapsed>. .ok() drops the Elapsed into Option<Option<bool>>, .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:

    // 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<Supervisor> 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 — the future-vs-timer race and its Result<T, Elapsed> return.
    • 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/ent/lessons/34-fix-landing-page-redirect.html b/ent/lessons/34-fix-landing-page-redirect.html new file mode 100644 index 0000000..495280d --- /dev/null +++ b/ent/lessons/34-fix-landing-page-redirect.html @@ -0,0 +1,40 @@ + + + + + + + PR #34 — Fix landing-page redirect (adj.ac/ent) + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #34 — Fix landing-page redirect (adj.ac/ent)

    +
    +

    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 <meta http-equiv="refresh"> bounce works, plus why the / in adj.ac/ent is a real URL path boundary rather than decoration.

    +

    redirectsstatic-sitehtml

    +

    2026-06-08 · +96/−5 · View PR

    +
    +

    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 <head> that the browser parses and acts on.

    <!-- index.html (apex) -->
    +<meta http-equiv="refresh" content="0; URL='/ent/'">
    +<link rel="canonical" href="https://adj.ac/ent/">
    +

    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:

    <!-- ent/index.html -->
    +<h1 class="wordmark"><span class="a">adj.ac</span><span class="slash">/</span><span class="b">ent</span></h1>
    +

    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

    +
    +
    + + + + diff --git a/ent/lessons/36-docs-as-config-contract.html b/ent/lessons/36-docs-as-config-contract.html new file mode 100644 index 0000000..218714e --- /dev/null +++ b/ent/lessons/36-docs-as-config-contract.html @@ -0,0 +1,48 @@ + + + + + + + PR #36 — Document `health_check_url`, `idle_timeout`, and `adj wait-ready` in README + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #36 — Document `health_check_url`, `idle_timeout`, and `adj wait-ready` in README

    +
    +

    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.

    +

    docs-as-contractdoctests

    +

    2026-06-08 · +8/−1 · View PR

    +
    +

    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:

    # 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:

    - 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:

    /// 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<Duration, ParseError> { /* ... */ }
    +

    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

    +
    +
    + + + + diff --git a/ent/lessons/37-signals-bypass-drop-cleanup.html b/ent/lessons/37-signals-bypass-drop-cleanup.html new file mode 100644 index 0000000..067ec85 --- /dev/null +++ b/ent/lessons/37-signals-bypass-drop-cleanup.html @@ -0,0 +1,53 @@ + + + + + + + PR #37 — Handle SIGTERM in daemon socket cleanup + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #37 — Handle SIGTERM in daemon socket cleanup

    +
    +

    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.

    +

    tokio-signaldropgraceful-shutdown

    +

    2026-06-08 · +15/−4 · View PR

    +
    +

    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<Signal> — 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

    // 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 returns 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<Mutex<…>> sharing pattern; the same socket.clone() + 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 — ctrl_c, the Unix SignalKind / Signal::recv stream API, and the caveats about async-signal-safety.
    • The Rust Book, ch. 15.3 — Running Code on Cleanup with the Drop Trait (what Drop guarantees — and, by omission, what it doesn't).
    +
    +
    + + + + diff --git a/ent/lessons/38-deterministic-async-tests.html b/ent/lessons/38-deterministic-async-tests.html new file mode 100644 index 0000000..1dd450b --- /dev/null +++ b/ent/lessons/38-deterministic-async-tests.html @@ -0,0 +1,54 @@ + + + + + + + PR #38 — Fix flaky proxy single-flight test and add CI workflow + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #38 — Fix flaky proxy single-flight test and add CI workflow

    +
    +

    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.

    +

    #[tokio::test]test-determinism

    +

    2026-06-08 · +45/−16 · View PR

    +
    +

    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:

    // 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.

    // 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:

    // 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<Mutex> sharing that lets the supervisor state and boot gate be read from many concurrent request tasks at once.

    Dig deeper

    +
    +
    + + + + diff --git a/ent/lessons/40-serve-https-local-ca.html b/ent/lessons/40-serve-https-local-ca.html new file mode 100644 index 0000000..5dc69e7 --- /dev/null +++ b/ent/lessons/40-serve-https-local-ca.html @@ -0,0 +1,65 @@ + + + + + + + PR #40 — Serve HTTPS on :8443 via opt-in local CA + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #40 — Serve HTTPS on :8443 via opt-in local CA

    +
    +

    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.

    +

    genericstrait-bounds

    +

    2026-06-08 · +1242/−49 · View PR

    +
    +

    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::<TcpStream> and serve_plain::<TlsStream<TcpStream>> 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.
    • — 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:

    // 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<S>(stream: S, sup: Arc<Supervisor>, gate: Arc<BootGate>)
    +where
    +    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
    +{
    +    let io = TokioIo::new(stream);
    +    let service = service_fn(move |req: Request<Incoming>| {
    +        // ... 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:

    // 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:

    // 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<TcpStream>
    +

    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 , 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

    +
    +
    + + + + diff --git a/ent/lessons/41-raw-string-templated-doc.html b/ent/lessons/41-raw-string-templated-doc.html new file mode 100644 index 0000000..0d4d404 --- /dev/null +++ b/ent/lessons/41-raw-string-templated-doc.html @@ -0,0 +1,59 @@ + + + + + + + PR #41 — Add `adj agent-instructions` command + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #41 — Add `adj agent-instructions` command

    +
    +

    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.

    +

    raw-string-literalsformat-macrostring-templating

    +

    2026-06-08 · +783/−0 · View PR

    +
    +

    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):

    // 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:

    // crates/adj/src/agent_docs.rs
    +pub fn emit(path: Option<String>) -> 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

    +
    +
    + + + + diff --git a/ent/lessons/43-trait-as-extension-point.html b/ent/lessons/43-trait-as-extension-point.html new file mode 100644 index 0000000..246a978 --- /dev/null +++ b/ent/lessons/43-trait-as-extension-point.html @@ -0,0 +1,71 @@ + + + + + + + PR #43 — Move CA private key into the macOS Keychain and add nameConstraints + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #43 — Move CA private key into the macOS Keychain and add nameConstraints

    +
    +

    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.

    +

    trait-implffi

    +

    2026-06-09 · +682/−38 · View PR

    +
    +

    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. 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<dyn RemoteKeyPair>, 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:

    // 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<Vec<u8>, 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<Vec<u8>, 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:

    // crates/adj/src/tls/keychain.rs
    +pub fn into_rcgen_keypair(self) -> Result<KeyPair> {
    +    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:

    // 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:

    // 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:

    // 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 — 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

    +
    +
    + + + + diff --git a/ent/lessons/45-iterator-map-collect-rows.html b/ent/lessons/45-iterator-map-collect-rows.html new file mode 100644 index 0000000..1bc1921 --- /dev/null +++ b/ent/lessons/45-iterator-map-collect-rows.html @@ -0,0 +1,49 @@ + + + + + + + PR #45 — Add built-in dashboard at status.adj.ac + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #45 — Add built-in dashboard at status.adj.ac

    +
    +

    iter().map(f).collect() is how you turn one collection into another — a lazy pipeline that does nothing until collect drives it.

    +

    iteratorsmap-collect

    +

    2026-06-09 · +817/−0 · View PR

    +
    +

    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<DashboardEntry> and collect fills a Vec. The compiler infers the rest.

    In this PR

    The transform is one line — a pure, synchronous mapping:

    // crates/adj/src/status.rs
    +let dtos: Vec<DashboardEntry> = 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:

    // crates/adj/src/status.rs
    +async fn snapshot(supervisor: Arc<Supervisor>) -> anyhow::Result<Vec<AppSummary>> {
    +    let reg = Registry::load()?;
    +    let mut entries = Vec::with_capacity(reg.apps.len());
    +    for (name, entry) in &reg.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 .awaits 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 pushes 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<Result<T, E>> 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<Supervisor> threaded through snapshot and handle is the Arc<Mutex> sharing pattern.

    Dig deeper

    • The Rust Book, ch. 13.2 — Processing a Series of Items with Iterators (laziness, map, collect).
    • std::fmt::Write — 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/ent/lessons/46-revamp-landing-positioning-page.html b/ent/lessons/46-revamp-landing-positioning-page.html new file mode 100644 index 0000000..4c3a94b --- /dev/null +++ b/ent/lessons/46-revamp-landing-positioning-page.html @@ -0,0 +1,67 @@ + + + + + + + PR #46 — Revamp landing page into a positioning page + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #46 — Revamp landing page into a positioning page

    +
    +

    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.

    +

    repo-layoutweb-frontenddual-license

    +

    2026-06-09 · +341/−24 · View PR

    +
    +

    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:

    # 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 <style> block, no build step. The positioning content is the new hero category line and the two-column problem statement:

    <!-- ent/index.html -->
    +<p class="category">Adjacent is a local dev-server harness &mdash; one supervised
    +  dev server that you and your agent share, behind a real URL.</p>
    +
    +<section class="statement">
    +  <h2>Your dev server has two developers now.</h2>
    +  <div>
    +    <p>When you and your agent need the same local server, you evict each other.
    +      The agent takes the process &mdash; you lose the logs. You take it back
    +      &mdash; the agent can't verify its work.</p>
    +    <p><strong>Adjacent supervises the server so neither of you has to own
    +      it.</strong> One daemon boots, routes and watches every app; both of you
    +      talk to it through the same CLI.</p>
    +  </div>
    +</section>
    +

    The root-level index.html is a one-line redirect so adj.ac/ lands on the real page at adj.ac/ent/ — the /ent path split is the brand pun (adj.ac + ent), enforced by URL structure:

    <!-- index.html -->
    +<meta http-equiv="refresh" content="0; URL='/ent/'">
    +<link rel="canonical" href="https://adj.ac/ent/">
    +

    The one Rust-ecosystem thing in the diff

    Cargo.toml flips the license, and this is a Rust convention worth knowing:

    # Cargo.toml — before / after
    +-license = "MIT"
    ++license = "MIT OR Apache-2.0"
    +

    Rust itself, and the overwhelming majority of crates on crates.io, dual-license under MIT OR Apache-2.0. The SPDX OR means a downstream user picks either license — MIT for simplicity, Apache-2.0 for its explicit patent grant. It's the community default; publishing a crate under bare MIT reads as slightly off-convention. The PR added LICENSE-MIT and LICENSE-APACHE files to match, which is the expected on-disk layout for a dual-licensed Rust project.

    Why it matters

    Keep the frontend out of members and Cargo stays fast and honest: cargo build compiles only Rust, cargo test runs only Rust tests, and a broken bit of CSS can never fail a cargo command. Pull the site into the workspace — say, as a crate with a build.rs that embeds the HTML — and now every landing-page tweak recompiles the binary and CI treats a copy edit like a code change. The sibling directory is the cheap, correct boundary.

    On licensing: MIT OR Apache-2.0 isn't cosmetic. The Apache-2.0 half carries an explicit patent grant that bare MIT lacks, and matching the ecosystem default is what lets other Rust projects depend on yours without a legal review.

    Related lessons

    • The justfile here also bumped just serve to --port=8081 so live-server wouldn't collide with the proxy's default :8080 — a small nod to the exact port-contention problem Adjacent exists to solve.

    Dig deeper

    +
    +
    + + + + diff --git a/ent/lessons/47-integration-tests-separate-crate.html b/ent/lessons/47-integration-tests-separate-crate.html new file mode 100644 index 0000000..405f8bc --- /dev/null +++ b/ent/lessons/47-integration-tests-separate-crate.html @@ -0,0 +1,55 @@ + + + + + + + PR #47 — Document and test the docker run container convention + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #47 — Document and test the docker run container convention

    +
    +

    Every file under crates/adj/tests/ is compiled as its own separate crate that links your code as an outside dependency — so it can only touch the public API, and for a binary-only crate that means driving the compiled executable via CARGO_BIN_EXE_adj.

    +

    integration-testsCARGO_BIN_EXE

    +

    2026-06-10 · +283/−0 · View PR

    +
    +

    The situation

    The PR is honest about being two things: a README section documenting how to run a containerized app under Adjacent, plus one new integration test that proves the convention actually works. The test — crates/adj/tests/docker.rs — lazy-boots traefik/whoami through the proxy, asserts a 200, checks docker ps sees the container, then runs adj down and confirms the container is gone. That last assertion is the one that would catch a signal-forwarding regression.

    To do all that, the test has to start a real adj daemon and talk to it exactly like a user would. That constraint isn't incidental — it falls straight out of how Rust organizes tests.

    The Rust idea

    Rust has two places tests live, and they are not interchangeable:

    1. Unit tests — a #[cfg(test)] mod tests { ... } block inside a source file (e.g. src/supervisor.rs). This module is compiled as part of the same crate, so it can reach private functions, structs, and fields. Use it to test internals.
    1. Integration tests — each file in the top-level tests/ directory. Here's the part that surprises people coming from other languages: Cargo compiles each of these files as its own independent crate, and that crate depends on yours the way any outside consumer would. It can only see pub items. Private internals are invisible to it, on purpose — an integration test proves the public surface works.

    Now the twist that makes adj interesting. crates/adj is a binary-only crate: its Cargo.toml declares a [[bin]] and there's no lib.rs. A binary exposes no library API to an external crate at all — there are no pub fns to call. So the integration test can't link against adj and call functions. Instead it drives the compiled adj executable as a subprocess, the same as a shell would.

    Cargo makes that ergonomic: when it builds the test, it sets an environment variable CARGO_BIN_EXE_<name> to the absolute path of the compiled binary. env!(...) reads it at compile time, so the path is baked in — no guessing at target/debug/, no cargo build ordering to manage.

    In this PR

    The test resolves the binary through that Cargo-provided variable:

    // crates/adj/tests/docker.rs
    +fn adj_bin() -> PathBuf {
    +    PathBuf::from(env!("CARGO_BIN_EXE_adj"))
    +}
    +

    Because it's driving a real binary, it also stands up a real daemon — sandboxed into a throwaway TempDir via ADJACENT_HOME, on a random port via ADJACENT_PROXY_PORT, so the test never touches your actual ~/.adjacent/:

    // crates/adj/tests/docker.rs
    +fn cmd(&self) -> Command {
    +    let mut c = Command::new(adj_bin());
    +    c.env("ADJACENT_HOME", &self.home_path);
    +    c.env("ADJACENT_PROXY_PORT", self.proxy_port.to_string());
    +    c.env("RUST_LOG", "warn");
    +    c.env_remove("PORT");
    +    c.env_remove("BIND_PORT");
    +    c
    +}
    +

    The test itself is async, so it's annotated #[tokio::test] — the async cousin of #[test], which spins up a Tokio runtime around the test body so you can .await inside it (here: awaiting daemon startup, subprocess output, and the container teardown poll):

    // crates/adj/tests/docker.rs
    +#[tokio::test]
    +async fn docker_run_lazy_boots_and_down_stops_the_container() {
    +    if !docker_ready().await {
    +        eprintln!("skipping: no reachable Docker daemon (or {IMAGE} unavailable)");
    +        return;
    +    }
    +    // ... boot through the proxy, assert 200, then `adj down` and confirm gone
    +}
    +

    One more integration-test detail worth naming: this file uses tempfile and tokio, and both are available because Cargo.toml lists them under [dev-dependencies] — dependencies compiled only for tests and examples, never shipped in the release binary.

    Why it matters

    The unit-vs-integration split is a real design decision, not boilerplate. Reaching into private state from a #[cfg(test)] mod tests is convenient, but it tests the code you wrote, not the code your users touch. The separate-crate rule forces the integration layer to go through the front door: for a library that means calling only pub items; for a binary like adj it means shelling out to the actual executable. That's why this test can honestly claim to prove the documented container convention — it exercises the same adj add / proxy / adj down path a developer runs by hand, with nothing mocked and no private shortcut.

    Skip the CARGO_BIN_EXE_<name> variable and you'd hardcode target/debug/adj, which breaks under --release, breaks on the CI runner's target dir, and silently runs a stale binary if you forget to rebuild. Letting Cargo hand you the path removes all three traps.

    Related lessons

    • PR #38 works inside this same tests/ directory but teaches a different axis: making an async integration test deterministic (killing flakiness). This lesson is the layout — where tests live and what they can see; #38 is how to make one reliable once it's there.
    • PR #16 stood up the supervisor this test ultimately drives; the daemon it boots is the Arc<Mutex<...>> machinery taught there, exercised end-to-end from outside.

    Dig deeper

    • The Rust Book, ch. 11.3 — Test Organization (the unit-vs-integration split, the tests/ directory, and testing binary crates)
    +
    +
    + + + + diff --git a/ent/lessons/48-warn-dont-swallow-errors.html b/ent/lessons/48-warn-dont-swallow-errors.html new file mode 100644 index 0000000..a0157b8 --- /dev/null +++ b/ent/lessons/48-warn-dont-swallow-errors.html @@ -0,0 +1,61 @@ + + + + + + + PR #48 — Warn instead of silently defaulting on idle_timeout parse failure + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #48 — Warn instead of silently defaulting on idle_timeout parse failure

    +
    +

    A Result is a value you must consume, but combinators like .unwrap_or(default) let you throw the Err away — reach for .unwrap_or_else(|err| …) when the error is worth surfacing before you fall back.

    +

    resulterror-handlingcombinators

    +

    2026-06-13 · +75/−2 · View PR

    +
    +

    The situation

    A bad idle_timeout string in adjacent.toml (say "10x") used to fall back to the 15-minute default without a peep. idle_timeout_for called .unwrap_or(Some(DEFAULT_IDLE_TIMEOUT)) on the parse Result, discarding the parse error. Today that path is unreachable — read_app_config validates eagerly — but a future caller that skips validation would turn a config typo into a silent default. This PR keeps the fallback but makes it observable.

    The Rust idea

    Rust has no exceptions. A function that can fail returns Result<T, E> — an enum with two variants, Ok(T) and Err(E). The failure is an ordinary value the caller holds in their hand, and the type system won't let you read the T without first acknowledging the Err might be there. That is the whole safety story: fallibility is visible in the signature and unavoidable at the call site.

    But "unavoidable" is not the same as "surfaced." The standard library ships combinators that collapse a Result down to a plain value, and some of them drop the error on the floor:

    • .unwrap_or(default) — returns the Ok value, or default on Err. The E is never bound to anything; it's gone.
    • .ok() — turns Result<T, E> into Option<T>, discarding E.
    • let Ok(x) = … else { … } — the else arm can't see the error either.

    Each one is a legitimate tool and each one silently swallows the Err. The compiler is happy — you did handle the Result — so the type system stops nudging you the moment you pick a combinator that ignores the payload. Swallowing an error is a code smell precisely because it looks identical to handling it.

    The fix is the lazy sibling: .unwrap_or_else(|err| …) takes a closure that receives the Err value. Now the error is bound to err, and you can log it, count it, or enrich it before returning your fallback. Same default, but the failure is no longer invisible.

    (_else also means the fallback is computed lazily — the closure only runs on Err — but here the real win is getting your hands on err.)

    In this PR

    // crates/adj/src/registry.rs
    +
    +// before:
    +Some(s) => parse_idle_timeout(s).unwrap_or(Some(DEFAULT_IDLE_TIMEOUT)),
    +
    +// after:
    +// Unreachable when callers go through `read_app_config`, which validates eagerly.
    +// Warn loudly anyway so a future code path that skips validation can't turn a
    +// config typo into a silent default.
    +Some(s) => parse_idle_timeout(s).unwrap_or_else(|err| {
    +    tracing::warn!(
    +        idle_timeout = s,
    +        error = %err,
    +        default = ?DEFAULT_IDLE_TIMEOUT,
    +        "invalid idle_timeout; falling back to default"
    +    );
    +    Some(DEFAULT_IDLE_TIMEOUT)
    +}),
    +

    The signature never changed — idle_timeout_for still returns Option<Duration>, so nothing downstream in supervisor.rs has to grow an error path. The only difference is that the err from parse_idle_timeout now flows into the closure instead of being dropped. %err formats it with Display, ?DEFAULT_IDLE_TIMEOUT with Debug — both are tracing's field syntax for capturing structured values.

    The test pins the one observable change. Since the returned value is identical before and after, the assertion has to capture the log output:

    // crates/adj/src/registry.rs
    +let resolved = tracing::subscriber::with_default(subscriber, || {
    +    idle_timeout_for(&cfg_with_idle_timeout(Some("10x")))
    +});
    +
    +assert_eq!(resolved, Some(DEFAULT_IDLE_TIMEOUT));
    +let logs = String::from_utf8(capture.0.lock().unwrap().clone()).unwrap();
    +assert!(
    +    logs.contains("invalid idle_timeout") && logs.contains("10x") && logs.contains("900s"),
    +    "expected a warn naming the bad value and the default it fell back to, got: {logs}"
    +);
    +

    Why it matters

    In a language with exceptions, the equivalent bug is a bare catch (Exception e) {} — the swallowed-error smell everyone recognizes. Rust doesn't force you to write that; it hands you a tidy one-liner (.unwrap_or) that does exactly the same thing and reads as intentional. The lesson is that Rust guarantees you considered the Result, not that you surfaced it. When the fallback is a real recovery worth keeping quiet about, .unwrap_or is fine. When the Err means "someone made a mistake" — a config typo, a malformed input — reach for .unwrap_or_else and at least say so on the way down.

    Related lessons

    • PR #49 tightens the producer side of this same function neighborhood: it makes parse_idle_timeout return a precise Err (rejecting zero durations with a message pointing at off). #48 is about not swallowing that Err; #49 is about making it worth reading.

    Dig deeper

    +
    +
    + + + + diff --git a/ent/lessons/49-parse-dont-validate-actionable-errors.html b/ent/lessons/49-parse-dont-validate-actionable-errors.html new file mode 100644 index 0000000..f9b3016 --- /dev/null +++ b/ent/lessons/49-parse-dont-validate-actionable-errors.html @@ -0,0 +1,56 @@ + + + + + + + PR #49 — Reject zero idle_timeout with a pointer to off + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #49 — Reject zero idle_timeout with a pointer to off

    +
    +

    Push validation into the parser so an invalid value can't exist downstream — and spend the extra line making the Err message tell the user what to do instead.

    +

    parse-dont-validateerror-handlingresult

    +

    2026-06-13 · +21/−0 · View PR

    +
    +

    The situation

    idle_timeout = "0s" used to parse cleanly into a zero Duration. That value then flowed into the idle scanner, which compares each app's idle age against its timeout every 500ms — a zero window means the app is always past its timeout, so it gets SIGTERM'd on the very next tick. The app quietly stops itself forever. This PR makes parse_idle_timeout reject zero at the door, and point the user at the thing they almost certainly meant: "off".

    The Rust idea

    "Parse, don't validate." There are two ways to handle an untrusted input.

    • Validate: accept it into your normal type (a Duration), then check if dur.is_zero() at each place that cares. The type says nothing — every consumer has to remember the rule, and a new call site can forget it.
    • Parse: do the check once, at the boundary, and return a type that cannot represent the bad state. Downstream code holds a value that is correct by construction.

    parse_idle_timeout returns Result<Option<Duration>>, and that type already encodes three distinct outcomes:

    • Err(_) — the input was malformed or nonsensical.
    • Ok(None) — idle shutdown is explicitly disabled ("off").
    • Ok(Some(d)) — a real, positive timeout.

    Before this PR, a fourth state leaked through: Ok(Some(Duration::ZERO)), a "valid" timeout that behaves like a self-destruct. Moving the is_zero() check inside the parser deletes that state. After the function returns Ok(Some(d)), every caller can trust d is non-zero without checking — the scanner never has to defend against a zero window, because one can't reach it.

    The second half of the lesson is the Err itself. Rust's Result makes failure a value you must handle, but it says nothing about the quality of the error. Err(anyhow!("invalid duration")) is technically a failure and practically useless. An actionable message names what went wrong and what to do instead.

    In this PR

    The check sits at the end of the parser, right before the only Ok(Some(...)) return:

    // crates/adj/src/registry.rs
    +if dur.is_zero() {
    +    return Err(anyhow!(
    +        "idle_timeout of zero would stop the app on every idle scan; use `off` to disable idle shutdown"
    +    ));
    +}
    +Ok(Some(dur))
    +

    That message is the whole point. It has two clauses: the consequence ("would stop the app on every idle scan") so the user understands why zero is refused, and the fix ("use off to disable idle shutdown") so they know the exact next keystroke. anyhow! is the anyhow crate's macro for building an ad-hoc error from a format string — the same ergonomics as format!, but it produces an Err value.

    The doc comment is updated to explain the why for the next reader, not just the what:

    // crates/adj/src/registry.rs
    +/// Zero durations are rejected: a zero window would make the app a permanent shutdown
    +/// candidate (stopped on every scan tick), and users writing `"0s"` almost always mean
    +/// "disable" — which is what `"off"` does.
    +

    The test pins the contract — every zero unit is rejected, and the message steers toward off:

    // crates/adj/src/registry.rs
    +#[test]
    +fn rejects_zero_and_points_at_off() {
    +    for raw in ["0s", "0ms", "0m", "0h"] {
    +        let err = parse_idle_timeout(raw).unwrap_err();
    +        assert!(
    +            err.to_string().contains("use `off`"),
    +            "error for {:?} should suggest `off`, got: {}",
    +            raw,
    +            err
    +        );
    +    }
    +}
    +

    Note what it asserts: not the whole string (brittle), but that the fix — use off`` — is present. The test guards the actionable part of the message specifically.

    Why it matters

    Because the check lives in the parser and read_app_config calls it eagerly, the error surfaces at adj add / adj up time — while the user is looking at their config — instead of manifesting hours later as a server that mysteriously won't stay up. That's the payoff of parse-don't-validate: the failure appears at the boundary, with full context, once. A language that let you pass a raw Duration around and hope every consumer re-checks it would push that failure deep into the runtime, far from the typo that caused it.

    Related lessons

    • PR #48 is the mirror image — the consumer side of this exact function. #48 is about not swallowing the Err that parse_idle_timeout returns (reach for .unwrap_or_else over .unwrap_or); #49 is about making that Err precise and worth reading in the first place. A good error is a handshake: the producer writes something actionable, the consumer bothers to surface it.
    • PR #29 covered error enums (giving failures distinct types). #49 is the complementary skill: even a stringly-typed anyhow! error earns its keep when the message tells the user what to do.

    Dig deeper

    • Parse, don't validate — the canonical essay. Written for Haskell, but the core move (make illegal states unrepresentable by choosing return types that exclude them) is exactly what the Result<Option<Duration>> signature does here.
    • The Rust Book, ch. 9 — Error Handling: Result, the ? operator, and recoverable-vs-unrecoverable failure.
    +
    +
    + + + + diff --git a/ent/lessons/50-weak-refs-self-cleaning-map.html b/ent/lessons/50-weak-refs-self-cleaning-map.html new file mode 100644 index 0000000..cc35018 --- /dev/null +++ b/ent/lessons/50-weak-refs-self-cleaning-map.html @@ -0,0 +1,57 @@ + + + + + + + PR #50 — Proxy follow-ups: deadline timing, BootGate GC, NAT-replacement warning + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #50 — Proxy follow-ups: deadline timing, BootGate GC, NAT-replacement warning

    +
    +

    Store Weak references as a map's values so the map garbage-collects itself — an entry lives only while some in-flight boot still holds the strong , and HashMap::retain sweeps the dead ones in one in-place pass.

    +

    weak-referenceshashmap-retain

    +

    2026-06-13 · +48/−6 · View PR

    +
    +

    The situation

    The proxy's BootGate keeps one lock per app name so concurrent first-requests boot the app exactly once (that mechanism is PR #24). But the original map held an Arc<Mutex<()>> per name, and nothing ever removed entries. Every name the daemon ever routed left a permanent row in the map. The daemon is long-lived, and there is no app-removal RPC to hook a cleanup call onto — so the map only ever grew. A slow leak, but a leak. This PR makes the map bound itself to boots that are actually in flight.

    The Rust idea

    is a strong reference: as long as one exists, the value stays alive. Weak<T> is a non-owning reference to the same value — it points at the data but does not count toward keeping it alive. When the last drops, the value is freed even if Weaks still point at where it used to be.

    Because a Weak might be dangling, you can't dereference it directly. You call .upgrade(), which returns Option<Arc<T>>: Some if the value is still alive (and hands you a fresh strong ref), None if it's gone. That Option is Rust forcing you to handle "the thing I referenced no longer exists" — there is no way to accidentally touch freed memory.

    Two more pieces the diff uses:

    • Arc::downgrade(&arc) mints a Weak from an — that's what you store in the map.
    • weak.strong_count() asks how many strong s are still out there. Zero means the value is dead and the entry is garbage.
    • HashMap::retain(|k, v| bool) walks every entry once and drops those where the closure returns false, editing the map in place — no rebuild, no second allocation.

    Put together: store Weak values, and an entry becomes collectable the moment its last drops. retain(|_, w| w.strong_count() > 0) is the sweep.

    In this PR

    The map's value type flips from to Weak:

    // crates/adj/src/proxy.rs
    +#[derive(Default)]
    +pub struct BootGate {
    +    locks: Mutex<HashMap<String, std::sync::Weak<Mutex<()>>>>,
    +}
    +

    And lock_for prunes on the way in, then either upgrades the surviving entry or mints a fresh lock:

    // crates/adj/src/proxy.rs
    +async fn lock_for(&self, name: &str) -> Arc<Mutex<()>> {
    +    let mut map = self.locks.lock().await;
    +    map.retain(|_, weak| weak.strong_count() > 0);
    +    if let Some(existing) = map.get(name).and_then(std::sync::Weak::upgrade) {
    +        return existing;
    +    }
    +    let lock = Arc::new(Mutex::new(()));
    +    map.insert(name.to_string(), Arc::downgrade(&lock));
    +    lock
    +}
    +

    Trace the lifetime. lock_for returns an — the caller in ensure_running holds it (let name_lock = gate.lock_for(name).await;) for the whole boot. So while a boot is in flight, at least one strong exists, strong_count() is > 0, and the map entry survives retain. Concurrent requests for the same name upgrade() that same live Weak and get the same back — single-flight identity is preserved (the new unit test asserts Arc::ptr_eq(&lock_a, &lock_a2)). Once the boot finishes and every caller drops its , strong_count() falls to zero, and the next lock_for call sweeps the dead entry. The map is now bounded by the number of boots happening right now, not by every name ever seen.

    The new test spells out the whole arc — hold two locks, confirm neither is pruned while alive, drop them all, then confirm the next lookup collects them:

    // crates/adj/src/proxy.rs
    +drop(lock_a);
    +drop(lock_a2);
    +drop(lock_b);
    +// All strong refs gone — the next lock_for sweeps the dead entries.
    +let _lock_c = gate.lock_for("c").await;
    +let map = gate.locks.lock().await;
    +assert_eq!(map.len(), 1);
    +assert!(map.contains_key("c"));
    +

    Why it matters

    The trap is thinking of a map as a passive container and forgetting it's a strong owner. In the original code the HashMap held s, so those apps could never be freed — the map itself was the reason they stayed alive. This is exactly the situation a garbage collector can't rescue you from either: a GC won't collect an object the map still strongly references, so a Java HashMap here would leak the same way. The tool for "reference it, but don't be the reason it lives" is a weak reference — Weak<T> in Rust, WeakHashMap in Java. Reaching for it turns an ever-growing map into a self-cleaning one, and the safety of upgrade() returning an Option means the "it might be gone" case is impossible to forget.

    Related lessons

    • PR #24 owns the single-flight boot itself — the per-name lock and the double-check that make concurrent first-requests boot an app exactly once. This lesson is the follow-up that stops the lock map from growing forever; read #24 first for what the map is for.
    • Same PR, second thread (monotonic time): ensure_running now captures its boot deadline as let deadline = tokio::time::Instant::now() + boot_timeout; before calling up(), so a slow spawn counts against the boot budget instead of being added on top of it. Instant is a monotonic clock — it only moves forward and is immune to wall-clock jumps (NTP steps, DST), which is why it's the right type for a deadline. Prefer it over SystemTime for measuring elapsed time, and reach for saturating_duration_since / checked arithmetic when subtracting two Instants so a reordering can't underflow.

    Dig deeper

    +
    +
    + + + + diff --git a/ent/lessons/51-typed-http-header-values.html b/ent/lessons/51-typed-http-header-values.html new file mode 100644 index 0000000..43d7e35 --- /dev/null +++ b/ent/lessons/51-typed-http-header-values.html @@ -0,0 +1,59 @@ + + + + + + + PR #51 — Forward X-Forwarded-* headers from the proxy + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #51 — Forward X-Forwarded-* headers from the proxy

    +
    +

    In the http crate a header value is a HeaderValue — a validated byte-string newtype, not a String — so building one is fallible (HeaderValue::from_str returns a Result), and that fallibility is how the type system blocks header injection.

    +

    newtypestyped-headersfallible-construction

    +

    2026-06-14 · +247/−13 · View PR

    +
    +

    The situation

    The proxy rewrites the incoming Host header to 127.0.0.1:<port> so upstream dev servers with host-allowlist checks accept the request. That rewrite erases the browser's real origin. This PR restores it with the three standard reverse-proxy headers — X-Forwarded-Host, X-Forwarded-For, X-Forwarded-Proto — so an app can still reconstruct where the request actually came from. To set them, the code has to construct header values, and that is where Rust's types show up.

    The Rust idea

    The http crate (re-exported through hyper) does not model headers as HashMap<String, String>. Keys are HeaderName, values are HeaderValue, and the collection is HeaderMap. A HeaderValue is a newtype wrapping validated bytes: a header value may not contain a carriage return, line feed, or NUL. Those are exactly the bytes an attacker would use to split one header into two — the classic header/response-injection attack. Encoding the rule in a type makes the illegal value unrepresentable: the only way to get a HeaderValue is through a constructor that checks.

    So construction is fallible. HeaderValue::from_str(s) returns Result<HeaderValue, InvalidHeaderValue> — hand it a string with a stray \r\n and you get Err, never a poisoned value. When the input is a compile-time constant you already know is legal, HeaderValue::from_static("http") skips the runtime check and hands back a HeaderValue directly (no Result to unwrap; it only panics if you wrote an illegal literal).

    Reading is fallible too. HeaderValue::to_str() returns a Result, because the wrapped bytes are opaque and aren't guaranteed to be valid UTF-8 — a HeaderValue is not a Rust String, so getting a &str back out can fail.

    Contrast a String-typed header map in Python, JS, or Go: nothing stops you writing a newline into a value, and the injection surfaces (if ever) as a runtime security bug. Rust makes the unsafe value impossible to build in the first place.

    In this PR

    The whole helper, from crates/adj/src/proxy.rs:

    // crates/adj/src/proxy.rs
    +fn set_forwarded_headers(
    +    headers: &mut hyper::HeaderMap,
    +    original_host: &str,
    +    client_ip: IpAddr,
    +    proto: &'static str,
    +) {
    +    if let Ok(v) = hyper::header::HeaderValue::from_str(original_host) {
    +        headers.insert("x-forwarded-host", v);
    +    }
    +    // ...
    +    let mut chain: Vec<String> = headers
    +        .get_all("x-forwarded-for")
    +        .iter()
    +        .filter_map(|v| v.to_str().ok())
    +        .map(str::to_string)
    +        .collect();
    +    chain.push(client_ip.to_string());
    +    let xff = chain.join(", ");
    +    if let Ok(v) = hyper::header::HeaderValue::from_str(&xff) {
    +        headers.insert("x-forwarded-for", v);
    +    }
    +    headers.insert(
    +        "x-forwarded-proto",
    +        hyper::header::HeaderValue::from_static(proto),
    +    );
    +}
    +

    Three constructions, three different type stories:

    • from_str(original_host) is fallible, so it's wrapped in if let Ok(v). A malformed Host produces Err, the insert is skipped, and the request still forwards — the bad bytes never reach the socket.
    • proto is "http" or "https", both compile-time-safe, so it uses from_static — no Result. That's also why the parameter is typed proto: &'static str: from_static demands a &'static str, and the accept loops thread in string literals to satisfy it.
    • Reading the existing chain uses .to_str().ok(). to_str is the read-side Result; .ok() inside filter_map drops any value that isn't valid UTF-8 instead of blowing up.

    One HeaderMap detail worth naming: insert replaces every existing value for a name, while append adds another line — a HeaderMap is a multimap that can hold several values under one key. The helper reads them all with get_all (not get, which would see only the first line), joins them, then inserts once to collapse the chain back to a single line.

    Why it matters

    Header injection is the trap this avoids. Picture the string-concatenation version: "X-Forwarded-Host: " + original_host written straight to the socket. A Host of evil\r\nX-Admin: true smuggles in a whole extra header. HeaderValue::from_str rejects the \r\n before it can ever become a value, and the diff's if let Ok(v) turns that rejection into "quietly skip the header," never "forward the attack." Nobody had to remember to sanitize — the type simply refuses to hold the dangerous bytes.

    Related lessons

    • PR #40 introduced the generic serve_plain that this PR threads client_ip and proto through — same file, same serve loop.
    • PR #48 is the same "a Result is a value you must handle" discipline, one layer up; here the fallible values are HeaderValue::from_str and .to_str().

    Dig deeper

    +
    +
    + + + + diff --git a/ent/lessons/52-abort-detached-tokio-tasks.html b/ent/lessons/52-abort-detached-tokio-tasks.html new file mode 100644 index 0000000..dc9fe5c --- /dev/null +++ b/ent/lessons/52-abort-detached-tokio-tasks.html @@ -0,0 +1,64 @@ + + + + + + + PR #52 — Abort the readiness probe's connection task when the probe resolves + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #52 — Abort the readiness probe's connection task when the probe resolves

    +
    +

    tokio::spawn returns a JoinHandle, and dropping that handle detaches the task so it keeps running on its own — to stop a background task once you're done with it, hold the handle and call .abort() (a no-op if it already finished), and for the leak-proof version let a Drop guard fire the abort on every exit path.

    +

    tokio-spawnjoin-handle-aborttask-cancellation

    +

    2026-06-14 · +86/−5 · View PR

    +
    +

    The situation

    The HTTP readiness probe (added in #29) opens a TCP connection to the app and speaks HTTP/1 through hyper. Hyper's client splits into two halves: a sender you push the request into, and a conn future that must be driven to actually pump bytes on the socket. The idiom is to tokio::spawn that conn future so it runs in the background while you await the response on sender.

    But the whole probe is wrapped in a 500ms tokio::time::timeout. If the timer wins, the attempt future is dropped mid-flight — and that spawned connection task, which owns a real TCP socket, is left running with nobody watching it. This PR keeps a handle to that task and aborts it once the probe resolves, timeout or not, so the task and its socket can't outlive the probe.

    The Rust idea

    Two facts a newcomer needs about spawned tasks.

    *1. Dropping a JoinHandle does not stop the task — it detaches it. tokio::spawn(fut) hands the future to the runtime and returns a JoinHandle<T>. Await the handle and you get the task's output. But here's the surprise if you're coming from threads, or from "a future is cancelled when you drop it": dropping the handle* to a spawned task doesn't cancel anything. The task keeps running to completion on its own. A bare tokio::spawn(...) that throws the handle away is fire-and-forget — fine for work that finishes by itself, a leak for work that might not.

    2. Cancellation is explicit: JoinHandle::abort(). It doesn't kill the task synchronously. It flags the task so the runtime drops it at its next .await point (or never starts it, if it hadn't run yet). Dropping the task runs the destructors of everything it owns — here, the hyper conn and its TCP socket. And abort() on an already-finished task is a harmless no-op, so calling it unconditionally after the probe is always safe: either it reclaims a lingering task, or it does nothing.

    The subtle part is guaranteeing the abort runs. If you call it by hand, every exit path has to reach that call — every early return, every ?, every panic. Miss one and the task leaks on that path. The RAII fix is the same one the language uses for locks and files: wrap the handle in a tiny struct whose Drop impl calls abort(), so the compiler runs it at scope exit on every path.

    // Sketch — the general RAII form (not the exact shape #52 shipped; see below).
    +struct AbortOnDrop(tokio::task::JoinHandle<()>);
    +impl Drop for AbortOnDrop {
    +    fn drop(&mut self) {
    +        self.0.abort();
    +    }
    +}
    +

    Drop running on every normal exit is exactly what makes this leak-proof — and it's the flip side of PR #37, which is the deep dive on Drop's reach and its one blind spot (a signal walks right past it).

    In this PR

    The handle is parked on a slot declared outside both the attempt future and the timeout, so it survives even when the timer drops attempt mid-flight. Option because the spawn happens partway through attempt — the connect might fail with ? before we ever spawn, so the slot is Some only once we got far enough to have a task.

    // crates/adj/src/readiness.rs
    +let mut conn_task: Option<tokio::task::JoinHandle<()>> = None;
    +let attempt = async {
    +    let addr = SocketAddr::from(([127, 0, 0, 1], port));
    +    let stream = TcpStream::connect(addr).await.ok()?;
    +    let io = TokioIo::new(stream);
    +    let (mut sender, conn) = client_http1::handshake::<_, Empty<Bytes>>(io).await.ok()?;
    +    conn_task = Some(tokio::spawn(async move {
    +        let _ = conn.await;
    +    }));
    +    // ... build + send the request, read the status, drain the body ...
    +    Some(status.is_success())
    +};
    +

    Parking the handle outside the timed-out future is the crux: the thing that owns the task has to outlive the thing that might get cancelled. Then, once the probe has resolved one way or another, the abort fires unconditionally:

    // crates/adj/src/readiness.rs
    +let ready = tokio::time::timeout(Duration::from_millis(500), attempt)
    +    .await
    +    .ok()
    +    .flatten()
    +    .unwrap_or(false);
    +// Abort unconditionally: a no-op once the task has finished, a cancellation (dropping the
    +// socket) when the probe timed out or the probe completed while the connection lingered.
    +if let Some(task) = conn_task {
    +    task.abort();
    +}
    +ready
    +

    Two honest notes on the shape it actually shipped:

    • It's a manual abort, not the Drop guard from the sketch. That's fine here because http_ready has a single linear tail: nothing between the timeout resolving and the if let ... abort() can return early or ? out, so every path reaches the abort. The day a function grows several ? early returns between grabbing a task handle and finishing with it, that's the signal to promote this manual call into the AbortOnDrop guard — same story as a lock, where hand-written unlock works until someone adds an early return and the guard makes it structural.
    • The PR couldn't even reproduce the leak. As the author's write-up notes, hyper 1.x happens to resolve the conn future when sender is dropped, so the detached task was already exiting on its own. The explicit abort() converts "cancellation that works by accident, because of a dependency's internals" into "cancellation we own" — and the two new tests pin the no-lingering-socket behavior against future hyper changes.

    Why it matters

    The trap is reading tokio::spawn(fut) with the handle discarded as fire-and-forget cleanup, when it's the opposite: the task outlives the handle. Someone from a GC language expects the task to be collected once nothing references it; someone who's learned "drop a future to cancel it" gets bitten because a spawned task is detached, not a bare future. One leaked socket per probe, multiplied by a poll loop, is a genuine file-descriptor leak. Holding the JoinHandle and aborting it — ideally from a Drop guard so no exit path can skip it — is how you make the background task's lifetime a strict subset of the work that needed it.

    Related lessons

    • PR #29 built the tokio::time::timeout this abort partners with. #29 bounds how long the probe waits; #52 cleans up what the probe spawned when that bound fires. Timeout + abort are the two halves of a cancel-safe async operation — the first stops waiting, the second stops the work.
    • PR #37 is the converse-and-complement on Drop. #37: Drop runs on normal scope exit and panic unwinding, but a signal skips it. #52: because Drop runs on every normal exit path, it's the leak-proof home for an abort(). Read them together for the full map of what Drop covers and what it doesn't.

    Dig deeper

    • JoinHandle::abort — the cancellation semantics, and the note that dropping a JoinHandle detaches (does not cancel) the task.
    • The Rust Book, ch. 15.3 — Running Code on Cleanup with the Drop Trait, the pattern behind the AbortOnDrop guard.
    +
    +
    + + + + diff --git a/ent/lessons/53-http-upgrade-stream-splicing.html b/ent/lessons/53-http-upgrade-stream-splicing.html new file mode 100644 index 0000000..24b2083 --- /dev/null +++ b/ent/lessons/53-http-upgrade-stream-splicing.html @@ -0,0 +1,58 @@ + + + + + + + PR #53 — Propagate WebSocket upgrades through the proxy + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #53 — Propagate WebSocket upgrades through the proxy

    +
    +

    An HTTP Upgrade trades parsed HTTP framing for the raw byte stream underneath — you reserve that stream with hyper::upgrade::on while you still own the request, then splice both raw halves with tokio::io::copy_bidirectional.

    +

    http-upgradecopy-bidirectional

    +

    2026-06-14 · +238/−2 · View PR

    +
    +

    The situation

    The proxy forwarded ordinary HTTP fine, but WebSocket requests (Vite/Webpack/Next HMR) died at the proxy boundary. A WebSocket starts as a normal GET carrying Upgrade: websocket; the app answers 101 Switching Protocols and from that byte onward the connection is no longer HTTP — it's a raw, full-duplex byte stream. The proxy has to notice the handshake, let it through, then get out of the way and shovel bytes both directions until someone hangs up.

    The Rust idea

    An upgrade is a handoff. Normally hyper owns the socket and parses framed HTTP messages for you. After a 101 there are no more messages to parse — there's a raw TCP stream underneath that hyper is finished with. hyper::upgrade::on(&mut req) hands you a future (OnUpgrade) that resolves to that raw stream once the upgrade completes. Think of it as a claim ticket: you reserve the stream now and collect it later.

    Two Rust facts make the timing of that claim load-bearing:

    • send_request(req) moves the request. Sending it upstream consumes req by value, and the upgrade handle lives inside the request's extension map. Once the request is moved, you can never reach back in for it. So you must claim the handle before the move — that's the only window where the server half is still yours.
    • The downstream upgrade only resolves after you return the 101. hyper's server loop completes the client-side upgrade only once forward hands back the 101 response. So awaiting the downstream future inline — before returning — would wait for something that can't happen until you return. Deadlock. The fix is to tokio::spawn a detached task: return the 101, and let that task collect both halves whenever they're ready.

    Once you hold both raw streams, tokio::io::copy_bidirectional(&mut a, &mut b) runs the two copies concurrently — a → b and b → a — and returns when both directions hit EOF. It replaces a hand-rolled "spawn two copy loops and coordinate the shutdown" dance with one call.

    In this PR

    Claim the downstream handle up front, gated on the Upgrade header so non-upgrade requests pay nothing (.then(|| …) only runs the closure when the bool is true):

    // crates/adj/src/proxy.rs
    +let downstream_upgrade = req
    +    .headers()
    +    .contains_key(hyper::header::UPGRADE)
    +    .then(|| hyper::upgrade::on(&mut req));
    +

    Drive the upstream client connection with_upgrades() so a 101 doesn't tear it down — the upstream's raw half has to survive past the handshake for us to claim it too:

    // crates/adj/src/proxy.rs
    +if let Err(err) = conn.with_upgrades().await {
    +    tracing::debug!("upstream connection ended: {err}");
    +}
    +

    On a 101, splice the two raw streams in a detached task:

    // crates/adj/src/proxy.rs
    +if resp.status() == StatusCode::SWITCHING_PROTOCOLS {
    +    if let Some(downstream) = downstream_upgrade {
    +        let upstream = hyper::upgrade::on(&mut resp);
    +        tokio::spawn(async move {
    +            let (down, up) = match tokio::join!(downstream, upstream) {
    +                (Ok(d), Ok(u)) => (d, u),
    +                // ...
    +            };
    +            let mut down = TokioIo::new(down);
    +            let mut up = TokioIo::new(up);
    +            if let Err(err) = tokio::io::copy_bidirectional(&mut down, &mut up).await {
    +                tracing::debug!("upgraded tunnel closed: {err}");
    +            }
    +        });
    +    }
    +}
    +

    tokio::join!(downstream, upstream) awaits both claim tickets concurrently and yields a tuple of Results; only when both raw streams are in hand does the pump start. The TokioIo::new wrappers matter: hyper's upgraded stream speaks hyper's own IO traits, but copy_bidirectional is a tokio function expecting AsyncRead + AsyncWrite, so TokioIo adapts one to the other.

    Why it matters

    The trap is claiming the stream too late. It's natural to reach for the upgrade handle after you have the response — that's when a WebSocket "feels" established. But send_request already moved the request by then, and Rust's ownership rules make that a hard compile error, not a subtle runtime bug: the handle is simply gone. The type system forces you to grab it during the one valid window. The second trap — awaiting the downstream upgrade inline — compiles fine and then hangs forever; the tokio::spawn is what keeps it from deadlocking, and the comment in the diff spells out why. A garbage-collected proxy in another language would let you hold a dangling reference to the consumed request and discover the mistake only when the tunnel silently never opens.

    Related lessons

    • PR #40 taught the generic serve_plain<S> loop these connections ride on — note it already calls .with_upgrades() on the server side. #53 is the client-side other half: without both, the upgrade completes on neither end.
    • PR #51 taught typed headers; the hyper::header::UPGRADE constant here is that same idea — a typed name instead of a stringly-typed "upgrade" lookup.

    Dig deeper

    +
    +
    + + + + diff --git a/ent/lessons/55-races-beyond-the-borrow-checker.html b/ent/lessons/55-races-beyond-the-borrow-checker.html new file mode 100644 index 0000000..7f92a00 --- /dev/null +++ b/ent/lessons/55-races-beyond-the-borrow-checker.html @@ -0,0 +1,62 @@ + + + + + + + PR #55 — Close the test-sandbox port race behind connection-reset flakes + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #55 — Close the test-sandbox port race behind connection-reset flakes

    +
    +

    / and the borrow checker eliminate in-process data races, but logical races over shared OS state — a port, a file, a row in someone else's database — are still entirely on you.

    +

    race-conditionTOCTOU

    +

    2026-06-14 · +176/−72 · View PR

    +
    +

    The situation

    Integration tests each spin up their own daemon in a sandbox and need a free proxy port. The old trick: bind 127.0.0.1:0, let the kernel pick a port, read the number, close the socket, then hand that number to the daemon to re-bind. Under cargo test seven suites run at once, all drawing from the same kernel ephemeral-port range. Another process could claim the port in the gap between the test closing it and the daemon re-binding it. The readiness probe's bare TCP connect then hit a foreign listener, called it ready, and the real request landed on a stranger's socket: "Connection reset by peer". It flaked roughly 1-in-45.

    The Rust idea

    Rust's concurrency guarantees are famous, and it's easy to over-read them. Here's the precise boundary.

    A data race is two threads touching the same memory location at the same time, at least one of them writing, with no synchronization. That is undefined behavior in C, a heisenbug in Go, and a compile error in Rust. The mechanism is two marker traits the compiler tracks for every type:

    • — safe to move to another thread.
    • — safe to share by reference (&T) across threads.

    is ; Rc<RefCell<T>> is neither. So tokio::spawn accepts the first and rejects the second, and the borrow checker won't let you reach shared memory except through the lock. Data races: gone, before the program ever runs.

    But note the load-bearing phrase: the same memory location. The guarantee is about memory your process owns. The moment two processes contend over something outside that model — a TCP port in the kernel's table, a file on disk, a network resource — the compiler has no view of it. No &mut crosses the boundary, no trait describes it, so there is nothing to check. This bug is a TOCTOU race — time-of-check to time-of-use: you check a fact (this port is free), and by the time you use it (bind it), the fact has changed. Classic, and completely invisible to /.

    In this PR

    The old code even confessed the race in its own doc comment:

    // crates/adj/tests/proxy.rs  (deleted)
    +/// Bind :0, read the assigned port, close. The kernel may reissue this number to anyone before
    +/// the daemon claims it, but for a localhost test that's rare enough to accept.
    +fn pick_port() -> u16 {
    +    let l = std::net::TcpListener::bind("127.0.0.1:0").expect("bind :0");
    +    l.local_addr().expect("local_addr").port()
    +}
    +

    "Rare enough to accept" held until seven suites ran concurrently. The bind → read → close → re-bind sequence has a window, and a window is all a race needs.

    The fix removes the window entirely. Don't pick a port and hand it off — let the daemon bind :0 itself, so the port is born bound to the right process and never freed. The daemon then publishes the actually-bound port to a file:

    // crates/adj/src/proxy.rs
    +let listener = TcpListener::bind(addr)
    +    .await
    +    .map_err(|e| anyhow!("binding proxy listener at {addr}: {e}"))?;
    +let bound = listener
    +    .local_addr()
    +    .map_err(|e| anyhow!("reading proxy listener addr: {e}"))?
    +    .port();
    +report_bound_port(crate::paths::proxy_port_path(), bound);
    +

    The test passes 0 and learns the real port by reading that file:

    // crates/adj/tests/proxy.rs
    +// 0 = the daemon binds a kernel-assigned port; start_daemon learns the real port
    +// from the proxy.port file. Picking a free port here and re-binding it in the
    +// daemon raced concurrent test processes drawing from the same ephemeral range,
    +// which flaked as "connection reset by peer" against a foreign listener.
    +proxy_port: 0,
    +
    // crates/adj/tests/proxy.rs
    +// proxy.port is written after bind, so a parsed port means the listener is live —
    +// and unambiguously ours, unlike a bare TCP connect to a guessed port.
    +if self.proxy_port == 0 {
    +    if let Some(p) = read_port_file(&port_file) {
    +        self.proxy_port = p;
    +    }
    +}
    +

    Two races collapse into zero. There's no free-then-rebind gap because the listener that binds the port is the one that keeps it. And the readiness signal gets stronger for free: a parsed proxy.port file means the listener is live and ours, where the old bare TcpStream::connect couldn't tell our daemon from a squatter on the same port.

    Why it matters

    The trap is assuming Rust's "fearless concurrency" covers all concurrency. It covers exactly one thing — shared memory inside your address space — and covers it completely. Everything with an identity outside your process is ordinary distributed-systems state, and the same discipline you'd use in any language applies: don't check-then-act across a gap where another actor can intervene. Here the fix is to eliminate the gap (bind once, keep it) rather than shrink it. Other flavors of the same lesson: hold the resource instead of releasing and reacquiring it, make the operation atomic (O_CREAT | O_EXCL, a CAS), or retry on the losing side. The borrow checker will happily let you write a textbook TOCTOU bug — it was never its job to stop you.

    Related lessons

    • PR #16 — the pattern whose guarantee this lesson bounds: it prevents data races on in-process memory, and only that.
    • PR #24 — single-flight boot gate, an in-process logical race solved with a lock; contrast it with #55, an OS-level race a lock can't touch.
    • PR #50 — GCing the per-name lock map, another case of managing shared in-process state safely.

    Dig deeper

    • The Rust Book, ch. 16 — Fearless Concurrency. Read it for what / buy you, but read it knowing the chapter is about data races specifically; the port race in this PR is deliberately outside its scope.
    +
    +
    + + + + diff --git a/ent/lessons/56-split-once-structured-keys.html b/ent/lessons/56-split-once-structured-keys.html new file mode 100644 index 0000000..90c5f41 --- /dev/null +++ b/ent/lessons/56-split-once-structured-keys.html @@ -0,0 +1,66 @@ + + + + + + + PR #56 — Worktree instances: per-label registry keys, remove/prune, wildcard TLS SANs + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #56 — Worktree instances: per-label registry keys, remove/prune, wildcard TLS SANs

    +
    +

    str::split_once cuts a string at the first delimiter into two borrowed &str slices and returns an Option, so "there was no delimiter" is a case you handle instead of a bug you forget — the clean way to parse a structured key.

    +

    string-slicessplit-onceborrowing

    +

    2026-06-13 · +3601/−112 · View PR

    +
    +

    The situation

    The registry maps a key to a path. This PR lets one base app register under several keys: a bare site is the app itself, and feature-x.site is a worktree instance that routes at feature-x.site.adj.ac. The key encodes structure — one dot separates the instance label from the base name. Every routed request has to pull those two pieces back out of a &str key, and it has to be cheap.

    The Rust idea

    str::split_once(delimiter) splits at the first match and hands back Option<(&str, &str)>. Two things are worth internalizing.

    It returns Option, so the "no delimiter" case can't be forgotten. In many languages you write "feature-x.site".split("."), get an array, and reach for [0] and [1] — hoping there was a dot. When there isn't, [1] is undefined, an out-of-bounds panic, or a silent empty string, depending on the language. That missing-delimiter path is a landmine. split_once turns it into a value: Some((before, after)) when the delimiter is present, None when it isn't. A bare key like "site" isn't an edge case to defend against — it's simply the None arm of a match.

    The two pieces are borrowed slices — no allocation. A &str is a pointer + length: a window into bytes someone else owns. The pair split_once returns points straight into the original string, so splitting a key costs two integer offsets, not two heap allocations. (Contrast a String, which owns its bytes on the heap. &str borrows; String owns.) The price of that speed is a lifetime: the slices are valid only while the input lives — which is exactly why the helper below returns borrows and lets the caller decide whether to .to_string() them.

    (split_once is the precise cousin of split, which returns a lazy iterator over every piece. When a string has a known two-part shape, you want the one that gives you exactly two.)

    In this PR

    The parser is six lines. split_key maps a registry key to (Option<label>, base):

    // crates/adj/src/registry.rs
    +/// Split a registry key into `(label, base)`. Keys are either a bare app name (`site`) or a
    +/// worktree-instance key (`feature-x.site`). `add` enforces at most one dot, so `split_once`
    +/// is total here.
    +pub fn split_key(key: &str) -> (Option<&str>, &str) {
    +    match key.split_once('.') {
    +        Some((label, base)) => (Some(label), base),
    +        None => (None, key),
    +    }
    +}
    +
    +/// The app name a registry key resolves config against: the part after the instance label,
    +/// or the whole key when there is no label.
    +pub fn base_name(key: &str) -> &str {
    +    split_key(key).1
    +}
    +

    Read the match: the Some arm is an instance key — wrap the label in Some, keep the base. The None arm is a bare app name — there's no label, and the whole key is the base. base_name just projects the second field (.1). Every return value is a borrow into key; nothing is copied.

    Now the load-bearing detail in that doc comment: "so split_once is total here." split_once always cuts at the first dot. On "a.b.c" it yields ("a", "b.c") — everything after the first delimiter lands in the second slice. So if a dotted app name ever slipped through, feature.my.app would misparse as label feature, base my.app. The claim that "the first dot is the structural boundary" only holds because of an invariant enforced somewhere else: app names can't contain dots. That check lives at the point where a config enters the system:

    // crates/adj/src/registry.rs
    +// Dots are structural in registry keys (`<label>.<name>` is a worktree instance), so a
    +// dotted app name would make `feature-x.site` ambiguous. Checked before the general
    +// DNS-label check so the dot case gets its own targeted message.
    +if cfg.name.contains('.') {
    +    return Err(anyhow!(
    +        "app name `{}` contains `.` — dots are reserved for worktree instances (`<label>.<name>`)",
    +        cfg.name
    +    ));
    +}
    +

    That's the other half of the design. Splitting downstream gets to be a one-liner because the boundary code guarantees at most one dot per key. Reject the malformed input where it enters (see #49), and the parser never has to cope with it.

    The test pins both shapes — bare key and instance key — in one place:

    // crates/adj/src/registry.rs
    +#[test]
    +fn split_key_handles_bare_and_instance_keys() {
    +    assert_eq!(split_key("site"), (None, "site"));
    +    assert_eq!(split_key("feature-x.site"), (Some("feature-x"), "site"));
    +    assert_eq!(base_name("site"), "site");
    +    assert_eq!(base_name("feature-x.site"), "site");
    +}
    +

    Why it matters

    The trap this sidesteps is the array-index split: grab [0]/[1], forget the no-delimiter path, and ship a parser that panics or silently mangles a bare key. split_once makes "no dot" a branch the compiler forces you to write, and pairing it with the upstream contains('.') reject turns "the first dot is the only dot" from a hope into a guarantee. Because the two pieces are borrowed slices, this runs on every routed request without allocating a thing — the invariant lives in code, and reading it back out is free.

    Related lessons

    • PR #49 is the same file and the same move from the other end: the dotted-name reject above is a parse-don't-validate boundary check, refusing bad input at read_app_config so nothing downstream has to re-check it.
    • PR #45 leans on the same borrowing trick — its DashboardEntry<'a> holds &str views into each app summary instead of cloning strings, exactly like split_key returns windows into key.

    Dig deeper

    • str::split_once — the signature (Option<(&str, &str)>), plus rsplit_once for splitting on the last delimiter instead of the first.
    • The Rust Book, ch. 4.3 — The Slice Type: why a &str is a borrowed view, and how its lifetime is tied to the data it points into.
    +
    +
    + + + + diff --git a/ent/lessons/60-compile-time-version-stamping.html b/ent/lessons/60-compile-time-version-stamping.html new file mode 100644 index 0000000..db87b3d --- /dev/null +++ b/ent/lessons/60-compile-time-version-stamping.html @@ -0,0 +1,73 @@ + + + + + + + PR #60 — Alpha Homebrew distribution: release CI, in-repo tap formula, version stamping + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #60 — Alpha Homebrew distribution: release CI, in-repo tap formula, version stamping

    +
    +

    A build.rs script runs before your crate compiles and can hand values to the compiler via cargo:rustc-env=… stdout directives, which env! (build fails if the var is missing) and option_env! (yields an Option, never fails) then bake into the binary as &'static str literals.

    +

    build-scriptscompile-time-envmacros

    +

    2026-06-14 · +243/−1 · View PR

    +
    +

    The situation

    Shipping adj via Homebrew means adj --version has to report something true. A tagged release should say 0.1.0-alpha.1; a build off a random local commit should say which commit (and whether the tree was dirty). None of that lives in the source — it lives in git and in CI env vars. This PR is mostly release plumbing (a Homebrew formula, a tag-triggered CI workflow, a just release target), but the one genuinely instructive Rust piece is how the binary learns its own version at compile time.

    The Rust idea

    Rust has no runtime reflection for build metadata. A binary can't ask "what version am I?" from thin air — anything it knows must be baked in at compile time or read from disk at runtime. For a version string you want it baked in: a self-contained binary with no sidecar VERSION file to lose.

    Rust gives you two macros that read the compiler's environment:

    • env!("NAME") — expands to the value of environment variable NAME at compile time, as a &'static str. If NAME isn't set, the build fails with a clear error. Use it when the value must exist. Cargo always sets CARGO_PKG_VERSION from Cargo.toml, so env!("CARGO_PKG_VERSION") is guaranteed to compile.
    • option_env!("NAME") — same idea, but expands to Option<&'static str>: Some(v) if the var was set at compile time, None if not. It never fails the build. Use it for metadata that may or may not be there.

    The ! marks these as macros, not functions. A function's argument is evaluated at runtime; these run during compilation and leave a string literal behind in the compiled code. That's the only way to reach the environment the compiler saw.

    But where does a var like ADJ_VERSION come from during compilation? That's what build.rs is for. Cargo compiles and runs build.rs before compiling the crate, and the script talks back to Cargo by printing specially-formatted lines to stdout — cargo:rustc-env=KEY=VALUE sets an env var that env!/option_env! can then read while the crate compiles.

    In this PR

    The const in main.rs picks the git/CI-derived version when present, else the Cargo version:

    // crates/adj/src/main.rs
    +const VERSION: &str = match option_env!("ADJ_VERSION") {
    +    Some(v) => v,
    +    None => env!("CARGO_PKG_VERSION"),
    +};
    +

    The whole expression is a const, so it's evaluated at compile time — match works in const context. option_env!("ADJ_VERSION") is Some only if the build set that var; otherwise it falls through to env!("CARGO_PKG_VERSION"), which can't fail because Cargo always provides it. Then clap uses it:

    // crates/adj/src/main.rs
    +#[command(
    +    name = "adj",
    +    version = VERSION,   // was: version,  (which defaults to CARGO_PKG_VERSION)
    +    about = "Adjacent: supervised local dev servers"
    +)]
    +

    The build script supplies ADJ_VERSION:

    // crates/adj/build.rs
    +fn git_describe() -> Option<String> {
    +    let out = Command::new("git")
    +        .args(["describe", "--tags", "--always", "--dirty", "--match", "v*"])
    +        .output()
    +        .ok()?;
    +    if !out.status.success() {
    +        return None;
    +    }
    +    let v = String::from_utf8(out.stdout).ok()?;
    +    let v = v.trim().trim_start_matches('v').to_string();
    +    (!v.is_empty()).then_some(v)
    +}
    +
    +fn main() {
    +    let version = std::env::var("ADJ_VERSION")
    +        .ok()
    +        .filter(|s| !s.is_empty())
    +        .or_else(git_describe);
    +
    +    if let Some(v) = version {
    +        println!("cargo:rustc-env=ADJ_VERSION={v}");
    +    }
    +
    +    println!("cargo:rerun-if-env-changed=ADJ_VERSION");
    +    // Refresh the git-describe value when HEAD moves; workspace root is two levels up from here.
    +    if Path::new("../../.git/HEAD").exists() {
    +        println!("cargo:rerun-if-changed=../../.git/HEAD");
    +    }
    +}
    +

    Walk the chain:

    1. CI sets ADJ_VERSION to the release tag (the workflow's Derive version from tag step strips the leading v). Locally that var is unset, so the script runs git describe --tags --always --dirty — which yields 0.1.0-alpha.1 on the exact tag, or an abbreviated commit like 61b2f6e off a plain commit, with a -dirty suffix appended when the working tree has uncommitted changes.
    2. The script prints cargo:rustc-env=ADJ_VERSION=<v>, which sets that var for the crate compile that follows.
    3. option_env!("ADJ_VERSION") in main.rs sees it and bakes it into const VERSION.

    The two trailing directives are cache control. Build scripts are cached aggressively, so without a hint Cargo would keep the stale value forever. cargo:rerun-if-env-changed=ADJ_VERSION re-runs the script when that var changes; cargo:rerun-if-changed=../../.git/HEAD re-runs it when you commit (HEAD moves), so git describe refreshes. Build scripts run with their working directory set to the package root (crates/adj), which is why the repo's .git/HEAD is ../../ up.

    Note the .ok()? calls in git_describe: they intentionally throw away git's errors and return None, which makes the whole thing fall back to the Cargo version. That's the legitimate face of swallowing a Result (see #48) — a source build with no .git directory shouldn't fail to compile just because git describe can't run.

    The rest of the PR is distribution tooling, not Rust: .github/workflows/release.yml (tag-triggered arm64 build + GitHub prerelease), Formula/adj.rb and render-formula.sh (the in-repo Homebrew tap), and a just release target that tags the next alpha. Worth knowing they exist; not where the Rust lesson lives. The user-facing brew install / cargo install side is #69's subject.

    Why it matters

    In many ecosystems you'd read the version from a file shipped next to the binary (a package.json, a bundled VERSION) or hardcode a string you forget to bump. Both drift, and the file can go missing. Compile-time stamping makes the binary self-describing with zero runtime dependencies. And the env! vs option_env! split encodes intent in the type: env! says "this must exist, fail the build if it doesn't" and hands you a &str; option_env! says "this might exist" and hands you an Option<&str> you're forced to handle. Typo CARGO_PKG_VERSION and the build stops immediately — you never ship a binary that reports the wrong version because a lookup silently returned empty.

    Related lessons

    • PR #48 — Warn instead of silently defaulting. build.rs here uses .ok()? to deliberately drop git's errors and fall back; #48 is about the cases where that silent drop is a bug. Same combinator, opposite verdict — the difference is whether the Err means "someone made a mistake."

    Dig deeper

    • The Cargo Book — Build Scripts — what build.rs can do and the full list of cargo: output directives (rustc-env, rerun-if-changed, rerun-if-env-changed).
    • std — env! and option_env! — the compile-time environment macros, and why one fails the build while the other returns Option.
    +
    +
    + + + + diff --git a/ent/lessons/63-what-clippy-teaches.html b/ent/lessons/63-what-clippy-teaches.html new file mode 100644 index 0000000..9ddfac6 --- /dev/null +++ b/ent/lessons/63-what-clippy-teaches.html @@ -0,0 +1,73 @@ + + + + + + + PR #63 — Add a fmt + clippy lint job to CI + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #63 — Add a fmt + clippy lint job to CI

    +
    +

    Clippy is a Rust teacher wearing a linter's badge — each lint names an idiom (single_match → if let) or a footgun (await_holding_lock, dead_code), and the honest reply is either to adopt the idiom or to #[allow] it with a reason.

    +

    clippyrustfmtlintsidioms

    +

    2026-06-14 · +604/−248 · View PR

    +
    +

    The situation

    The repo had never been gated on formatting or lints. This PR adds a CI job that runs two commands and fails the build if either complains:

    # .github/workflows/ci.yml
    +- run: cargo fmt --all -- --check
    +- run: cargo clippy --workspace --all-targets -- -D warnings
    +

    To land that gate green it had to fix everything the two commands surfaced. Be honest about the split: the vast majority of the +604/−248 is one mechanical cargo fmt --all commit — struct literals expanded to multi-line, long calls wrapped — with no behavior change and nothing to learn. The interesting part is a tiny second commit (+46/−7) where clippy -D warnings forced three real changes. That's the lesson: not the formatter, but what clippy taught.

    The Rust idea

    Clippy ships ~700 lints, and almost none of them are about whitespace — rustfmt owns that. Clippy is about idiom and correctness. Each lint encodes a small piece of "here's how a seasoned Rust programmer would write this, and why." Running it is less like a spell-check and more like pairing with someone who keeps saying "there's a cleaner way." -D warnings (deny all warnings) turns that advice into a hard gate: the build fails until you either take the advice or explicitly, in writing, decline it.

    Declining is a first-class move. #[allow(clippy::some_lint)] says "I read this lint, I understand what it's warning about, and it doesn't apply here." A clippy pass done right leaves behind a mix of both: code you improved, and a few #[allow]s each carrying a comment that explains the disagreement. This PR has one of each kind.

    In this PR

    1. single_match → if let. Clippy's single_match fires when a match has one arm that does real work and a second arm that does nothing (_ => {}). Rust already has a construct for "run this code only if the value matches one pattern, otherwise skip" — that's if let. A two-arm match with an empty catch-all is if let wearing a costume, and reading it forces you to scan to the bottom to confirm the _ really is empty.

    // crates/adj/src/tls.rs — ensure_leaf
    +// before:
    +match (fs::read_to_string(&cert_path), fs::read_to_string(&key_path)) {
    +    (Ok(cert), Ok(key)) => match leaf_covers(&cert, sans) {
    +        Ok(true) => return Ok((cert, key)),
    +        Ok(false) | Err(_) => {}
    +    },
    +    _ => {}
    +}
    +
    +// after:
    +if let (Ok(cert), Ok(key)) = (
    +    fs::read_to_string(&cert_path),
    +    fs::read_to_string(&key_path),
    +) {
    +    if let Ok(true) = leaf_covers(&cert, sans) {
    +        return Ok((cert, key));
    +    }
    +}
    +

    Both matches collapse the same way. Semantics are identical — only a readable, SAN-covering cached leaf short-circuits; everything else (a read error, a parse error, a cert that doesn't cover the wanted names) falls through to re-issue. But the if let version says that: "if both files read, and the leaf covers the SANs, use it." The _ => {} and Ok(false) | Err(_) => {} filler arms are gone, so there's nothing to double-check.

    2. await_holding_lock — the documented decline. This lint flags holding a std::sync::MutexGuard across an .await. It's a real footgun: on a multi-threaded async runtime the task can be parked mid-await while still holding the lock, and another task on that same thread can block forever trying to acquire it — a deadlock. Clippy is right to warn by default. Here it's a false alarm, and the fix is to say so, not to restructure working test code:

    // crates/adj/tests/tls.rs
    +#[cfg(target_os = "macos")]
    +#[tokio::test]
    +// The keychain guard intentionally spans the test's awaits to serialize on LOGIN_KEYCHAIN_LOCK;
    +// a current-thread tokio runtime can't deadlock on it, so the std-guard-across-await lint is moot.
    +#[allow(clippy::await_holding_lock)]
    +async fn install_ca_generates_files_and_prints_macos_command() {
    +

    The #[allow] is localized to the three tests that need it, not slapped on the whole crate. That matters: if the supervisor ever grows a real lock-across-await, the lint stays live to catch it. A repo-wide #![allow] would have silenced the warning everywhere and thrown away its value.

    3. dead_code / unused_imports under a cross-platform gate. The clippy job runs on ubuntu-latest, but the keychain code is #[cfg(target_os = "macos")] — it compiles to nothing on Linux. So on the CI runner, every helper that only the macOS path uses becomes genuinely dead code, and -D warnings promotes those warnings to errors. The fix is to #[cfg]-gate the now-unused items to match their only caller:

    // crates/adj/src/tls.rs
    +pub(crate) use keychain::delete as delete_keychain_ca;
    +// Only the macOS doctor checks load a keychain handle; on other targets the re-export is unused.
    +#[cfg(target_os = "macos")]
    +pub(crate) use keychain::load as load_keychain_ca;
    +

    ...and for a stub that exists only to mirror an API on the platform where it's never called, a deliberate #[allow(dead_code)]:

    // crates/adj/src/tls/keychain.rs — the non-macOS stub
    +// Mirrors the macOS KeychainKey API for symmetry; the non-macOS doctor path never calls
    +// it, so it's dead on these targets.
    +#[allow(dead_code)]
    +pub fn sign_canary(&self) -> Result<()> {
    +    Err(unsupported())
    +}
    +

    Same test scaffolding (with_temp_home, HOME_LOCK, the curl_available helper) got the same #[cfg(target_os = "macos")] treatment.

    Why it matters

    Two takeaways a linter-skeptic misses.

    First, dead_code under --all-targets -D warnings on a Linux runner is a free cross-compilation check. Adjacent is mostly developed on a Mac, and it's easy to leave a helper unused on platforms you never build locally. The CI job compiles the non-macOS cfg branches you'd otherwise never exercise, and catches the drift. The lint isn't nagging about tidiness — it's proving that every target still hangs together.

    Second, a green clippy run doesn't mean "no lints fired." It means "every lint that fired was either fixed or explicitly declined with a reason." That's the discipline -D warnings enforces: you can't ignore advice by scrolling past it. You either take it, or you write down why you didn't — and the next reader gets that reasoning for free. An #[allow] with a comment is worth more than clean code that silently dodged the question.

    Related lessons

    • PR #40 shipped the HTTPS + local-CA machinery. The ensure_leaf match and the keychain helpers clippy is cleaning up here are exactly that code — this PR is #40's neighborhood one linter-pass later.
    • PR #48 is the same spirit in a different tool: it's about surfacing a Result's Err instead of swallowing it. Clippy and the type system are both in the business of making the invisible visible — this PR just does it at the linter layer.

    Dig deeper

    • The Clippy lint index — every lint, searchable, each with a before/after and the reasoning behind it. single_match, await_holding_lock, and the rustc dead_code/unused_imports lints all have entries worth reading.
    +
    +
    + + + + diff --git a/ent/lessons/65-health-files-and-metadata.html b/ent/lessons/65-health-files-and-metadata.html new file mode 100644 index 0000000..5e13ef2 --- /dev/null +++ b/ent/lessons/65-health-files-and-metadata.html @@ -0,0 +1,56 @@ + + + + + + + PR #65 — Add community health files + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #65 — Add community health files

    +
    +

    None — this PR is seven Markdown/YAML files under .github/ (+160/−0), zero Rust. The lesson is open-source project hygiene, and the Rust-ecosystem analog worth knowing: the way GitHub reads your .github/ files and renders them into the repo UI is exactly how crates.io reads your Cargo.toml package metadata — description, repository, readme, keywords, categories, license, rust-version — and renders it into your crate page.

    +

    oss-hygienecrates.io-metadata

    +

    2026-06-14 · +160/−0 · View PR

    +
    +

    The situation

    The repo had code and a README but none of the paperwork a public project is judged by: no code of conduct, no contribution guide, no security policy, no issue or PR templates. This PR adds the whole set — CODE_OF_CONDUCT.md, CONTRIBUTING.md, SECURITY.md, two issue templates plus a config.yml, and a PULL_REQUEST_TEMPLATE.md — all under .github/.

    The idea (no Rust this time)

    These are community health files, and the reason they live in .github/ is that GitHub reads them by convention and wires them into the UI. Drop CONTRIBUTING.md there and the "New pull request" screen sprouts a link to it. Add ISSUE_TEMPLATE/*.md and the "New issue" button becomes a menu of your templates. Add SECURITY.md and a "Report a vulnerability" affordance appears. You're not writing docs a human has to go find — you're filling in slots a platform already knows how to render.

    The trick is that the file's location and name are the contract. GitHub doesn't parse your prose; it keys off the path (.github/SECURITY.md) and, for templates, off the YAML front-matter at the top of the file. Get the path right and the feature lights up; get it wrong and the file is just dead Markdown.

    In this PR

    The issue templates lead with YAML front-matter — that block is what GitHub reads to build the template menu (the name and about become the menu entry):

    # .github/ISSUE_TEMPLATE/feature_request.md
    +---
    +name: Feature Request
    +about: Propose an enhancement or new capability for Adjacent
    +labels: enhancement
    +---
    +

    config.yml isn't a template — it configures the template chooser, keeping the blank-issue escape hatch open and routing open-ended questions to Discussions instead of the issue tracker:

    # .github/ISSUE_TEMPLATE/config.yml
    +blank_issues_enabled: true
    +contact_links:
    +  - name: General questions
    +    url: https://github.com/nonrational/adjacent/discussions
    +    about: Ask questions and discuss Adjacent in Discussions
    +

    SECURITY.md states a support window and points reporters at GitHub's private reporting flow rather than a public issue or an email address:

    <!-- .github/SECURITY.md -->
    +For security vulnerabilities, please use GitHub's [private vulnerability
    +reporting feature](https://github.com/nonrational/adjacent/security/advisories/new)
    +rather than opening a public issue.
    +

    And CONTRIBUTING.md closes with the standard Rust dual-license inbound clause — every contribution comes in under both licenses, no extra paperwork:

    <!-- .github/CONTRIBUTING.md -->
    +Unless you explicitly state otherwise, any contribution intentionally submitted
    +for inclusion in this work by you ... shall be dual licensed under the Apache
    +License, Version 2.0 and the MIT license, without any additional terms or
    +conditions.
    +

    That clause isn't free-floating — it matches a field already in the manifest, which is the bridge to the Rust angle.

    Why it matters — the crates.io angle

    crates.io is to a published crate what GitHub's repo page is to a repo: it reads structured metadata and renders it. The source of that metadata is the [package] table in Cargo.toml. The repo's workspace manifest already carries two of the important fields, and the license here is the exact counterpart to the CONTRIBUTING clause above:

    # Cargo.toml — [workspace.package] (already in the repo, not added by this PR)
    +license = "MIT OR Apache-2.0"
    +repository = "https://github.com/nonrational/adjacent"
    +

    If Adjacent were ever cargo published, crates.io would surface license as a badge and repository as the "Repository" link — the same "platform renders your metadata" move as GitHub and .github/. But the metadata slots for a polished crate page go further, and they're worth knowing even before you publish:

    • description — the one-liner under the crate name in search results. Required to publish.
    • readme — path to the file crates.io renders as the crate's front page (defaults to README.md).
    • keywords / categories — how people find you in search and browse; categories must come from crates.io's fixed slug list.
    • rust-version — your MSRV (minimum supported Rust version). Cargo refuses to build with an older toolchain and prints a clear error instead of a confusing mid-compile failure.

    The lesson mirrors the health files exactly: don't write a paragraph telling people your MSRV or where to file bugs — put the value in the slot the tooling reads, and the tooling shows it for you, consistently, everywhere.

    Related lessons

    • PR #46 covers the MIT OR Apache-2.0 dual-license convention itself and the on-disk LICENSE-* layout — the CONTRIBUTING clause here is the inbound-contribution half of that same decision.
    • PR #36 makes the "the file's path/format is the contract" point from the docs side.

    Dig deeper

    +
    +
    + + + + diff --git a/ent/lessons/68-lean-agent-docs.html b/ent/lessons/68-lean-agent-docs.html new file mode 100644 index 0000000..1b86cf7 --- /dev/null +++ b/ent/lessons/68-lean-agent-docs.html @@ -0,0 +1,37 @@ + + + + + + + PR #68 — Remove the agent-identity section from CLAUDE.md + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #68 — Remove the agent-identity section from CLAUDE.md

    +
    +

    None — this is a pure documentation deletion (+0/−10), no Rust and no technical concept to teach.

    +

    docs-hygienehonest-series

    +

    2026-06-16 · +0/−10 · View PR

    +
    +

    What happened

    This PR deleted the ## Agent identity section from CLAUDE.md — ten lines of personal operational detail (the nonreagent/nonrational GitHub accounts, the gh auth switch dance, the per-commit author line) that don't belong in an open-source repo. The durable principle it encoded still lives, machine-agnostically, in each contributor's own profile:

    ## Agent identity (`nonrational/adjacent` only)
    +
    +Agents commit, push, and self-review as the GitHub user `nonreagent` — the human reviews and merges.
    +- `gh auth switch -u nonreagent` + `gh auth setup-git` before any `git push`. Inline `GH_TOKEN=...` does nothing for `git push`.
    +- **Never** run `gh pr review --approve` or `gh pr merge`. Approval and merge are the human's job.
    +

    The meta-note worth keeping: agent-instruction files (CLAUDE.md, AGENTS.md) earn their keep by staying lean. Every line is context an agent re-reads on each task, and anything personal or repo-account-specific is noise for other contributors plus a small information leak. Prune aggressively.

    And this file is the honesty principle in action: a good teaching series says so plainly when a PR carries no lesson, rather than inventing one.

    +
    +
    + + + + diff --git a/ent/lessons/69-distributing-a-rust-cli.html b/ent/lessons/69-distributing-a-rust-cli.html new file mode 100644 index 0000000..b03994b --- /dev/null +++ b/ent/lessons/69-distributing-a-rust-cli.html @@ -0,0 +1,47 @@ + + + + + + + PR #69 — Add alpha install directions to the homepage + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #69 — Add alpha install directions to the homepage

    +
    +

    No Rust in this diff (it's the ent/ homepage plus a Homebrew formula rename) — but the content teaches the consumer side of shipping a Rust CLI: a Homebrew tap gives users a prebuilt binary with no toolchain, versus cargo install compiling from source into ~/.cargo/bin, versus grabbing the release tarball by hand.

    +

    distributionhomebrewcargo-install

    +

    2026-06-21 · +46/−11 · View PR

    +
    +

    The situation

    The alpha was installable but the homepage still said "coming soon." This PR put the real install steps on the page and renamed the Homebrew package adj → adjacent so brew install adjacent reads naturally. The diff is HTML, bash, YAML, a Ruby formula, and Markdown — zero Rust. Worth teaching anyway, because "how does a stranger get your Rust binary onto their machine" is a real question every CLI project answers, and this PR answers it in public.

    The idea (no Rust this time)

    A compiled Rust CLI is just a binary. Getting it onto a user's PATH has three common routes, and they trade off differently:

    1. A package manager (Homebrew tap). The user runs two commands, gets a prebuilt binary, and never needs a Rust toolchain. Updates flow through brew upgrade. You (the producer) do the compiling once in CI and publish the artifact.
    2. cargo install. Compiles from source on the user's machine into ~/.cargo/bin. Requires them to have Rust installed, and the first build can take minutes. Updates are cargo install --force. Great for Rust developers, a non-starter for everyone else.
    3. The prebuilt release tarball, by hand. Download, extract, drop the binary on PATH. No toolchain, no Homebrew — but you own every update yourself.

    The Homebrew route is really #3 with a manager wrapped around it: the formula just points at the same release tarball and automates the download-and-place.

    In this PR

    The homepage now renders the two-command install path (ent/index.html):

    <!-- ent/index.html -->
    +<section>
    +  <div class="label">install &middot; alpha</div>
    +  <div class="cmds">
    +    <code class="cmd"><span class="prompt">$</span>brew tap nonrational/adjacent https://github.com/nonrational/adjacent<span class="note"># straight from the repo &mdash; no separate tap</span></code>
    +    <code class="cmd"><span class="prompt">$</span>brew install adjacent<span class="note"># Apple Silicon, unsigned alpha build</span></code>
    +  </div>
    +</section>
    +

    Which the user runs as:

    brew tap nonrational/adjacent https://github.com/nonrational/adjacent
    +brew install adjacent
    +

    Two subtleties worth naming:

    The package name and the binary name don't have to match. brew install adjacent installs a CLI you invoke as adj. Homebrew's formula name is just a handle for the package; what lands on PATH is whatever the formula installs. This PR renamed the package to adjacent while the CLAUDE.md contract keeps the binary adj.

    What the tap actually wraps is route #3. The formula's source of truth points straight at a prebuilt release artifact (.github/scripts/render-formula.sh):

    # .github/scripts/render-formula.sh
    +url="https://github.com/${repo}/releases/download/${tag}/adj-${version}-aarch64-apple-darwin.tar.gz"
    +

    So the user who runs brew install adjacent and the user who downloads that .tar.gz by hand get byte-for-byte the same binary. Homebrew is the convenience layer, not a different build.

    For contrast, the from-source route lives in this repo's justfile:

    # justfile
    +install:
    +  cargo install --path crates/adj
    +

    That compiles the workspace and installs adj into ~/.cargo/bin — the Rust developer's path, needing a full toolchain and a real compile, no prebuilt artifact involved.

    Why it matters

    Pick the wrong default and you lose users. Lead a non-Rust audience with cargo install and you've told them to install a compiler and wait for a cold build before they can try your tool — most bounce. Lead with a Homebrew tap and the cost is on you: CI has to build, sign (or admit it's unsigned, as this alpha does), and publish the artifact, and you have to keep the formula's url and sha256 in sync with each release. This PR even adds a smoke CI job that taps and installs from the published formula, so a broken install path fails the release instead of a user. The producer eats the complexity so the consumer's path stays two commands.

    Related lessons

    • PR #60 built the producer half this consumes — the release CI, the in-repo tap formula, and version stamping that make brew install adjacent resolve to a real artifact: View PR #60. This PR is the consumer-facing directions for that machinery.
    • PR #46 — the other side of the same ent/ homepage: how a single-binary Rust project carries a non-Rust web frontend beside its Cargo workspace. Same "honest, no-Rust diff" shape as this one.

    Dig deeper

    +
    +
    + + + + diff --git a/ent/lessons/75-hand-render-config-to-disk.html b/ent/lessons/75-hand-render-config-to-disk.html new file mode 100644 index 0000000..25bb146 --- /dev/null +++ b/ent/lessons/75-hand-render-config-to-disk.html @@ -0,0 +1,73 @@ + + + + + + + PR #75 — Scaffold a default adjacent.toml in `adj add` + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #75 — Scaffold a default adjacent.toml in `adj add`

    +
    +

    When a config file is meant for a human to open and edit, hand-render it as text so comments, # TODO placeholders, and commented-out optional fields survive — toml::to_string(&struct) would drop every one of them — and write it with std::fs::write behind a !path.exists() guard so you never clobber a file the user already owns.

    +

    std::fstomlfile-generation

    +

    2026-06-21 · +1552/−6 · View PR

    +
    +

    The situation

    adj add . used to dead-end when a directory had no adjacent.toml. This PR makes it generate a sensible default instead — filling in name and cmd when it can detect the stack, leaving # TODO guidance when it can't. The generated file's whole reason for existing is that a person opens it next and edits it. So two questions: how do you produce a config file that's friendly to a human editor, and how do you write it to disk without ever overwriting one the user already wrote?

    The Rust idea

    Rust's toml crate can serialize a struct straight to a config string — toml::to_string(&cfg) — the write-side mirror of the #[derive(Deserialize)] that reads adjacent.toml back in (PR #19). It's the obvious reach, and for a machine-to-machine file it's the right one: faithful, round-trippable, no hand-maintained text.

    But to_string serializes data, and a struct holds no comments. Feed it an AppConfig and you get back exactly:

    name = "myapp"
    +cmd = "npm run dev"
    +

    No header explaining where the file came from. No # Optional: block showing the fields the user could set. No # TODO next to a value they still need to fill in. For a scaffold, those comments aren't decoration — they're the entire payload. The file is a teaching aid disguised as config.

    So the tool for a human-facing config file is the opposite of a serializer: you render the text by hand, comments and all, and treat the struct as something you'll parse the result back into later, not something you serialize from. The cost is that you're now responsible for producing valid TOML yourself — which the PR pins down with a test, below.

    In this PR

    render builds the file as a string. Every line the user benefits from — the header, the # TODOs, the commented-out optional block — is text serde would never emit:

    // crates/adj/src/scaffold.rs
    +fn render(name: Option<&str>, cmd: Option<&str>) -> String {
    +    let name_line = match name {
    +        Some(n) => format!("name = \"{n}\"\n"),
    +        None => "name = \"app\"  # TODO: set a name (lowercase letters, digits, `-`)\n".to_string(),
    +    };
    +    let cmd_line = match cmd {
    +        Some(c) => format!("cmd = \"{c}\"\n"),
    +        None => "cmd = \"\"  # TODO: set your dev command, e.g. \"npm run dev\"\n".to_string(),
    +    };
    +    format!(
    +        "# Generated by `adj add` — edit and re-run if needed.\n\
    +         {name_line}\
    +         {cmd_line}\
    +         \n\
    +         # Optional:\n\
    +         # port_env = \"PORT\"\n\
    +         # env_file = \".env.local\"\n\
    +         # idle_timeout = \"15m\"          # \"30s\" / \"1h\" / \"off\"\n\
    +         # health_check_url = \"/healthz\"\n"
    +    )
    +}
    +

    The match name / match cmd is where "known value" and "placeholder with guidance" branch: a detected value renders as a live name = "…" line, an absent one renders as a commented # TODO. A serializer can't express that — there's no struct field that means "leave a note for the human here."

    Hand-rendering does put one job on you: emitting valid TOML. The PR's doc comment spells out why this particular template is safe without any escaping logic —

    // crates/adj/src/scaffold.rs
    +/// Render the `adjacent.toml` body. `None` fields become TODO placeholders. Detected commands
    +/// come from a fixed vocabulary (none contain `"`) and `sanitize_name` guarantees `name` is
    +/// `[a-z0-9-]`, so no TOML string escaping is needed here.
    +

    — and a unit test nails the door shut by parsing the rendered string back through the real toml crate. If a future edit ever produces something that isn't valid TOML, this goes red:

    // crates/adj/src/scaffold.rs
    +assert!(
    +    toml::from_str::<toml::Value>(&toml).is_ok(),
    +    "invalid toml: {toml}"
    +);
    +

    That's the safety net that makes hand-rendering defensible: you give up serde's guaranteed-valid output, so you buy it back with a round-trip assertion.

    Then the write, in client::add. The whole thing is guarded on the file not already existing:

    // crates/adj/src/client.rs
    +let manifest = canon.join("adjacent.toml");
    +if !manifest.exists() {
    +    let scaffold = scaffold::build(&canon);
    +    std::fs::write(&manifest, &scaffold.toml)
    +        .with_context(|| format!("writing {}", manifest.display()))?;
    +    // ... branch on detected name/cmd ...
    +}
    +

    std::fs::write is the one-liner form — open (creating or truncating), write the whole buffer, close, all in a call. The if !manifest.exists() in front of it is the not-clobbering rule: a user who already hand-wrote an adjacent.toml never has it silently replaced by a generated one. (An integration test asserts exactly this — an existing manifest comes back byte-identical after adj add.)

    Why it matters

    Two traps, one on each side.

    Reaching for the serializer feels like the disciplined choice and quietly produces a worse file. toml::to_string(&cfg) is faithful and valid — and strips the comments that were the point. The user opens a two-line file with no hint of what else they can set or what they still need to fill in. Knowing when not to serialize is the actual skill here: struct-to-TOML for data you'll read back by machine, hand-rendered text for a file a human edits next.

    Skipping the existence check is the destructive one. std::fs::write truncates — point it at a path that already holds a carefully-tuned manifest and the old contents are gone, no prompt, no backup. The !exists() guard is what keeps a convenience feature from eating the user's work.

    One honest caveat: exists()-then-write() is check-then-act, so there's a hair-thin window where the file could appear between the two calls. For an interactive command a person runs at a shell, that window doesn't matter. If you needed it to matter — a concurrent or hostile setting — the atomic form is std::fs::OpenOptions::new().create_new(true), which fails outright if the file already exists rather than checking first. adj add doesn't need it; knowing the upgrade path exists is the point.

    Related lessons

    • PR #41 is the sibling technique — it also builds a document with format! string templating, and teaches the raw-string-literal (r#"…"#) mechanics that make a multi-line template readable. This lesson is the decision that sits above that mechanism: why you hand-render a config at all instead of serializing a struct, plus the disk write. #41 is how to template; #75 is when (and how not to clobber).
    • PR #19 is the exact mirror: it reads adjacent.toml back in with #[derive(Deserialize)] and Option fields. #75 writes the file #19 parses — and #19's strict "missing cmd is an error" is what the # TODO: set your dev command placeholder is steering the user away from tripping.
    • PR #22 serializes DTOs to JSON with serde — because that output is consumed by jq, a machine, where a stable derived shape is exactly right. Put next to #75 they're the clean contrast: serialize for machines, hand-render for humans.

    Dig deeper

    • std::fs::write — the create-or-truncate one-shot write used here.
    • std::fs::OpenOptions::create_new — the atomic "fail if it already exists" alternative to a separate exists() check.
    • The toml crate — toml::from_str (used in the round-trip test) and toml::to_string (the serializer this PR deliberately doesn't use for generation, because it drops comments).
    +
    +
    + + + + diff --git a/ent/lessons/76-derived-env-precedence-order.html b/ent/lessons/76-derived-env-precedence-order.html new file mode 100644 index 0000000..5ae8e31 --- /dev/null +++ b/ent/lessons/76-derived-env-precedence-order.html @@ -0,0 +1,65 @@ + + + + + + + PR #76 — Inject an ADJ_* boot environment into supervised apps + + + + + + +
    +
    + adj.ac/ent + all lessons +
    +
    +

    PR #76 — Inject an ADJ_* boot environment into supervised apps

    +
    +

    Compute a namespace of derived values into a Vec<(String, String)> with format!, then express precedence as insertion order — inject that layer last so Command::env's last-writer-wins overwrites anything the user set.

    +

    format!last-writer-winsderived-data

    +

    2026-06-24 · +1303/−12 · View PR

    +
    +

    The situation

    PR #17 injected one variable: $PORT. That let an app bind, but not address itself. A dev server behind the proxy still defaults its own links to localhost:<port> — wrong for every URL a user clicks through *.adj.ac. The daemon knows the real facts (the name, the .adj.ac host, the listener ports); the app can't reconstruct them. So the daemon now hands the app a whole reserved namespace — ADJ_NAME, ADJ_HOST, and four base URLs — computed at boot. And because they're authoritative, they have to beat anything the user set in env_file or [env].

    The Rust idea

    Two ideas, tied together.

    Deriving data with format!. format! is println!'s sibling that returns instead of prints: it allocates a brand-new owned String from a template. format!("https://{host}") interpolates the host variable directly (Rust's inline captures — the name in the braces is a real variable in scope). Owned is the key word: the returned String carries its own bytes, so a function can return it or push it into a Vec without any borrow outliving the stack frame it came from. That's what makes a pure "build the values" helper possible — feed it a name and two ports, get back owned pairs, no daemon or socket required to test it.

    Precedence as insertion order. Command::env(key, value) inserts-or-overrides a single entry (see #17). When you apply several layers to the same command, the order you apply them in is the precedence policy — the last write for a given key wins. There's no merge function, no priority flag, no "if not already set" check. To make one layer authoritative, you run it last. Precedence stops being a rule buried in a helper and becomes a visible fact about the sequence of statements.

    In this PR

    A pure helper builds the derived pairs. Note it returns Vec<(String, String)> — an ordered list of owned key/value tuples, not a map — because the caller only iterates it, and order is exactly what the precedence step needs:

    // crates/adj/src/supervisor.rs
    +fn adj_env(name: &str, http: Option<u16>, https: Option<u16>) -> Vec<(String, String)> {
    +    let host = format!("{name}{}", crate::proxy::HOST_SUFFIX);
    +    let mut vars = vec![
    +        ("ADJ_NAME".to_string(), name.to_string()),
    +        ("ADJ_HOST".to_string(), host.clone()),
    +        ("ADJ_URL".to_string(), format!("https://{host}")),
    +        ("ADJ_URL_HTTP".to_string(), format!("http://{host}")),
    +    ];
    +    if let Some(p) = https {
    +        vars.push(("ADJ_URL_DIRECT".to_string(), format!("https://{host}:{p}")));
    +    }
    +    if let Some(p) = http {
    +        vars.push((
    +            "ADJ_URL_HTTP_DIRECT".to_string(),
    +            format!("http://{host}:{p}"),
    +        ));
    +    }
    +    vars
    +}
    +
    • host is computed once, then reused. host.clone() hands one owned copy to the ADJ_HOST pair while the original stays live for the three format! calls below it — each of those borrows host (&host under the hood) to read its bytes, so host must still exist.
    • The four unconditional entries go in a vec![…] literal. The two _DIRECT URLs are conditional: if let Some(p) = https unwraps the port only when it resolved, and .pushes a pair carrying it. A None port means that URL is simply absent — no entry pointing at a dead port. Optionality is expressed by whether the pair exists, not by an empty string.
    • Everything is an owned String. "ADJ_NAME".to_string() and each format! produce data the Vec owns outright, so the whole thing can be returned by value.

    Then up() applies it as the final env layer:

    // crates/adj/src/supervisor.rs
    +command.env(port_env, port.to_string());
    +// Daemon-owned ADJ_* namespace: the app's own external identity and URLs. Injected
    +// after env_file/[env] so these authoritative values win over anything a user set.
    +for (k, v) in adj_env(&name, self.proxy_ports.http(), self.proxy_ports.https()) {
    +    command.env(k, v);
    +}
    +

    The full precedence chain, top to bottom, is env_file → [env] → PORT → ADJ_*. Each command.env call overwrites the same key from an earlier layer. The ADJ_* loop runs last, so if a user set ADJ_HOST in their [env] table, the daemon's value silently overwrites it. That's the entire enforcement of "reserved, daemon-owned namespace" — no validation, no rejection, just position in the sequence.

    Because adj_env is pure and returns plain data, the unit tests skip the daemon entirely and collect the pairs into a HashMap for random-access lookup by key:

    // crates/adj/src/supervisor.rs
    +let vars: std::collections::HashMap<String, String> =
    +    adj_env("alannorton-com", Some(8080), Some(8443))
    +        .into_iter()
    +        .collect();
    +assert_eq!(vars["ADJ_URL_DIRECT"], "https://alannorton-com.adj.ac:8443");
    +

    The builder returns a Vec (order matters, for precedence and for iteration); the test .collect()s it into a HashMap because it only wants "give me the value for this key." Same pairs, two container shapes, each chosen for what the code at hand does with them.

    Why it matters

    In a language where you mutate a shared, global environment or reach for a merge(defaults, overrides) helper, precedence is implicit and easy to get backwards. Someone reorders two blocks, or flips an argument, and user config silently starts winning over values that were supposed to be authoritative — a bug that type-checks and passes most tests. Here the policy is one readable fact: the ADJ_* loop is the last thing that touches the environment before spawn, so it wins by construction. Move it above the [env] layer and the precedence inverts — but you'd see that in the diff, because it's a line order, not a hidden flag.

    The derived-data half pays off in testing. format! returning owned Strings lets adj_env be a pure function of (name, http, https) — no I/O, no clock, no socket. Every value format, the worktree-key case, and the "port unresolved → drop the URL" rule get asserted in plain unit tests that run in microseconds, with the end-to-end test left to prove only that the wiring reaches a real child.

    Related lessons

    • PR #17 is the one-variable version of this exact mechanic: Command::env as insert-or-override, and why the value must be an owned String. #17 injects PORT alone; #76 generalizes it into a computed namespace and makes the layer order load-bearing. Read #17 first for the builder and inheritance model; this lesson is about deriving a set of values and using order as precedence.
    • PR #45 builds rows the cousin way — map + collect into a collection — where order doesn't carry meaning the way it does here.

    Dig deeper

    • std::fmt / the format! macro — the template syntax, inline argument captures ({host}), and that it returns an owned String.
    • std::collections::HashMap — the "Which collection?" preamble at the top of the std::collections module is the canonical guide to Vec vs HashMap: sequence-with-order vs lookup-by-key, exactly the choice this PR makes twice.
    +
    +
    + + + + diff --git a/ent/lessons/build.mjs b/ent/lessons/build.mjs index 5652205..717a1c3 100644 --- a/ent/lessons/build.mjs +++ b/ent/lessons/build.mjs @@ -532,3 +532,75 @@ export function renderIndexPage(readmeMd, { theme = THEME } = {}) { `; } + +import { readFileSync, writeFileSync, readdirSync, mkdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function checkCrossLinks(lessons) { + const known = new Set(lessons.map((l) => `${l.pr}-${l.slug}.md`)); + const errors = []; + const linkRe = /\]\((\d+-[a-z0-9-]+\.md)\)/g; + for (const l of lessons) { + for (const m of l.bodyMarkdown.matchAll(linkRe)) { + if (!known.has(m[1])) errors.push(`${l.pr}-${l.slug}.md links to missing lesson '${m[1]}'`); + } + } + return errors; +} + +export function buildSite({ lessons, glossary, readmeMd, theme = THEME }) { + const glossaryIndex = buildGlossaryIndex(glossary); + const files = new Map(); + for (const lesson of lessons) { + files.set(`${lesson.pr}-${lesson.slug}.html`, renderLessonPage(lesson, { glossaryIndex, theme })); + } + files.set('index.html', renderIndexPage(readmeMd, { theme })); + files.set('lessons.css', renderStylesheet(theme)); + files.set('drawer.js', renderDrawerScript()); + files.set('glossary.js', renderGlossaryScript(glossary)); + return files; +} + +export async function main(argv) { + const check = argv.includes('--check'); + const root = process.cwd(); + const { lessonsDir, readme, outDir } = THEME.paths; + + const glossary = JSON.parse(readFileSync(join(root, lessonsDir, 'glossary.json'), 'utf8')); + const { errors } = validateGlossary(glossary); + + const files = readdirSync(join(root, lessonsDir)).filter((f) => /^\d+-[a-z0-9-]+\.md$/.test(f)); + const lessons = files + .map((f) => parseLesson(f, readFileSync(join(root, lessonsDir, f), 'utf8'))) + .sort((a, b) => a.pr - b.pr); + const linkErrors = checkCrossLinks(lessons); + + const allErrors = [...errors, ...linkErrors]; + if (allErrors.length) { + console.error('build failed:\n' + allErrors.map((e) => ' - ' + e).join('\n')); + process.exitCode = 1; + return; + } + + const missing = coverageReport(lessons, buildGlossaryIndex(glossary)); + if (missing.length) { + console.log(`coverage: ${missing.length} backticked terms not in glossary (top 10):`); + console.log(missing.slice(0, 10).map((m) => ` ${m.count}x ${m.term}`).join('\n')); + } + + if (check) { + console.log(`check OK — ${lessons.length} lessons, ${Object.keys(glossary).length} glossary entries`); + return; + } + + const readmeMd = readFileSync(join(root, readme), 'utf8'); + const out = buildSite({ lessons, glossary, readmeMd }); + mkdirSync(join(root, outDir), { recursive: true }); + for (const [name, content] of out) writeFileSync(join(root, outDir, name), content); + console.log(`wrote ${out.size} files to ${outDir}`); +} + +if (import.meta.main) { + main(process.argv.slice(2)); +} diff --git a/ent/lessons/build.test.mjs b/ent/lessons/build.test.mjs index 535d27a..876cf15 100644 --- a/ent/lessons/build.test.mjs +++ b/ent/lessons/build.test.mjs @@ -310,3 +310,31 @@ test('parseReadmeIndex unescapes table-cell pipe escapes in takeaways', () => { const { rows } = parseReadmeIndex(md); assert.equal(rows[0].takeaway, 'use `.unwrap_or_else(|err|)` first'); }); + +import { buildSite, checkCrossLinks } from './build.mjs'; + +const GL = { + arc: { term: 'Arc', aliases: ['Arc'], short: 's', why: 'w', prereqs: [], link: { label: 'x', url: 'y' } }, +}; +const LESSONS = [{ + pr: 16, slug: 'supervised-app-with-logs', title: 'PR #16 — X', + lesson: 'Clone an `Arc`.', tags: ['Arc'], + merged: { date: '2026-06-08', delta: '+1/−0', url: 'https://example.com/16' }, + bodyMarkdown: 'Boot with an `Arc`. See [PR #17](17-inject-port-into-child-env.md).', +}]; + +test('buildSite returns pages plus the four shared assets', () => { + const files = buildSite({ lessons: LESSONS, glossary: GL, readmeMd: '## The lessons\n' }); + assert.ok(files.has('16-supervised-app-with-logs.html')); + assert.ok(files.has('index.html')); + assert.ok(files.has('lessons.css')); + assert.ok(files.has('drawer.js')); + assert.ok(files.has('glossary.js')); + assert.match(files.get('16-supervised-app-with-logs.html'), /data-term="arc"/); +}); + +test('checkCrossLinks flags a link with no target lesson', () => { + const errs = checkCrossLinks(LESSONS); // #17 not in the list + assert.equal(errs.length, 1); + assert.match(errs[0], /17-inject-port-into-child-env/); +}); diff --git a/ent/lessons/drawer.js b/ent/lessons/drawer.js new file mode 100644 index 0000000..c285bf5 --- /dev/null +++ b/ent/lessons/drawer.js @@ -0,0 +1,72 @@ +/* generated by build.mjs; edit docs/lessons/*.md */ +(() => { + const G = window.__GLOSSARY__ || {}; + const scrim = document.createElement('div'); + scrim.className = 'drawer-scrim'; + const drawer = document.createElement('aside'); + drawer.className = 'drawer'; + drawer.setAttribute('role', 'dialog'); + drawer.setAttribute('aria-label', 'Term explanation'); + drawer.innerHTML = + '' + + '' + + '
    '; + document.body.append(scrim, drawer); + const body = drawer.querySelector('.d-body'); + // Escapes text and attribute values alike (includes ") so a quote in a glossary key or URL can't break out of an attribute. + const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + let history = []; + + function render(key) { + const e = G[key]; + if (!e) return; + const prereqs = (e.prereqs || []) + .filter((k) => G[k]) + .map((k) => '') + .join(''); + const link = e.link ? '

    Dig deeper

    ' : ''; + body.innerHTML = + '

    ' + esc(e.term) + '

    ' + + '

    ' + esc(e.short || '') + '

    ' + + (e.why ? '

    Why it matters

    ' + esc(e.why) + '

    ' : '') + + (prereqs ? '

    Learn first

    ' + prereqs + '
    ' : '') + + link; + // Back is shown only once we've descended past the root term. + drawer.classList.toggle('has-history', history.length > 1); + } + + function open(key) { + history = [key]; + render(key); + scrim.classList.add('open'); + drawer.classList.add('open'); + } + function close() { + scrim.classList.remove('open'); + drawer.classList.remove('open'); + history = []; + drawer.classList.remove('has-history'); + } + + document.addEventListener('click', (ev) => { + const term = ev.target.closest('.term'); + if (term && term.dataset.term) { + ev.preventDefault(); + if (ev.target.closest('.drawer')) { + history.push(term.dataset.term); // descend into a prereq + render(term.dataset.term); + } else { + open(term.dataset.term); // open from the lesson prose + } + return; + } + if (ev.target.closest('.drawer .close')) { close(); } + }); + drawer.querySelector('.back-link').addEventListener('click', () => { + history.pop(); + const prev = history[history.length - 1]; + if (prev) { render(prev); } else { close(); } + }); + scrim.addEventListener('click', close); + document.addEventListener('keydown', (ev) => { if (ev.key === 'Escape') close(); }); +})(); diff --git a/ent/lessons/glossary.js b/ent/lessons/glossary.js new file mode 100644 index 0000000..17d9e1e --- /dev/null +++ b/ent/lessons/glossary.js @@ -0,0 +1,2 @@ +/* generated by build.mjs; edit docs/lessons/*.md */ +window.__GLOSSARY__ = {"arc":{"term":"Arc","aliases":["Arc","Arc>","Arc>"],"short":"Atomically reference-counted shared pointer. Cloning it bumps a refcount and hands back a second owner of the same value, not a copy.","why":"Lets two threads or tasks own one value at once. It is how a detached task can touch state the spawner still holds.","prereqs":["ownership","move","send-sync"],"link":{"label":"Rust Book 16.3","url":"https://doc.rust-lang.org/book/ch16-03-shared-state.html"}},"mutex":{"term":"Mutex","aliases":["Mutex"],"short":"A lock guarding interior data so only one holder mutates at a time. tokio's async Mutex yields the thread while waiting instead of blocking it.","why":"Shared ownership alone still races on writes. The Mutex is what makes the shared mutation safe, checked at compile time.","prereqs":["ownership","send-sync"],"link":{"label":"Rust Book 16.3","url":"https://doc.rust-lang.org/book/ch16-03-shared-state.html"}},"ownership":{"term":"ownership","aliases":[],"short":"Every value has exactly one owner; when the owner goes out of scope the value is dropped. No garbage collector, no manual free.","why":"The single-owner rule is the root of Rust's memory safety and the reason sharing has to be made explicit.","prereqs":[],"link":{"label":"Rust Book 4.1","url":"https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html"}},"move":{"term":"move","aliases":["move semantics"],"short":"Assigning or passing a non-Copy value transfers ownership; the source can no longer be used. `move` on a closure forces it to own its captures.","why":"A spawned task must own what it touches. `move` is how you hand ownership into it so it can outlive the caller.","prereqs":["ownership"],"link":{"label":"Rust Book 4.1","url":"https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html#variables-and-data-interacting-with-move"}},"send-sync":{"term":"Send / Sync","aliases":["Send","Sync","Send + Sync"],"short":"Marker traits the compiler infers: Send means a value can move to another thread; Sync means it can be shared by reference across threads.","why":"They are what let the compiler reject data races before the program runs. Arc is Send/Sync; Rc is not.","prereqs":["ownership"],"link":{"label":"Rust Book 16.4","url":"https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html"}}}; diff --git a/ent/lessons/index.html b/ent/lessons/index.html new file mode 100644 index 0000000..16bf2a7 --- /dev/null +++ b/ent/lessons/index.html @@ -0,0 +1,69 @@ + + + + + + + adj.ac/ent — Rust, one PR at a time + + + + + + +
    +
    + adj.ac/ent + Rust, one PR at a time +
    +
    +

    Rust, one PR at a time

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    #16Arc<Mutex<T>> + tokio::spawnA detached task must own what it touches ('static); clone an Arc to share state, a Mutex to mutate it safely.
    #17Command env injectionCommand is a builder that inherits the parent env by default; .env(k, v) layers one variable on before .spawn().
    #19Option<T> + serde DeserializeOption<T> is how a field says "may be absent"; serde fills a missing Option with None for you.
    #22serde Serialize + wire DTOsEmit public JSON through dedicated DTO types — derive when the shape matches, hand-write impl Serialize to pin an exact contract.
    #24Single-flight with a per-key lockBoot a resource once under concurrent demand: a short map lock hands out a per-name lock, held across the boot while the rest re-check.
    #29Timeouts + custom error enumsBound async work with tokio::time::timeout and a deadline: Instant; model why a wait ended as an enum.
    #34 ◦Client-side redirectsHow a <meta http-equiv="refresh"> redirect works, and why the / in adj.ac/ent is a load-bearing path boundary.
    #36 ◦Docs as the config contractNo Rust — but the ecosystem's answer to doc rot is doctests: cargo test runs the rust examples in your doc comments.
    #37Signals bypass DropA signal-killed process skips destructors, so external cleanup (unlink a socket) must happen in a tokio::signal handler.
    #38Deterministic async testsAssert on observable state you can count, never on timing — and a still-flaky test is a real race, not noise.
    #40Generics + trait boundsOne serve_plain<S> loop, monomorphized per stream type, drives both TCP and TLS at zero runtime cost.
    #41Raw strings + format!r#"…"# bakes a whole doc into the binary verbatim; format!'s {name} capture splices in the dynamic bits.
    #43Implementing a library trait (+ FFI)Impl rcgen's RemoteKeyPair so signing calls out to the Keychain — the CA key never enters process memory.
    #45iter().map().collect()Turn one collection into another with a lazy pipeline that does nothing until collect drives it.
    #46 ◦Repo layout + dual licensingThe web frontend lives outside the Cargo members list so cargo build never sees it; MIT OR Apache-2.0 is the ecosystem default.
    #47Integration test layoutEach file in tests/ is its own crate touching only the public API; a binary-only crate drives itself via CARGO_BIN_EXE_adj.
    #48Don't swallow the Err.unwrap_or(default) throws the error away; .unwrap_or_else(|err| …) hands it to you to surface first.
    #49Parse, don't validatePush the check into the parser so a bad value can't exist downstream — and spend a line making the Err tell the user what to do.
    #50Weak refs + HashMap::retainStore Weak values so a map garbage-collects itself; retain sweeps the dead entries in one in-place pass.
    #51Typed headers as newtypesA HeaderValue is a validated byte-string, not a String; fallible construction is how the type blocks header injection.
    #52Task cancellationDropping a JoinHandle detaches the task; hold it and .abort() — ideally from a Drop guard so it fires on every path.
    #53HTTP upgrades + copy_bidirectionalAn Upgrade trades HTTP framing for the raw stream; claim it with hyper::upgrade::on, then splice both halves.
    #55Races the borrow checker can't catchSend/Sync kill in-process data races; a TOCTOU race over a port or file is still entirely on you.
    #56str::split_onceCut a string at the first delimiter into two borrowed &str slices, returning Option so "no delimiter" is a case, not a bug.
    #60build.rs + env!/option_env!A build script emits cargo:rustc-env=… before compile; env!/option_env! bake those into the binary as &'static str.
    #63What clippy teachesEach lint names an idiom or a footgun; the honest reply is adopt it or #[allow] it with a reason.
    #65 ◦OSS hygiene + crates.io metadataNo Rust — the crates.io analog of .github/ health files is Cargo.toml package metadata (keywords, categories, rust-version).
    #68 ◦Sometimes there's no lessonA ten-line docs deletion. The honest move is to say so — that's the series' principle in action.
    #69 ◦Distributing a Rust CLIHomebrew tap (prebuilt, no toolchain) vs cargo install (compiles to ~/.cargo/bin) vs a raw tarball — who eats the complexity.
    #75Generating a config fileHand-render human-editable TOML so comments survive; write with std::fs::write behind a !path.exists() guard so you never clobber.
    #76Derived data + precedence orderCompute a namespace with format! into a Vec<(k, v)>; inject it last so Command::env's last-writer-wins makes it win.
    +

    Reading paths by theme

    +
      +
    • Shared state & async concurrency — #16 → #24 → #50 → #52 → #55
    • +
    • Error handling — #29 → #48 → #49
    • +
    • Traits & generics — #40 (bounds / static dispatch) → #43 (impl / dynamic dispatch)
    • +
    • Serde, config & data — #19 → #22 → #75 → #76
    • +
    • The proxy stack — #24 → #40 → #51 → #53
    • +
    • Process lifecycle & env — #17 → #37 → #76
    • +
    • Strings & slices — #41 → #45 → #56
    • +
    • Testing — #38 → #47 → #55
    • +
    • Tooling, ecosystem & distribution — #60 → #63 → #65 → #69
    • +
    +
    +
    + + diff --git a/ent/lessons/lessons.css b/ent/lessons/lessons.css new file mode 100644 index 0000000..2132359 --- /dev/null +++ b/ent/lessons/lessons.css @@ -0,0 +1,56 @@ +/* generated by build.mjs; edit docs/lessons/*.md */ +:root { + --ink: #0a0a0a; --paper: #ededea; --soft: #b5b5af; --dim: #7a7a74; + --rule: #1f1f1d; --accent: #d4a574; --accent-dim: #8a6a44; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { background: var(--ink); color: var(--paper); font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 15px; line-height: 1.65; -webkit-font-smoothing: antialiased; } +body { min-height: 100vh; padding: 3rem clamp(1.25rem, 6vw, 5rem); } +.frame { max-width: 760px; margin: 0 auto; } +header { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 3rem; } +.wordmark { font-weight: 700; font-size: 1.1rem; letter-spacing: -0.03em; text-decoration: none; } +.wordmark .a { color: var(--paper); } .wordmark .b { color: var(--accent); } .wordmark .slash { color: var(--rule); font-weight: 300; margin: 0 0.1em; } +.back { font-size: 12px; color: var(--dim); text-transform: uppercase; letter-spacing: 0.1em; text-decoration: none; border-bottom: 1px dotted var(--dim); } +.back:hover { color: var(--accent); border-color: var(--accent); } +article h1 { font-size: clamp(1.6rem, 4vw, 2.3rem); font-weight: 700; letter-spacing: -0.02em; line-height: 1.15; margin-bottom: 1.5rem; } +.lesson-header { border-left: 2px solid var(--accent-dim); padding-left: 1.1rem; margin-bottom: 2.5rem; } +.lesson-header .lesson { font-size: 1.15rem; font-weight: 300; margin-bottom: 0.75rem; } +.lesson-header .tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.6rem; } +.lesson-header .meta { font-size: 12px; color: var(--dim); letter-spacing: 0.04em; } +article h2 { font-size: 1.25rem; margin: 2.5rem 0 1rem; padding-top: 1.25rem; border-top: 1px solid var(--rule); } +article h3 { font-size: 1.05rem; margin: 1.75rem 0 0.75rem; } +article p { margin-bottom: 1rem; color: var(--paper); } +article ul, article ol { margin: 0 0 1rem 1.4rem; } +article li { margin-bottom: 0.35rem; } +article a { color: var(--paper); text-decoration: none; border-bottom: 1px solid var(--accent-dim); } +article a:hover { color: var(--accent); border-color: var(--accent); } +code { font-family: inherit; color: var(--accent); font-size: 0.95em; } +pre { background: rgba(255,255,255,0.02); border: 1px solid var(--rule); border-left: 2px solid var(--accent-dim); padding: 1rem 1.15rem; margin-bottom: 1.25rem; overflow-x: auto; } +pre code { color: var(--paper); font-size: 13px; line-height: 1.5; } +.term { font: inherit; background: none; border: none; padding: 0; cursor: pointer; border-bottom: 1px dotted var(--accent); } +.term:hover, .term:focus-visible { background: rgba(212,165,116,0.1); } +.tag code { font-size: 0.85em; } +span.tag { border-bottom: 1px dotted var(--rule); } +table.index { width: 100%; border-collapse: collapse; margin-bottom: 2.5rem; font-size: 0.9rem; } +table.index td { border-bottom: 1px solid var(--rule); padding: 0.6rem 0.75rem 0.6rem 0; vertical-align: top; } +table.index td.pr { white-space: nowrap; } +table.index td.pr a { color: var(--accent); border: none; } +table.index td.takeaway { color: var(--soft); } +table.index tr.sparse td { color: var(--dim); } +table.index .mark { color: var(--accent-dim); } +/* Drawer (built by drawer.js) */ +.drawer-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.15s; z-index: 10; } +.drawer-scrim.open { opacity: 1; } +.drawer { position: fixed; top: 0; right: 0; height: 100%; width: min(360px, 90vw); background: var(--ink); border-left: 1px solid var(--rule); transform: translateX(100%); transition: transform 0.18s ease-out; z-index: 11; padding: 1.75rem 1.5rem; overflow-y: auto; } +.drawer.open { transform: none; } +.drawer .close { position: absolute; top: 1rem; right: 1.1rem; background: none; border: none; color: var(--dim); font: inherit; font-size: 1.2rem; cursor: pointer; } +.drawer .close:hover { color: var(--accent); } +.drawer .back-link { background: none; border: none; color: var(--dim); font: inherit; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; cursor: pointer; margin-bottom: 1rem; display: none; } +.drawer.has-history .back-link { display: inline-block; } +.drawer .d-term { color: var(--accent); font-weight: 700; font-size: 1.1rem; margin-bottom: 0.9rem; word-break: break-word; } +.drawer .d-label { font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--dim); margin: 1.2rem 0 0.4rem; } +.drawer .d-short, .drawer .d-why { color: var(--paper); font-size: 0.95rem; } +.drawer .d-prereqs { display: flex; flex-wrap: wrap; gap: 0.4rem; } +.drawer .d-prereqs .term { border: 1px solid var(--accent-dim); border-radius: 2px; padding: 0.2rem 0.55rem; } +.drawer .d-link a { color: var(--accent); font-size: 0.9rem; } +@media (max-width: 540px) { body { padding: 2rem 1.1rem; } } diff --git a/justfile b/justfile index 02734fb..2922ef9 100644 --- a/justfile +++ b/justfile @@ -35,7 +35,10 @@ release: git tag "$tag" git push origin "$tag" -serve: +build-lessons: + node ent/lessons/build.mjs + +serve: build-lessons npx live-server --port=8081 # Rebuild + re-create the local CA so the macOS keychain ACL trusts the new binary. From 3ebd523a214d63c805d74059ba6b9a5c138eef5f Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Sun, 12 Jul 2026 08:49:01 -0700 Subject: [PATCH 15/19] Author the full glossary and fix the drawer scrim pointer-events Expand docs/lessons/glossary.json from 5 seed terms to 45 foundational entries, each with a plain-language definition, a why-it-matters line grounded in the project, a prerequisite ladder, and a doc link. Regenerate the site: 36 distinct terms now annotate across the lessons, and prereq chains descend to their roots. Also fix the drawer scrim swallowing every click: the closed scrim sat over the page with opacity 0, which does not stop pointer events. It now has pointer-events: none until open. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ej8GZ5gZEaB2nsXG7aMxqn --- docs/lessons/glossary.json | 364 ++++++++++++++++-- ent/lessons/16-supervised-app-with-logs.html | 6 +- .../17-inject-port-into-child-env.html | 8 +- .../19-optional-fields-with-serde.html | 12 +- .../22-serialize-wire-dto-contract.html | 8 +- ent/lessons/24-single-flight-boot-gate.html | 2 +- .../29-deadlines-and-failure-enums.html | 10 +- .../37-signals-bypass-drop-cleanup.html | 6 +- ent/lessons/38-deterministic-async-tests.html | 2 +- ent/lessons/40-serve-https-local-ca.html | 6 +- ent/lessons/41-raw-string-templated-doc.html | 6 +- ent/lessons/43-trait-as-extension-point.html | 6 +- ent/lessons/45-iterator-map-collect-rows.html | 8 +- .../46-revamp-landing-positioning-page.html | 2 +- .../47-integration-tests-separate-crate.html | 2 +- ent/lessons/48-warn-dont-swallow-errors.html | 8 +- ...parse-dont-validate-actionable-errors.html | 8 +- .../50-weak-refs-self-cleaning-map.html | 8 +- ent/lessons/51-typed-http-header-values.html | 6 +- .../52-abort-detached-tokio-tasks.html | 8 +- .../53-http-upgrade-stream-splicing.html | 4 +- .../55-races-beyond-the-borrow-checker.html | 2 +- .../56-split-once-structured-keys.html | 10 +- .../60-compile-time-version-stamping.html | 8 +- ent/lessons/63-what-clippy-teaches.html | 8 +- .../75-hand-render-config-to-disk.html | 4 +- .../76-derived-env-precedence-order.html | 12 +- ent/lessons/build.mjs | 4 +- ent/lessons/glossary.js | 2 +- ent/lessons/lessons.css | 4 +- 30 files changed, 432 insertions(+), 112 deletions(-) diff --git a/docs/lessons/glossary.json b/docs/lessons/glossary.json index 5b0406e..9e58ef2 100644 --- a/docs/lessons/glossary.json +++ b/docs/lessons/glossary.json @@ -1,42 +1,362 @@ { - "arc": { - "term": "Arc", - "aliases": ["Arc", "Arc>", "Arc>"], - "short": "Atomically reference-counted shared pointer. Cloning it bumps a refcount and hands back a second owner of the same value, not a copy.", - "why": "Lets two threads or tasks own one value at once. It is how a detached task can touch state the spawner still holds.", - "prereqs": ["ownership", "move", "send-sync"], - "link": { "label": "Rust Book 16.3", "url": "https://doc.rust-lang.org/book/ch16-03-shared-state.html" } - }, - "mutex": { - "term": "Mutex", - "aliases": ["Mutex"], - "short": "A lock guarding interior data so only one holder mutates at a time. tokio's async Mutex yields the thread while waiting instead of blocking it.", - "why": "Shared ownership alone still races on writes. The Mutex is what makes the shared mutation safe, checked at compile time.", - "prereqs": ["ownership", "send-sync"], - "link": { "label": "Rust Book 16.3", "url": "https://doc.rust-lang.org/book/ch16-03-shared-state.html" } - }, "ownership": { "term": "ownership", "aliases": [], - "short": "Every value has exactly one owner; when the owner goes out of scope the value is dropped. No garbage collector, no manual free.", - "why": "The single-owner rule is the root of Rust's memory safety and the reason sharing has to be made explicit.", + "short": "Every value has exactly one owner. When the owner goes out of scope the value is dropped and its memory freed. No garbage collector, no manual free().", + "why": "The single-owner rule is the root of Rust's memory safety, and the reason sharing, borrowing, and moving all have to be made explicit rather than assumed.", "prereqs": [], "link": { "label": "Rust Book 4.1", "url": "https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html" } }, + "borrowing": { + "term": "borrowing", + "aliases": ["reference"], + "short": "Accessing a value through a reference (&T) without taking ownership. You can have many shared &T reads, or exactly one exclusive &mut T, but never both at once.", + "why": "Borrowing lets code use a value without moving it, and the one-writer-or-many-readers rule is what stops data races at compile time instead of in production.", + "prereqs": ["ownership"], + "link": { "label": "Rust Book 4.2", "url": "https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html" } + }, "move": { "term": "move", "aliases": ["move semantics"], - "short": "Assigning or passing a non-Copy value transfers ownership; the source can no longer be used. `move` on a closure forces it to own its captures.", - "why": "A spawned task must own what it touches. `move` is how you hand ownership into it so it can outlive the caller.", + "short": "Assigning or passing a non-Copy value transfers ownership; the source can no longer be used. `move` on a closure forces it to take ownership of what it captures.", + "why": "A spawned task must own what it touches. `move` is how you hand ownership into a closure so it can outlive the function that created it.", "prereqs": ["ownership"], "link": { "label": "Rust Book 4.1", "url": "https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html#variables-and-data-interacting-with-move" } }, + "lifetime": { + "term": "lifetime", + "aliases": [], + "short": "The span of code for which a reference stays valid. The compiler checks that no reference outlives the value it points at, sometimes with named annotations like 'a.", + "why": "Lifetimes are how Rust guarantees a reference can never dangle. Most are inferred; you only write them when the compiler can't tell which input a returned reference came from.", + "prereqs": ["borrowing"], + "link": { "label": "Rust Book 10.3", "url": "https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html" } + }, + "static-lifetime": { + "term": "'static", + "aliases": ["'static"], + "short": "A lifetime meaning \"lives for the entire program.\" A value is 'static if it owns all its data (borrows nothing) or points at data baked into the binary.", + "why": "A task handed to a background executor must be 'static: it may outlive the function that spawned it, so it can't borrow anything on that function's stack.", + "prereqs": ["lifetime"], + "link": { "label": "Rust Book 10.3", "url": "https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime" } + }, "send-sync": { "term": "Send / Sync", "aliases": ["Send", "Sync", "Send + Sync"], - "short": "Marker traits the compiler infers: Send means a value can move to another thread; Sync means it can be shared by reference across threads.", - "why": "They are what let the compiler reject data races before the program runs. Arc is Send/Sync; Rc is not.", + "short": "Marker traits the compiler infers automatically: Send means a value can be moved to another thread; Sync means it can be shared by reference across threads.", + "why": "They are what let the compiler reject cross-thread data races before the program runs. Arc is Send + Sync; Rc is neither, which is why spawn rejects it.", "prereqs": ["ownership"], "link": { "label": "Rust Book 16.4", "url": "https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html" } + }, + "interior-mutability": { + "term": "interior mutability", + "aliases": ["interior mutability", "RefCell"], + "short": "A pattern where you mutate data through a shared (&T) reference, with a wrapper type (Cell, RefCell, Mutex) enforcing the borrow rules at runtime instead of compile time.", + "why": "Sometimes you genuinely need to mutate shared state; interior mutability moves the one-writer check to runtime so the compiler will allow it, and a Mutex is its thread-safe form.", + "prereqs": ["ownership", "borrowing"], + "link": { "label": "Rust Book 15.5", "url": "https://doc.rust-lang.org/book/ch15-05-interior-mutability.html" } + }, + "reference-counting": { + "term": "reference counting", + "aliases": [], + "short": "Sharing ownership of one value among several owners by keeping a count of how many exist; the value is dropped when the last owner goes away. Rc for one thread, Arc across threads.", + "why": "It's the escape hatch for when a single owner isn't enough. The count is the trade you make: cheap clones that share, at the cost of a runtime counter and no compile-time single-owner guarantee.", + "prereqs": ["ownership"], + "link": { "label": "Rust Book 15.4", "url": "https://doc.rust-lang.org/book/ch15-04-rc.html" } + }, + "rc": { + "term": "Rc", + "aliases": ["Rc"], + "short": "A single-threaded reference-counted pointer. Cloning it bumps a non-atomic count and hands back another owner of the same value.", + "why": "Cheaper than Arc because the count isn't atomic, but that's exactly why it isn't Send: hand it to another thread and the compiler stops you. Reach for Arc when threads are involved.", + "prereqs": ["reference-counting", "send-sync"], + "link": { "label": "Rust Book 15.4", "url": "https://doc.rust-lang.org/book/ch15-04-rc.html" } + }, + "arc": { + "term": "Arc", + "aliases": ["Arc", "Arc>", "Arc>", "Arc"], + "short": "Atomically reference-counted shared pointer. Cloning it bumps a thread-safe refcount and hands back a second owner of the same value, not a copy.", + "why": "Lets two threads or tasks own one value at once. It is how a detached task can touch state the spawner still holds. Wrap it around a Mutex when the shared value also needs mutation.", + "prereqs": ["reference-counting", "move", "send-sync"], + "link": { "label": "Rust Book 16.3", "url": "https://doc.rust-lang.org/book/ch16-03-shared-state.html" } + }, + "weak": { + "term": "Weak", + "aliases": ["Weak"], + "short": "A non-owning reference to an Arc/Rc value. It doesn't keep the value alive; upgrade() returns None once the last strong owner is gone.", + "why": "Breaks reference cycles that would otherwise leak, and lets a map hold entries that clean themselves up: store Weak values and the map can drop the dead ones.", + "prereqs": ["arc"], + "link": { "label": "Rust Book 15.6", "url": "https://doc.rust-lang.org/book/ch15-06-reference-cycles.html" } + }, + "mutex": { + "term": "Mutex", + "aliases": ["Mutex", "Mutex"], + "short": "A lock guarding interior data so only one holder mutates at a time. tokio's async Mutex yields the task while waiting instead of blocking the whole thread.", + "why": "Shared ownership alone still races on writes. The Mutex is what makes the shared mutation safe, and the compiler won't let you reach the data without taking the lock first.", + "prereqs": ["interior-mutability", "send-sync"], + "link": { "label": "Rust Book 16.3", "url": "https://doc.rust-lang.org/book/ch16-03-shared-state.html" } + }, + "async-await": { + "term": "async / .await", + "aliases": [".await", "async"], + "short": "async fn returns a future that does nothing until awaited; .await drives it and yields control back to the runtime while waiting, so one thread can juggle many tasks.", + "why": "It's cooperative concurrency: awaiting a slow I/O call frees the thread to run other tasks instead of blocking, which is how a single process can supervise many servers at once.", + "prereqs": [], + "link": { "label": "Rust Book 17.1", "url": "https://doc.rust-lang.org/book/ch17-01-futures-and-syntax.html" } + }, + "tokio-spawn": { + "term": "tokio::spawn", + "aliases": ["tokio::spawn"], + "short": "Hands a future to the tokio runtime to run concurrently, returning a JoinHandle. The task may run on any worker thread and may outlive the caller, so it must be 'static and Send.", + "why": "The go-to way to run work in the background. Its 'static + Send bounds are exactly why detached tasks reach for Arc> instead of borrowing the caller's state.", + "prereqs": ["async-await", "send-sync", "static-lifetime", "arc"], + "link": { "label": "tokio docs", "url": "https://docs.rs/tokio/latest/tokio/task/fn.spawn.html" } + }, + "timeout": { + "term": "tokio::time::timeout", + "aliases": ["tokio::time::timeout"], + "short": "Wraps a future with a deadline; it resolves to Err once the duration elapses, cancelling the inner future.", + "why": "Bounds how long async work may take so a hung boot or a wedged health check turns into a clean, reportable failure instead of a task that waits forever.", + "prereqs": ["async-await", "result"], + "link": { "label": "tokio docs", "url": "https://docs.rs/tokio/latest/tokio/time/fn.timeout.html" } + }, + "option": { + "term": "Option", + "aliases": ["Option", "Option", "Some", "None"], + "short": "An enum that is either Some(value) or None. Rust has no null; absence is a value you must handle, checked by the compiler.", + "why": "\"Might be missing\" becomes a case the type forces you to consider, so you can't accidentally use a value that isn't there. Serde fills a missing field with None for you.", + "prereqs": [], + "link": { "label": "Rust Book 6.1", "url": "https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html#the-option-enum-and-its-advantages-over-null-values" } + }, + "result": { + "term": "Result", + "aliases": ["Result", "Result", "Ok", "Err"], + "short": "An enum that is either Ok(value) or Err(error). Rust has no exceptions; a fallible operation returns its error as a value you must handle.", + "why": "Failure is in the type signature, so you can't ignore it by accident. How you handle the Err (surface it, default it, propagate it) becomes a deliberate choice at each call site.", + "prereqs": [], + "link": { "label": "Rust Book 9.2", "url": "https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html" } + }, + "question-mark": { + "term": "the ? operator", + "aliases": ["?"], + "short": "On a Result or Option, ? unwraps the Ok/Some value or early-returns the Err/None from the enclosing function. It replaces a match that would just propagate the error.", + "why": "Turns manual error-forwarding into one character, keeping the happy path readable while still handling every failure explicitly.", + "prereqs": ["result", "option"], + "link": { "label": "Rust Book 9.2", "url": "https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html#a-shortcut-for-propagating-errors-the--operator" } + }, + "combinators": { + "term": "combinators", + "aliases": [".ok()", "unwrap_or", "unwrap_or_else", "and_then", "as_deref"], + "short": "Methods on Option/Result that transform them without a match: map, and_then, ok, unwrap_or, unwrap_or_else, and friends. Each takes a closure or a default.", + "why": "They express \"do this if present, else that\" as a chain instead of a branch. The distinction matters: unwrap_or throws the error away, unwrap_or_else hands it to you first.", + "prereqs": ["option", "result", "closure"], + "link": { "label": "Option docs", "url": "https://doc.rust-lang.org/std/option/enum.Option.html" } + }, + "match": { + "term": "match / if let", + "aliases": ["match", "if let"], + "short": "match compares a value against patterns and must cover every case; if let is the shorthand for when you only care about one pattern and ignore the rest.", + "why": "Exhaustive matching means adding a new enum variant makes the compiler point you at every place that must handle it, so \"forgot a case\" becomes a build error.", + "prereqs": ["option"], + "link": { "label": "Rust Book 6.2", "url": "https://doc.rust-lang.org/book/ch06-02-match.html" } + }, + "closure": { + "term": "closure", + "aliases": ["closure"], + "short": "An anonymous function written with |args| body that can capture variables from its surrounding scope, by reference or (with move) by value.", + "why": "Closures are how you pass behavior around: a combinator's transform, a task's body, a Drop guard's cleanup. What they capture, and whether they move it, decides their lifetime.", + "prereqs": ["ownership"], + "link": { "label": "Rust Book 13.1", "url": "https://doc.rust-lang.org/book/ch13-01-closures.html" } + }, + "iterator": { + "term": "iterator", + "aliases": ["Iterator", ".iter()", ".map()"], + "short": "A lazy sequence: .iter() and adapters like .map() build a pipeline that does no work until something drives it. Nothing is computed until you consume it.", + "why": "Laziness means you can chain transforms cheaply and only pay for what you actually take. The pipeline stays declarative; collect (or a for loop) is what makes it run.", + "prereqs": ["closure"], + "link": { "label": "Rust Book 13.2", "url": "https://doc.rust-lang.org/book/ch13-02-iterators.html" } + }, + "collect": { + "term": "collect", + "aliases": [".collect()", "collect"], + "short": "Consumes an iterator and gathers its items into a collection (Vec, HashMap, String, ...). It's the driver that finally runs a lazy iterator pipeline.", + "why": "Turns one collection into another in a single expression, with the target type telling collect what to build. It's where a map/filter chain stops being lazy and produces a value.", + "prereqs": ["iterator"], + "link": { "label": "Iterator::collect", "url": "https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.collect" } + }, + "trait": { + "term": "trait", + "aliases": ["trait"], + "short": "A named set of methods a type can implement, like an interface. Code can then require \"any type that implements this trait\" rather than one concrete type.", + "why": "Traits are Rust's main tool for shared behavior and abstraction, and implementing a library's trait is how you plug your own type into that library's machinery.", + "prereqs": [], + "link": { "label": "Rust Book 10.2", "url": "https://doc.rust-lang.org/book/ch10-02-traits.html" } + }, + "generics": { + "term": "generics", + "aliases": ["generic", "generics"], + "short": "Writing code over a type parameter (like T or S) instead of a concrete type, so one function or struct works for many types.", + "why": "Write the logic once and let the compiler specialize it per type. Combined with trait bounds, generics give you abstraction with no runtime cost.", + "prereqs": ["trait"], + "link": { "label": "Rust Book 10.1", "url": "https://doc.rust-lang.org/book/ch10-01-syntax.html" } + }, + "trait-bound": { + "term": "trait bound", + "aliases": ["trait bound", "trait bounds"], + "short": "A constraint on a generic type, written like T: Display, saying \"T must implement this trait.\" It's what lets generic code actually call methods on T.", + "why": "The bound is the contract: it tells the compiler which methods are available on the type parameter, and tells callers which types they're allowed to pass.", + "prereqs": ["generics"], + "link": { "label": "Rust Book 10.2", "url": "https://doc.rust-lang.org/book/ch10-02-traits.html#traits-as-parameters" } + }, + "static-dispatch": { + "term": "static dispatch", + "aliases": ["static dispatch"], + "short": "Resolving which concrete method to call at compile time. Generic code is monomorphized into a specialized copy per type, so calls are direct with no runtime lookup.", + "why": "It's abstraction for free: the generic loop over a stream type compiles down to the same machine code you'd have written by hand for each concrete type.", + "prereqs": ["generics", "monomorphization"], + "link": { "label": "Rust Book 17.2", "url": "https://doc.rust-lang.org/book/ch17-02-trait-objects.html#trait-objects-perform-dynamic-dispatch" } + }, + "dynamic-dispatch": { + "term": "dynamic dispatch", + "aliases": ["dyn", "dynamic dispatch", "trait object"], + "short": "Resolving the method at runtime through a trait object (dyn Trait), which carries a pointer to the right implementation. One code path handles many types.", + "why": "Trades a tiny runtime indirection for flexibility: you can store different implementing types together or choose one at runtime, which generics can't do.", + "prereqs": ["trait"], + "link": { "label": "Rust Book 17.2", "url": "https://doc.rust-lang.org/book/ch17-02-trait-objects.html" } + }, + "monomorphization": { + "term": "monomorphization", + "aliases": ["monomorphization", "monomorphized"], + "short": "The compiler's process of turning generic code into concrete code by stamping out a specialized copy for each type it's used with.", + "why": "It's why generics have zero runtime overhead: by the time the program runs, each generic use is just ordinary type-specific code. The cost is paid in compile time and binary size.", + "prereqs": ["generics"], + "link": { "label": "Rust Book 10.1", "url": "https://doc.rust-lang.org/book/ch10-01-syntax.html#performance-of-code-using-generics" } + }, + "drop": { + "term": "Drop / RAII", + "aliases": ["Drop", "RAII"], + "short": "The Drop trait runs cleanup code automatically when a value goes out of scope. Tie a resource's release to a value's lifetime and you can't forget to free it.", + "why": "Files close, locks release, and guards fire without a manual step. The catch: a signal-killed process skips destructors, so external cleanup can't rely on Drop alone.", + "prereqs": ["ownership"], + "link": { "label": "Rust Book 15.3", "url": "https://doc.rust-lang.org/book/ch15-03-drop.html" } + }, + "newtype": { + "term": "newtype pattern", + "aliases": ["newtype", "HeaderValue"], + "short": "Wrapping a value in a single-field struct to give it a distinct type, so a validated or domain-specific value can't be confused with a plain one.", + "why": "The wrapper is where you enforce an invariant once (a HeaderValue is a validated byte-string, not any String), and the type system carries that guarantee everywhere after.", + "prereqs": ["trait"], + "link": { "label": "Rust Book 19.3", "url": "https://doc.rust-lang.org/book/ch19-03-advanced-traits.html#using-the-newtype-pattern-to-implement-external-traits" } + }, + "derive-macro": { + "term": "derive macro", + "aliases": ["derive", "#[derive(Deserialize)]", "#[derive(Serialize, Deserialize)]"], + "short": "A #[derive(...)] attribute that generates a trait implementation for your type at compile time, so you don't hand-write boilerplate impls.", + "why": "One line gives you the code for common traits (Debug, Clone, and serde's Serialize/Deserialize). You only hand-write the impl when the derived shape isn't exactly what you need.", + "prereqs": ["trait"], + "link": { "label": "Rust Book 19.5", "url": "https://doc.rust-lang.org/book/ch19-05-macros.html#how-to-write-a-custom-derive-macro" } + }, + "serde": { + "term": "Serialize / Deserialize", + "aliases": ["Serialize", "Deserialize", "serde"], + "short": "serde's two traits for converting Rust values to and from formats like JSON or TOML. Usually derived; #[serde(...)] attributes tune field names, defaults, and shape.", + "why": "Deriving them on a dedicated DTO type pins your public wire format to a Rust type, so the contract lives in code and the compiler keeps the two in sync.", + "prereqs": ["derive-macro"], + "link": { "label": "serde.rs", "url": "https://serde.rs/derive.html" } + }, + "unsafe-ffi": { + "term": "unsafe / FFI", + "aliases": ["unsafe", "FFI"], + "short": "unsafe marks code where you, not the compiler, uphold the safety rules, typically to call across a foreign function interface (FFI) into C or a system library.", + "why": "It's the escape hatch for things the borrow checker can't verify, like calling into the OS keychain. The unsafe block is a deliberate, auditable boundary, not a free-for-all.", + "prereqs": ["ownership"], + "link": { "label": "Rust Book 19.1", "url": "https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html" } + }, + "instant": { + "term": "Instant vs SystemTime", + "aliases": ["Instant", "SystemTime"], + "short": "Instant is a monotonic clock for measuring elapsed time; SystemTime is the wall clock that can jump backward when the OS adjusts it.", + "why": "For deadlines and timeouts you want Instant, because it never goes backward. Reach for SystemTime only when you need an actual calendar time.", + "prereqs": [], + "link": { "label": "std::time::Instant", "url": "https://doc.rust-lang.org/std/time/struct.Instant.html" } + }, + "vec": { + "term": "Vec", + "aliases": ["Vec", "Vec"], + "short": "A growable, heap-allocated array of T. It owns its elements and frees them when it's dropped.", + "why": "The default owned collection. Building one with the vec![] macro or by collecting an iterator is the everyday way to hold a variable number of values.", + "prereqs": ["ownership"], + "link": { "label": "std::vec::Vec", "url": "https://doc.rust-lang.org/std/vec/struct.Vec.html" } + }, + "hashmap": { + "term": "HashMap", + "aliases": ["HashMap"], + "short": "A hash-table mapping owned keys to owned values, with average O(1) insert and lookup.", + "why": "The standard key-value store. It owns what you put in it, and methods like retain let you edit it in place without rebuilding it.", + "prereqs": ["ownership"], + "link": { "label": "std::collections::HashMap", "url": "https://doc.rust-lang.org/std/collections/struct.HashMap.html" } + }, + "hashmap-retain": { + "term": "HashMap::retain", + "aliases": ["HashMap::retain", "retain"], + "short": "Keeps only the entries for which a closure returns true, removing the rest in one in-place pass over the map.", + "why": "The idiomatic way to garbage-collect a map (for example, dropping entries whose Weak value has expired) without allocating a new one or iterating twice.", + "prereqs": ["closure", "hashmap"], + "link": { "label": "HashMap::retain", "url": "https://doc.rust-lang.org/std/collections/struct.HashMap.html#method.retain" } + }, + "str-vs-string": { + "term": "&str vs String", + "aliases": ["String", "&str"], + "short": "String is an owned, growable UTF-8 buffer; &str is a borrowed view into one (or into a string literal). String owns, &str borrows.", + "why": "The distinction is ownership again: take &str in function arguments to borrow without allocating, and return String when the caller needs to own the result.", + "prereqs": ["ownership", "borrowing"], + "link": { "label": "Rust Book 8.2", "url": "https://doc.rust-lang.org/book/ch08-02-strings.html" } + }, + "split-once": { + "term": "str::split_once", + "aliases": ["split_once", "str::split_once"], + "short": "Cuts a string at the first occurrence of a delimiter into two borrowed &str slices, returning Option so \"delimiter not found\" is a None case, not a panic.", + "why": "Exactly right for parsing key=value or label.name: you get both halves as borrows of the original, and the Option makes the missing-delimiter case something you handle, not a bug.", + "prereqs": ["option", "str-vs-string"], + "link": { "label": "str::split_once", "url": "https://doc.rust-lang.org/std/primitive.str.html#method.split_once" } + }, + "format-macro": { + "term": "format!", + "aliases": ["format!"], + "short": "Builds a new String from a template, filling {} placeholders with values. {name} captures a variable of that name directly from scope.", + "why": "The everyday way to assemble strings. Its inline-capture form keeps templated text readable, and it's how derived data like a URL or an env value gets composed.", + "prereqs": ["str-vs-string"], + "link": { "label": "std::format!", "url": "https://doc.rust-lang.org/std/macro.format.html" } + }, + "build-rs": { + "term": "build.rs", + "aliases": ["build.rs"], + "short": "A build script: Rust code the compiler runs before compiling your crate. Printing cargo:rustc-env=KEY=VALUE injects a value the crate can read at compile time.", + "why": "It's how compile-time facts (a git SHA, a build timestamp) get baked into the binary, so the program can report exactly which build it is without any runtime lookup.", + "prereqs": [], + "link": { "label": "Cargo Book", "url": "https://doc.rust-lang.org/cargo/reference/build-scripts.html" } + }, + "env-macro": { + "term": "env! / option_env!", + "aliases": ["env!", "option_env!"], + "short": "Compile-time macros that read an environment variable and bake it into the binary as a &'static str. env! fails the build if it's unset; option_env! yields an Option instead.", + "why": "Paired with build.rs, they turn build-time environment values into constants compiled into the program, so there's nothing to read or fail at runtime.", + "prereqs": ["build-rs", "static-lifetime"], + "link": { "label": "std::env!", "url": "https://doc.rust-lang.org/std/macro.env.html" } + }, + "include-str": { + "term": "include_str!", + "aliases": ["include_str!"], + "short": "A macro that reads a file at compile time and embeds its contents into the binary as a &'static str.", + "why": "Bakes a whole document (a template, a help text) into the executable, so it ships as one self-contained file with nothing to load from disk at runtime.", + "prereqs": ["static-lifetime"], + "link": { "label": "std::include_str!", "url": "https://doc.rust-lang.org/std/macro.include_str.html" } + }, + "command": { + "term": "Command", + "aliases": ["Command"], + "short": "The builder for spawning a child process: chain .env(), .arg(), .stdout() to configure it, then .spawn(). It inherits the parent's environment by default.", + "why": "A textbook builder pattern, and the surface where a supervisor injects a child's port and environment before starting it, one .env() layer at a time.", + "prereqs": [], + "link": { "label": "std::process::Command", "url": "https://doc.rust-lang.org/std/process/struct.Command.html" } } } diff --git a/ent/lessons/16-supervised-app-with-logs.html b/ent/lessons/16-supervised-app-with-logs.html index c15a52d..aaa1cc2 100644 --- a/ent/lessons/16-supervised-app-with-logs.html +++ b/ent/lessons/16-supervised-app-with-logs.html @@ -20,10 +20,10 @@

    PR #16 — Tracer: supervised app with logs

    To mutate state from an async task that outlives the function that spawned it, hand the task an clone — gives shared ownership across threads, the makes the mutation safe.

    -

    tokio::spawn

    +

    2026-06-08 · +2369/−0 · View PR

    -

    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 ().

    is the idiomatic answer to both:

    • — an atomically reference-counted 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.
    • — 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<RefCell<T>> won't compile here: Rc isn't , so tokio::spawn rejects it.

    In this PR

    The supervisor holds all app state behind one shared, locked handle:

    // crates/adj/src/supervisor.rs
    +      

    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 must be — 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 ().

    is the idiomatic answer to both:

    • — an atomically reference-counted 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 , so both can go into spawned tasks.
    • — 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<RefCell<T>> won't compile here: isn't , so rejects it.

    In this PR

    The supervisor holds all app state behind one shared, locked handle:

    // crates/adj/src/supervisor.rs
     #[derive(Default)]
     pub struct Supervisor {
         inner: Arc<Mutex<Inner>>,
    @@ -62,7 +62,7 @@ 

    The situation

    This is the tracer-bullet PR that stood the project up: Ok(pid) } -

    The load-bearing lines are the two just before tokio::spawn:

    • let inner_handle = self.inner.clone(); clones the , not the Inner. The new handle points at the same Mutex<Inner> 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 — one writer at a time, no data race, enforced at compile time.

    Why it matters

    Without , 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 , 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<Mutex<...>> 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 load-bearing lines are the two just before :

    • let inner_handle = self.inner.clone(); clones the , not the Inner. The new handle points at the same Mutex<Inner> 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 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 through the same — one writer at a time, no data race, enforced at compile time.

    Why it matters

    Without , 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 , two tasks writing the same 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<Mutex<...>> 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

    diff --git a/ent/lessons/17-inject-port-into-child-env.html b/ent/lessons/17-inject-port-into-child-env.html index daa51c8..03c680e 100644 --- a/ent/lessons/17-inject-port-into-child-env.html +++ b/ent/lessons/17-inject-port-into-child-env.html @@ -23,7 +23,7 @@

    PR #17 — Inject PORT into supervised processes

    tokio::process::Commandbuilder-patternenv-vars

    2026-06-08 · +322/−12 · View PR

    -

    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:

    // crates/adj/src/supervisor.rs
    +      

    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 . 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 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:

    // crates/adj/src/supervisor.rs
     let port_env = cfg.port_env.as_deref().unwrap_or("PORT");
     let mut command = Command::new("sh");
     command
    @@ -34,18 +34,18 @@ 

    The situation

    A supervised dev server has to bind somewhere, .stdin(Stdio::null()) .stdout(Stdio::from(log_file)) .stderr(Stdio::from(stderr_file)); -

    • cfg.port_env.as_deref().unwrap_or("PORT") collapses Option<String> 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:

    // crates/adj/src/registry.rs
    +
    • cfg.port_env.as_deref().unwrap_or("PORT") collapses Option<String> into a plain : as_deref() borrows the inner as if present, and unwrap_or("PORT") supplies the default when it's . 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 (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:

    // 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<String>,
    -

    #[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:

    // crates/adj/tests/tracer.rs
    +

    #[serde(default)] means an absent key deserializes to 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:

    // 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 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 — 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.
    +

    Read that comment as a demonstration of the semantics: because 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 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 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 — tokio's 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/ent/lessons/19-optional-fields-with-serde.html b/ent/lessons/19-optional-fields-with-serde.html index c03f9ae..6d181ad 100644 --- a/ent/lessons/19-optional-fields-with-serde.html +++ b/ent/lessons/19-optional-fields-with-serde.html @@ -19,15 +19,15 @@

    PR #19 — Env loading: [env] table and env_file pointer

    -

    Option<T> 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.

    -

    optionserdedeserializeconfig

    +

    is how a struct field says "this may be absent" — and serde's derive fills a missing field with for you, while #[serde(default)] is what saves any non- field from erroring when its key is missing.

    +

    optiondeserializeconfig

    2026-06-08 · +509/−1 · View PR

    -

    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<T> — an enum with exactly two variants:

    enum Option<T> {
    +      

    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 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 — an enum with exactly two variants:

    enum Option<T> {
         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<T> — a special case serde already knows about. A missing Option<T> 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:

    // crates/adj/src/registry.rs
    +

    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 , 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.
    • — a special case serde already knows about. A missing field deserializes to on its own, without #[serde(default)], because serde special-cases the "no value" path for options.

    So for the fields in this PR, the #[serde(default)] attribute is technically redundant — they would parse to regardless. It is kept for consistency with the existing port_env field above them, and because the intent reads clearly at a glance. Drop 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:

    // crates/adj/src/registry.rs
     pub struct AppConfig {
         pub name: String,
         pub cmd: String,
    @@ -42,7 +42,7 @@ 

    The situation

    adjacent.toml grew two new, entirely optio #[serde(default)] pub env_file: Option<String>, } -

    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<BTreeMap<String, String>>. 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 Options, and the if let Some(...) is the compiler-enforced "handle both cases":

    // crates/adj/src/supervisor.rs
    +

    name and cmd are bare — required, and a manifest missing either one is rightly rejected. The two new fields are , so is the normal, no-error resting state.

    Notice env is Option<BTreeMap<String, String>>. 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 ) 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 s, and the if let Some(...) is the compiler-enforced "handle both cases":

    // 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() {
    @@ -58,7 +58,7 @@ 

    The situation

    adjacent.toml grew two new, entirely optio } } 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

    +

    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 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 , 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 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 side — not swallowing a parse 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 for the JSON DTOs; PR #75 covers writing an adjacent.toml. This lesson stays strictly on the + reading side.

    Dig deeper

    diff --git a/ent/lessons/22-serialize-wire-dto-contract.html b/ent/lessons/22-serialize-wire-dto-contract.html index 41841dc..03437fb 100644 --- a/ent/lessons/22-serialize-wire-dto-contract.html +++ b/ent/lessons/22-serialize-wire-dto-contract.html @@ -20,10 +20,10 @@

    PR #22 — Agent JSON output on read commands

    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.

    -

    serdeSerializewire-contract

    +

    wire-contract

    2026-06-08 · +1071/−38 · View PR

    -

    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:

    // crates/adj-protocol/src/lib.rs
    +      

    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 , 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 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 / because it round-trips over IPC, and the new started_at is optional so old serialized state still deserializes:

    // crates/adj-protocol/src/lib.rs
     #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
     #[serde(tag = "kind", rename_all = "snake_case")]
     pub enum AppState {
    @@ -65,7 +65,7 @@ 

    The situation

    Every read command (adj list, status 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:

    // crates/adj-protocol/src/lib.rs
    +

    The 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:

    // crates/adj-protocol/src/lib.rs
     #[derive(Debug, Clone, Serialize, Deserialize)]
     pub struct LogRecord {
         pub ts: String,
    @@ -79,7 +79,7 @@ 

    The situation

    Every read command (adj list, status 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 () — this same PR reuses that pattern to write the JSONL: the LogWriter wraps the file in Arc<Mutex<tokio::fs::File>> so the stdout and stderr reader tasks can append LogRecords 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<S: Serializer> is generic over the output format; that's the trait-bound machinery #40 covers, applied to serde's serializer.

    Dig deeper

    +

    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 () — this same PR reuses that pattern to write the JSONL: the LogWriter wraps the file in Arc<Mutex<tokio::fs::File>> so the stdout and stderr reader tasks can append LogRecords without racing.
    • PR #19 teaches the mirror image on the read side — and fields. Here LogRecord round-trips both ways; #22 is the half, #19 the half.
    • PR #40 (generics) — the hand-written fn serialize<S: Serializer> is generic over the output format; that's the trait-bound machinery #40 covers, applied to serde's serializer.

    Dig deeper

    diff --git a/ent/lessons/24-single-flight-boot-gate.html b/ent/lessons/24-single-flight-boot-gate.html index 4018171..94092a2 100644 --- a/ent/lessons/24-single-flight-boot-gate.html +++ b/ent/lessons/24-single-flight-boot-gate.html @@ -62,7 +62,7 @@

    The situation

    The proxy lazy-boots an app on its first request. But a .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<Mutex<()>>. 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 pattern this builds on — shared ownership across tokio::spawned tasks. Read that first; this lesson is the single-flight variation layered on top.

    Dig deeper

    • tokio::sync::Mutex — the async mutex used here; note the docs' guidance on holding a guard across .await, which is exactly what the boot lock does.
    +

    Three requests for flight.adj.ac arrive together. All three miss the fast path. All three call lock_for and get the same Arc<Mutex<()>>. One wins name_lock.lock().await and boots; the other two block on (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 pattern this builds on — shared ownership across ed tasks. Read that first; this lesson is the single-flight variation layered on top.

    Dig deeper

    • tokio::sync::Mutex — the async mutex used here; note the docs' guidance on holding a guard across , which is exactly what the boot lock does.
    diff --git a/ent/lessons/29-deadlines-and-failure-enums.html b/ent/lessons/29-deadlines-and-failure-enums.html index 2ebde07..f876624 100644 --- a/ent/lessons/29-deadlines-and-failure-enums.html +++ b/ent/lessons/29-deadlines-and-failure-enums.html @@ -19,11 +19,11 @@

    PR #29 — Readiness URL, wait-ready, and idle shutdown

    -

    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.

    +

    Bound async work two ways — thread a deadline: Instant through a poll loop, and wrap a single hang-prone await in — then model the outcomes as a custom enum so each caller can react to why the wait ended, not just that it did.

    tokio-timeoutdeadlineserror-enum

    2026-06-08 · +1102/−56 · View PR

    -

    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<T, Elapsed>: 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<u16, anyhow::Error>, 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 matches 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.

    // crates/adj/src/readiness.rs
    +      

    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 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 — . A single 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<T, Elapsed>: 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<u16, anyhow::Error>, 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 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.

    // 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
    @@ -62,13 +62,13 @@ 

    The situation

    When a request arrives for a stopped app, the proxy laz } } } -

    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:

    // crates/adj/src/readiness.rs
    +

    Inside a single probe, bounds the one await that could hang forever — an app that completes the TCP handshake but never writes a response:

    // crates/adj/src/readiness.rs
         tokio::time::timeout(Duration::from_millis(500), attempt)
             .await
             .ok()
             .flatten()
             .unwrap_or(false)
    -

    timeout(...) yields Result<Option<bool>, Elapsed>. .ok() drops the Elapsed into Option<Option<bool>>, .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:

    // crates/adj/src/daemon.rs
    +

    timeout(...) yields Result<Option<bool>, Elapsed>. drops the Elapsed into Option<Option<bool>>, .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:

    // crates/adj/src/daemon.rs
         match readiness_wait(&name, supervisor.as_ref(), &cfg, deadline).await {
             Ok(_) => Ok(Response::Ok),
             Err(ReadinessError::Timeout) => Err(anyhow!(
    @@ -78,7 +78,7 @@ 

    The situation

    When a request arrives for a stopped app, the proxy laz "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<Supervisor> 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 — the future-vs-timer race and its Result<T, Elapsed> return.
    • 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.
    +

    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<Supervisor> 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

    • — the future-vs-timer race and its Result<T, Elapsed> return.
    • 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/ent/lessons/37-signals-bypass-drop-cleanup.html b/ent/lessons/37-signals-bypass-drop-cleanup.html index 067ec85..3874a99 100644 --- a/ent/lessons/37-signals-bypass-drop-cleanup.html +++ b/ent/lessons/37-signals-bypass-drop-cleanup.html @@ -19,11 +19,11 @@

    PR #37 — Handle SIGTERM in daemon socket cleanup

    -

    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.

    +

    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.

    tokio-signaldropgraceful-shutdown

    2026-06-08 · +15/−4 · View PR

    -

    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<Signal> — 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

    // crates/adj/src/daemon.rs
    +      

    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 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 is wired to scope exit.

    Here's the catch that trips people coming from garbage-collected or exception-based languages: 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 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 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<Signal> — 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

    // 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
    @@ -44,7 +44,7 @@ 

    The situation

    The daemon binds a Unix socket at ~/.adjacent/soc 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 returns 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<Mutex<…>> sharing pattern; the same socket.clone() + 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 — ctrl_c, the Unix SignalKind / Signal::recv stream API, and the caveats about async-signal-safety.
    • The Rust Book, ch. 15.3 — Running Code on Cleanup with the Drop Trait (what Drop guarantees — and, by omission, what it doesn't).
    +

    Walking the lines that matter:

    • Installing the handler can fail, so signal(...) returns a . The binds the Signal on success; on failure it warns and returns 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 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 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 " and move on. But 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 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 deliberately, as an RAII guard that aborts a background task when a value goes out of scope. Read them together — #52 shows 's reach (any normal scope exit, guaranteed), #37 shows its limit (a signal walks right past it).
    • PR #16 covers the Arc<Mutex<…>> sharing pattern; the same socket.clone() + 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 — ctrl_c, the Unix SignalKind / Signal::recv stream API, and the caveats about async-signal-safety.
    • The Rust Book, ch. 15.3 — Running Code on Cleanup with the Trait (what guarantees — and, by omission, what it doesn't).
    diff --git a/ent/lessons/38-deterministic-async-tests.html b/ent/lessons/38-deterministic-async-tests.html index 1dd450b..52bb404 100644 --- a/ent/lessons/38-deterministic-async-tests.html +++ b/ent/lessons/38-deterministic-async-tests.html @@ -23,7 +23,7 @@

    PR #38 — Fix flaky proxy single-flight test and add CI workflow

    #[tokio::test]test-determinism

    2026-06-08 · +45/−16 · View PR

    -

    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:

    // crates/adj/tests/proxy.rs  (existing test, unchanged by this PR)
    +      

    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 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:

    // 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}");
    diff --git a/ent/lessons/40-serve-https-local-ca.html b/ent/lessons/40-serve-https-local-ca.html
    index 5dc69e7..395215b 100644
    --- a/ent/lessons/40-serve-https-local-ca.html
    +++ b/ent/lessons/40-serve-https-local-ca.html
    @@ -20,10 +20,10 @@
           

    PR #40 — Serve HTTPS on :8443 via opt-in local CA

    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.

    -

    genericstrait-bounds

    +

    trait-bounds

    2026-06-08 · +1242/−49 · View PR

    -

    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::<TcpStream> and serve_plain::<TlsStream<TcpStream>> 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.
    • — 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:

    // crates/adj/src/proxy.rs
    +      

    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::<TcpStream> and serve_plain::<TlsStream<TcpStream>> 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.
    • — the connection is driven inside , which may run it on a different worker thread, so the stream must be safe to send across threads.
    • — 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:

    // 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.
    @@ -56,7 +56,7 @@ 

    The situation

    The daemon already ran an HTTP reverse proxy. This PR a } }; serve_plain(tls_stream, sup, gate).await; // S = TlsStream<TcpStream> -

    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 , 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

    +

    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 , the 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

    diff --git a/ent/lessons/41-raw-string-templated-doc.html b/ent/lessons/41-raw-string-templated-doc.html index 0d4d404..cc6fd11 100644 --- a/ent/lessons/41-raw-string-templated-doc.html +++ b/ent/lessons/41-raw-string-templated-doc.html @@ -19,11 +19,11 @@

    PR #41 — Add `adj agent-instructions` command

    -

    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.

    +

    A raw string literal (r#"…"#) bakes a whole multi-line document into the binary verbatim — no escaping, no external file — and '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.

    raw-string-literalsformat-macrostring-templating

    2026-06-08 · +783/−0 · View PR

    -

    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):

    // crates/adj/src/agent_docs.rs
    +      

    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 . The two dynamic fields are {name} and {cmd}. Since Rust 2021, 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 call has no arguments after the string.

    In this PR

    The template is a raw string literal; fills the two holes (trimmed):

    // crates/adj/src/agent_docs.rs
     fn render(name: &str, cmd: &str) -> String {
         format!(
             r#"# Working with `{name}` via Adjacent
    @@ -50,7 +50,7 @@ 

    The situation

    adj agent-instructions prints a markdown s 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

    +

    One subtlety: 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 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 , 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 / / 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 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

    diff --git a/ent/lessons/43-trait-as-extension-point.html b/ent/lessons/43-trait-as-extension-point.html index 246a978..b389653 100644 --- a/ent/lessons/43-trait-as-extension-point.html +++ b/ent/lessons/43-trait-as-extension-point.html @@ -55,14 +55,14 @@

    The situation

    The local CA's private key used to sit at ~/.adja 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:

    // crates/adj/src/tls/keychain.rs
    +

    Supporting detail: the FFI seam

    Our sign bottoms out in Apple's C Security framework, and generating the key does too. That crossing is where shows up. A C constant the Rust bindings didn't expose gets linked directly:

    // 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:

    // crates/adj/src/tls/keychain.rs — create_random_key()
    +

    And the raw call into the framework is wrapped in :

    // 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 — 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

    +

    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. 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 — 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

    diff --git a/ent/lessons/45-iterator-map-collect-rows.html b/ent/lessons/45-iterator-map-collect-rows.html index 1bc1921..6f31329 100644 --- a/ent/lessons/45-iterator-map-collect-rows.html +++ b/ent/lessons/45-iterator-map-collect-rows.html @@ -19,14 +19,14 @@

    PR #45 — Add built-in dashboard at status.adj.ac

    -

    iter().map(f).collect() is how you turn one collection into another — a lazy pipeline that does nothing until collect drives it.

    +

    iter().map(f).collect() is how you turn one collection into another — a lazy pipeline that does nothing until drives it.

    iteratorsmap-collect

    2026-06-09 · +817/−0 · View PR

    -

    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<DashboardEntry> and collect fills a Vec. The compiler infers the rest.

    In this PR

    The transform is one line — a pure, synchronous mapping:

    // crates/adj/src/status.rs
    +      

    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: to walk the collection, .map(f) to transform each element, 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, . 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.)

    builds the target type. The type you're collecting into drives it. Annotate the binding as Vec<DashboardEntry> and fills a . The compiler infers the rest.

    In this PR

    The transform is one line — a pure, synchronous mapping:

    // crates/adj/src/status.rs
     let dtos: Vec<DashboardEntry> = 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:

    // crates/adj/src/status.rs
    +

    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. gathers them into the 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:

    // crates/adj/src/status.rs
     async fn snapshot(supervisor: Arc<Supervisor>) -> anyhow::Result<Vec<AppSummary>> {
         let reg = Registry::load()?;
         let mut entries = Vec::with_capacity(reg.apps.len());
    @@ -40,7 +40,7 @@ 

    The situation

    The dashboard at status.adj.ac polls

    Why a plain for loop here and a .map().collect() there? Because this loop .awaits 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 pushes 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<Result<T, E>> 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<Supervisor> threaded through snapshot and handle is the Arc<Mutex> sharing pattern.

    Dig deeper

    • The Rust Book, ch. 13.2 — Processing a Series of Items with Iterators (laziness, map, collect).
    • std::fmt::Write — 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! + +.
    +

    Why a plain for loop here and a .map().collect() there? Because this loop s supervisor.state(name) on every pass. A plain 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 pushes 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 that returns leaves you holding a Vec<Result<T, E>> 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 — 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<Supervisor> threaded through snapshot and handle is the Arc<Mutex> sharing pattern.

    Dig deeper

    • The Rust Book, ch. 13.2 — Processing a Series of Items with Iterators (laziness, map, ).
    • std::fmt::Write — the string-building cousin: when you do assemble text by hand, write!/writeln! append into one instead of allocating a fresh string per + +.
    diff --git a/ent/lessons/46-revamp-landing-positioning-page.html b/ent/lessons/46-revamp-landing-positioning-page.html index 4c3a94b..67ad0f1 100644 --- a/ent/lessons/46-revamp-landing-positioning-page.html +++ b/ent/lessons/46-revamp-landing-positioning-page.html @@ -58,7 +58,7 @@

    The situation

    Adjacent needed a real landing page — not just a word

    The one Rust-ecosystem thing in the diff

    Cargo.toml flips the license, and this is a Rust convention worth knowing:

    # Cargo.toml — before / after
     -license = "MIT"
     +license = "MIT OR Apache-2.0"
    -

    Rust itself, and the overwhelming majority of crates on crates.io, dual-license under MIT OR Apache-2.0. The SPDX OR means a downstream user picks either license — MIT for simplicity, Apache-2.0 for its explicit patent grant. It's the community default; publishing a crate under bare MIT reads as slightly off-convention. The PR added LICENSE-MIT and LICENSE-APACHE files to match, which is the expected on-disk layout for a dual-licensed Rust project.

    Why it matters

    Keep the frontend out of members and Cargo stays fast and honest: cargo build compiles only Rust, cargo test runs only Rust tests, and a broken bit of CSS can never fail a cargo command. Pull the site into the workspace — say, as a crate with a build.rs that embeds the HTML — and now every landing-page tweak recompiles the binary and CI treats a copy edit like a code change. The sibling directory is the cheap, correct boundary.

    On licensing: MIT OR Apache-2.0 isn't cosmetic. The Apache-2.0 half carries an explicit patent grant that bare MIT lacks, and matching the ecosystem default is what lets other Rust projects depend on yours without a legal review.

    Related lessons

    • The justfile here also bumped just serve to --port=8081 so live-server wouldn't collide with the proxy's default :8080 — a small nod to the exact port-contention problem Adjacent exists to solve.

    Dig deeper

    +

    Rust itself, and the overwhelming majority of crates on crates.io, dual-license under MIT OR Apache-2.0. The SPDX OR means a downstream user picks either license — MIT for simplicity, Apache-2.0 for its explicit patent grant. It's the community default; publishing a crate under bare MIT reads as slightly off-convention. The PR added LICENSE-MIT and LICENSE-APACHE files to match, which is the expected on-disk layout for a dual-licensed Rust project.

    Why it matters

    Keep the frontend out of members and Cargo stays fast and honest: cargo build compiles only Rust, cargo test runs only Rust tests, and a broken bit of CSS can never fail a cargo command. Pull the site into the workspace — say, as a crate with a that embeds the HTML — and now every landing-page tweak recompiles the binary and CI treats a copy edit like a code change. The sibling directory is the cheap, correct boundary.

    On licensing: MIT OR Apache-2.0 isn't cosmetic. The Apache-2.0 half carries an explicit patent grant that bare MIT lacks, and matching the ecosystem default is what lets other Rust projects depend on yours without a legal review.

    Related lessons

    • The justfile here also bumped just serve to --port=8081 so live-server wouldn't collide with the proxy's default :8080 — a small nod to the exact port-contention problem Adjacent exists to solve.

    Dig deeper

    diff --git a/ent/lessons/47-integration-tests-separate-crate.html b/ent/lessons/47-integration-tests-separate-crate.html index 405f8bc..d605570 100644 --- a/ent/lessons/47-integration-tests-separate-crate.html +++ b/ent/lessons/47-integration-tests-separate-crate.html @@ -37,7 +37,7 @@

    The situation

    The PR is honest about being two things: a README secti c.env_remove("BIND_PORT"); c } -

    The test itself is async, so it's annotated #[tokio::test] — the async cousin of #[test], which spins up a Tokio runtime around the test body so you can .await inside it (here: awaiting daemon startup, subprocess output, and the container teardown poll):

    // crates/adj/tests/docker.rs
    +

    The test itself is , so it's annotated #[tokio::test] — the async cousin of #[test], which spins up a Tokio runtime around the test body so you can inside it (here: awaiting daemon startup, subprocess output, and the container teardown poll):

    // crates/adj/tests/docker.rs
     #[tokio::test]
     async fn docker_run_lazy_boots_and_down_stops_the_container() {
         if !docker_ready().await {
    diff --git a/ent/lessons/48-warn-dont-swallow-errors.html b/ent/lessons/48-warn-dont-swallow-errors.html
    index a0157b8..bb2e3c8 100644
    --- a/ent/lessons/48-warn-dont-swallow-errors.html
    +++ b/ent/lessons/48-warn-dont-swallow-errors.html
    @@ -19,11 +19,11 @@
         

    PR #48 — Warn instead of silently defaulting on idle_timeout parse failure

    -

    A Result is a value you must consume, but combinators like .unwrap_or(default) let you throw the Err away — reach for .unwrap_or_else(|err| …) when the error is worth surfacing before you fall back.

    -

    resulterror-handlingcombinators

    +

    A is a value you must consume, but combinators like .unwrap_or(default) let you throw the away — reach for .unwrap_or_else(|err| …) when the error is worth surfacing before you fall back.

    +

    resulterror-handling

    2026-06-13 · +75/−2 · View PR

    -

    The situation

    A bad idle_timeout string in adjacent.toml (say "10x") used to fall back to the 15-minute default without a peep. idle_timeout_for called .unwrap_or(Some(DEFAULT_IDLE_TIMEOUT)) on the parse Result, discarding the parse error. Today that path is unreachable — read_app_config validates eagerly — but a future caller that skips validation would turn a config typo into a silent default. This PR keeps the fallback but makes it observable.

    The Rust idea

    Rust has no exceptions. A function that can fail returns Result<T, E> — an enum with two variants, Ok(T) and Err(E). The failure is an ordinary value the caller holds in their hand, and the type system won't let you read the T without first acknowledging the Err might be there. That is the whole safety story: fallibility is visible in the signature and unavoidable at the call site.

    But "unavoidable" is not the same as "surfaced." The standard library ships combinators that collapse a Result down to a plain value, and some of them drop the error on the floor:

    • .unwrap_or(default) — returns the Ok value, or default on Err. The E is never bound to anything; it's gone.
    • .ok() — turns Result<T, E> into Option<T>, discarding E.
    • let Ok(x) = … else { … } — the else arm can't see the error either.

    Each one is a legitimate tool and each one silently swallows the Err. The compiler is happy — you did handle the Result — so the type system stops nudging you the moment you pick a combinator that ignores the payload. Swallowing an error is a code smell precisely because it looks identical to handling it.

    The fix is the lazy sibling: .unwrap_or_else(|err| …) takes a closure that receives the Err value. Now the error is bound to err, and you can log it, count it, or enrich it before returning your fallback. Same default, but the failure is no longer invisible.

    (_else also means the fallback is computed lazily — the closure only runs on Err — but here the real win is getting your hands on err.)

    In this PR

    // crates/adj/src/registry.rs
    +      

    The situation

    A bad idle_timeout string in adjacent.toml (say "10x") used to fall back to the 15-minute default without a peep. idle_timeout_for called .unwrap_or(Some(DEFAULT_IDLE_TIMEOUT)) on the parse , discarding the parse error. Today that path is unreachable — read_app_config validates eagerly — but a future caller that skips validation would turn a config typo into a silent default. This PR keeps the fallback but makes it observable.

    The Rust idea

    Rust has no exceptions. A function that can fail returns — an enum with two variants, Ok(T) and Err(E). The failure is an ordinary value the caller holds in their hand, and the type system won't let you read the T without first acknowledging the might be there. That is the whole safety story: fallibility is visible in the signature and unavoidable at the call site.

    But "unavoidable" is not the same as "surfaced." The standard library ships combinators that collapse a down to a plain value, and some of them drop the error on the floor:

    • .unwrap_or(default) — returns the value, or default on . The E is never bound to anything; it's gone.
    • — turns into , discarding E.
    • let Ok(x) = … else { … } — the else arm can't see the error either.

    Each one is a legitimate tool and each one silently swallows the . The compiler is happy — you did handle the — so the type system stops nudging you the moment you pick a combinator that ignores the payload. Swallowing an error is a code smell precisely because it looks identical to handling it.

    The fix is the lazy sibling: .unwrap_or_else(|err| …) takes a closure that receives the value. Now the error is bound to err, and you can log it, count it, or enrich it before returning your fallback. Same default, but the failure is no longer invisible.

    (_else also means the fallback is computed lazily — the closure only runs on — but here the real win is getting your hands on err.)

    In this PR

    // crates/adj/src/registry.rs
     
     // before:
     Some(s) => parse_idle_timeout(s).unwrap_or(Some(DEFAULT_IDLE_TIMEOUT)),
    @@ -52,7 +52,7 @@ 

    The situation

    A bad idle_timeout string in adjacen logs.contains("invalid idle_timeout") && logs.contains("10x") && logs.contains("900s"), "expected a warn naming the bad value and the default it fell back to, got: {logs}" ); -

    Why it matters

    In a language with exceptions, the equivalent bug is a bare catch (Exception e) {} — the swallowed-error smell everyone recognizes. Rust doesn't force you to write that; it hands you a tidy one-liner (.unwrap_or) that does exactly the same thing and reads as intentional. The lesson is that Rust guarantees you considered the Result, not that you surfaced it. When the fallback is a real recovery worth keeping quiet about, .unwrap_or is fine. When the Err means "someone made a mistake" — a config typo, a malformed input — reach for .unwrap_or_else and at least say so on the way down.

    Related lessons

    • PR #49 tightens the producer side of this same function neighborhood: it makes parse_idle_timeout return a precise Err (rejecting zero durations with a message pointing at off). #48 is about not swallowing that Err; #49 is about making it worth reading.

    Dig deeper

    +

    Why it matters

    In a language with exceptions, the equivalent bug is a bare catch (Exception e) {} — the swallowed-error smell everyone recognizes. Rust doesn't force you to write that; it hands you a tidy one-liner (.unwrap_or) that does exactly the same thing and reads as intentional. The lesson is that Rust guarantees you considered the , not that you surfaced it. When the fallback is a real recovery worth keeping quiet about, .unwrap_or is fine. When the means "someone made a mistake" — a config typo, a malformed input — reach for .unwrap_or_else and at least say so on the way down.

    Related lessons

    • PR #49 tightens the producer side of this same function neighborhood: it makes parse_idle_timeout return a precise (rejecting zero durations with a message pointing at off). #48 is about not swallowing that ; #49 is about making it worth reading.

    Dig deeper

    diff --git a/ent/lessons/49-parse-dont-validate-actionable-errors.html b/ent/lessons/49-parse-dont-validate-actionable-errors.html index f9b3016..123ec2f 100644 --- a/ent/lessons/49-parse-dont-validate-actionable-errors.html +++ b/ent/lessons/49-parse-dont-validate-actionable-errors.html @@ -19,18 +19,18 @@

    PR #49 — Reject zero idle_timeout with a pointer to off

    -

    Push validation into the parser so an invalid value can't exist downstream — and spend the extra line making the Err message tell the user what to do instead.

    +

    Push validation into the parser so an invalid value can't exist downstream — and spend the extra line making the message tell the user what to do instead.

    parse-dont-validateerror-handlingresult

    2026-06-13 · +21/−0 · View PR

    -

    The situation

    idle_timeout = "0s" used to parse cleanly into a zero Duration. That value then flowed into the idle scanner, which compares each app's idle age against its timeout every 500ms — a zero window means the app is always past its timeout, so it gets SIGTERM'd on the very next tick. The app quietly stops itself forever. This PR makes parse_idle_timeout reject zero at the door, and point the user at the thing they almost certainly meant: "off".

    The Rust idea

    "Parse, don't validate." There are two ways to handle an untrusted input.

    • Validate: accept it into your normal type (a Duration), then check if dur.is_zero() at each place that cares. The type says nothing — every consumer has to remember the rule, and a new call site can forget it.
    • Parse: do the check once, at the boundary, and return a type that cannot represent the bad state. Downstream code holds a value that is correct by construction.

    parse_idle_timeout returns Result<Option<Duration>>, and that type already encodes three distinct outcomes:

    • Err(_) — the input was malformed or nonsensical.
    • Ok(None) — idle shutdown is explicitly disabled ("off").
    • Ok(Some(d)) — a real, positive timeout.

    Before this PR, a fourth state leaked through: Ok(Some(Duration::ZERO)), a "valid" timeout that behaves like a self-destruct. Moving the is_zero() check inside the parser deletes that state. After the function returns Ok(Some(d)), every caller can trust d is non-zero without checking — the scanner never has to defend against a zero window, because one can't reach it.

    The second half of the lesson is the Err itself. Rust's Result makes failure a value you must handle, but it says nothing about the quality of the error. Err(anyhow!("invalid duration")) is technically a failure and practically useless. An actionable message names what went wrong and what to do instead.

    In this PR

    The check sits at the end of the parser, right before the only Ok(Some(...)) return:

    // crates/adj/src/registry.rs
    +      

    The situation

    idle_timeout = "0s" used to parse cleanly into a zero Duration. That value then flowed into the idle scanner, which compares each app's idle age against its timeout every 500ms — a zero window means the app is always past its timeout, so it gets SIGTERM'd on the very next tick. The app quietly stops itself forever. This PR makes parse_idle_timeout reject zero at the door, and point the user at the thing they almost certainly meant: "off".

    The Rust idea

    "Parse, don't validate." There are two ways to handle an untrusted input.

    • Validate: accept it into your normal type (a Duration), then check if dur.is_zero() at each place that cares. The type says nothing — every consumer has to remember the rule, and a new call site can forget it.
    • Parse: do the check once, at the boundary, and return a type that cannot represent the bad state. Downstream code holds a value that is correct by construction.

    parse_idle_timeout returns Result<Option<Duration>>, and that type already encodes three distinct outcomes:

    • Err(_) — the input was malformed or nonsensical.
    • Ok(None) — idle shutdown is explicitly disabled ("off").
    • Ok(Some(d)) — a real, positive timeout.

    Before this PR, a fourth state leaked through: Ok(Some(Duration::ZERO)), a "valid" timeout that behaves like a self-destruct. Moving the is_zero() check inside the parser deletes that state. After the function returns Ok(Some(d)), every caller can trust d is non-zero without checking — the scanner never has to defend against a zero window, because one can't reach it.

    The second half of the lesson is the itself. Rust's makes failure a value you must handle, but it says nothing about the quality of the error. Err(anyhow!("invalid duration")) is technically a failure and practically useless. An actionable message names what went wrong and what to do instead.

    In this PR

    The check sits at the end of the parser, right before the only Ok(Some(...)) return:

    // crates/adj/src/registry.rs
     if dur.is_zero() {
         return Err(anyhow!(
             "idle_timeout of zero would stop the app on every idle scan; use `off` to disable idle shutdown"
         ));
     }
     Ok(Some(dur))
    -

    That message is the whole point. It has two clauses: the consequence ("would stop the app on every idle scan") so the user understands why zero is refused, and the fix ("use off to disable idle shutdown") so they know the exact next keystroke. anyhow! is the anyhow crate's macro for building an ad-hoc error from a format string — the same ergonomics as format!, but it produces an Err value.

    The doc comment is updated to explain the why for the next reader, not just the what:

    // crates/adj/src/registry.rs
    +

    That message is the whole point. It has two clauses: the consequence ("would stop the app on every idle scan") so the user understands why zero is refused, and the fix ("use off to disable idle shutdown") so they know the exact next keystroke. anyhow! is the anyhow crate's macro for building an ad-hoc error from a format string — the same ergonomics as , but it produces an value.

    The doc comment is updated to explain the why for the next reader, not just the what:

    // crates/adj/src/registry.rs
     /// Zero durations are rejected: a zero window would make the app a permanent shutdown
     /// candidate (stopped on every scan tick), and users writing `"0s"` almost always mean
     /// "disable" — which is what `"off"` does.
    @@ -47,7 +47,7 @@ 

    The situation

    idle_timeout = "0s" used to parse cleanly ); } } -

    Note what it asserts: not the whole string (brittle), but that the fix — use off`` — is present. The test guards the actionable part of the message specifically.

    Why it matters

    Because the check lives in the parser and read_app_config calls it eagerly, the error surfaces at adj add / adj up time — while the user is looking at their config — instead of manifesting hours later as a server that mysteriously won't stay up. That's the payoff of parse-don't-validate: the failure appears at the boundary, with full context, once. A language that let you pass a raw Duration around and hope every consumer re-checks it would push that failure deep into the runtime, far from the typo that caused it.

    Related lessons

    • PR #48 is the mirror image — the consumer side of this exact function. #48 is about not swallowing the Err that parse_idle_timeout returns (reach for .unwrap_or_else over .unwrap_or); #49 is about making that Err precise and worth reading in the first place. A good error is a handshake: the producer writes something actionable, the consumer bothers to surface it.
    • PR #29 covered error enums (giving failures distinct types). #49 is the complementary skill: even a stringly-typed anyhow! error earns its keep when the message tells the user what to do.

    Dig deeper

    • Parse, don't validate — the canonical essay. Written for Haskell, but the core move (make illegal states unrepresentable by choosing return types that exclude them) is exactly what the Result<Option<Duration>> signature does here.
    • The Rust Book, ch. 9 — Error Handling: Result, the ? operator, and recoverable-vs-unrecoverable failure.
    +

    Note what it asserts: not the whole string (brittle), but that the fix — use off`` — is present. The test guards the actionable part of the message specifically.

    Why it matters

    Because the check lives in the parser and read_app_config calls it eagerly, the error surfaces at adj add / adj up time — while the user is looking at their config — instead of manifesting hours later as a server that mysteriously won't stay up. That's the payoff of parse-don't-validate: the failure appears at the boundary, with full context, once. A language that let you pass a raw Duration around and hope every consumer re-checks it would push that failure deep into the runtime, far from the typo that caused it.

    Related lessons

    • PR #48 is the mirror image — the consumer side of this exact function. #48 is about not swallowing the that parse_idle_timeout returns (reach for .unwrap_or_else over .unwrap_or); #49 is about making that precise and worth reading in the first place. A good error is a handshake: the producer writes something actionable, the consumer bothers to surface it.
    • PR #29 covered error enums (giving failures distinct types). #49 is the complementary skill: even a stringly-typed anyhow! error earns its keep when the message tells the user what to do.

    Dig deeper

    • Parse, don't validate — the canonical essay. Written for Haskell, but the core move (make illegal states unrepresentable by choosing return types that exclude them) is exactly what the Result<Option<Duration>> signature does here.
    • The Rust Book, ch. 9 — Error Handling: , the operator, and recoverable-vs-unrecoverable failure.
    diff --git a/ent/lessons/50-weak-refs-self-cleaning-map.html b/ent/lessons/50-weak-refs-self-cleaning-map.html index cc35018..5f8172a 100644 --- a/ent/lessons/50-weak-refs-self-cleaning-map.html +++ b/ent/lessons/50-weak-refs-self-cleaning-map.html @@ -19,11 +19,11 @@

    PR #50 — Proxy follow-ups: deadline timing, BootGate GC, NAT-replacement warning

    -

    Store Weak references as a map's values so the map garbage-collects itself — an entry lives only while some in-flight boot still holds the strong , and HashMap::retain sweeps the dead ones in one in-place pass.

    +

    Store references as a map's values so the map garbage-collects itself — an entry lives only while some in-flight boot still holds the strong , and sweeps the dead ones in one in-place pass.

    weak-referenceshashmap-retain

    2026-06-13 · +48/−6 · View PR

    -

    The situation

    The proxy's BootGate keeps one lock per app name so concurrent first-requests boot the app exactly once (that mechanism is PR #24). But the original map held an Arc<Mutex<()>> per name, and nothing ever removed entries. Every name the daemon ever routed left a permanent row in the map. The daemon is long-lived, and there is no app-removal RPC to hook a cleanup call onto — so the map only ever grew. A slow leak, but a leak. This PR makes the map bound itself to boots that are actually in flight.

    The Rust idea

    is a strong reference: as long as one exists, the value stays alive. Weak<T> is a non-owning reference to the same value — it points at the data but does not count toward keeping it alive. When the last drops, the value is freed even if Weaks still point at where it used to be.

    Because a Weak might be dangling, you can't dereference it directly. You call .upgrade(), which returns Option<Arc<T>>: Some if the value is still alive (and hands you a fresh strong ref), None if it's gone. That Option is Rust forcing you to handle "the thing I referenced no longer exists" — there is no way to accidentally touch freed memory.

    Two more pieces the diff uses:

    • Arc::downgrade(&arc) mints a Weak from an — that's what you store in the map.
    • weak.strong_count() asks how many strong s are still out there. Zero means the value is dead and the entry is garbage.
    • HashMap::retain(|k, v| bool) walks every entry once and drops those where the closure returns false, editing the map in place — no rebuild, no second allocation.

    Put together: store Weak values, and an entry becomes collectable the moment its last drops. retain(|_, w| w.strong_count() > 0) is the sweep.

    In this PR

    The map's value type flips from to Weak:

    // crates/adj/src/proxy.rs
    +      

    The situation

    The proxy's BootGate keeps one lock per app name so concurrent first-requests boot the app exactly once (that mechanism is PR #24). But the original map held an Arc<Mutex<()>> per name, and nothing ever removed entries. Every name the daemon ever routed left a permanent row in the map. The daemon is long-lived, and there is no app-removal RPC to hook a cleanup call onto — so the map only ever grew. A slow leak, but a leak. This PR makes the map bound itself to boots that are actually in flight.

    The Rust idea

    is a strong reference: as long as one exists, the value stays alive. is a non-owning reference to the same value — it points at the data but does not count toward keeping it alive. When the last drops, the value is freed even if s still point at where it used to be.

    Because a might be dangling, you can't dereference it directly. You call .upgrade(), which returns Option<Arc<T>>: if the value is still alive (and hands you a fresh strong ref), if it's gone. That is Rust forcing you to handle "the thing I referenced no longer exists" — there is no way to accidentally touch freed memory.

    Two more pieces the diff uses:

    • Arc::downgrade(&arc) mints a from an — that's what you store in the map.
    • weak.strong_count() asks how many strong s are still out there. Zero means the value is dead and the entry is garbage.
    • HashMap::retain(|k, v| bool) walks every entry once and drops those where the closure returns false, editing the map in place — no rebuild, no second allocation.

    Put together: store values, and an entry becomes collectable the moment its last drops. retain(|_, w| w.strong_count() > 0) is the sweep.

    In this PR

    The map's value type flips from to :

    // crates/adj/src/proxy.rs
     #[derive(Default)]
     pub struct BootGate {
         locks: Mutex<HashMap<String, std::sync::Weak<Mutex<()>>>>,
    @@ -39,7 +39,7 @@ 

    The situation

    The proxy's BootGate keeps one lock per ap map.insert(name.to_string(), Arc::downgrade(&lock)); lock } -

    Trace the lifetime. lock_for returns an — the caller in ensure_running holds it (let name_lock = gate.lock_for(name).await;) for the whole boot. So while a boot is in flight, at least one strong exists, strong_count() is > 0, and the map entry survives retain. Concurrent requests for the same name upgrade() that same live Weak and get the same back — single-flight identity is preserved (the new unit test asserts Arc::ptr_eq(&lock_a, &lock_a2)). Once the boot finishes and every caller drops its , strong_count() falls to zero, and the next lock_for call sweeps the dead entry. The map is now bounded by the number of boots happening right now, not by every name ever seen.

    The new test spells out the whole arc — hold two locks, confirm neither is pruned while alive, drop them all, then confirm the next lookup collects them:

    // crates/adj/src/proxy.rs
    +

    Trace the lifetime. lock_for returns an — the caller in ensure_running holds it (let name_lock = gate.lock_for(name).await;) for the whole boot. So while a boot is in flight, at least one strong exists, strong_count() is > 0, and the map entry survives . Concurrent requests for the same name upgrade() that same live and get the same back — single-flight identity is preserved (the new unit test asserts Arc::ptr_eq(&lock_a, &lock_a2)). Once the boot finishes and every caller drops its , strong_count() falls to zero, and the next lock_for call sweeps the dead entry. The map is now bounded by the number of boots happening right now, not by every name ever seen.

    The new test spells out the whole arc — hold two locks, confirm neither is pruned while alive, drop them all, then confirm the next lookup collects them:

    // crates/adj/src/proxy.rs
     drop(lock_a);
     drop(lock_a2);
     drop(lock_b);
    @@ -48,7 +48,7 @@ 

    The situation

    The proxy's BootGate keeps one lock per ap let map = gate.locks.lock().await; assert_eq!(map.len(), 1); assert!(map.contains_key("c")); -

    Why it matters

    The trap is thinking of a map as a passive container and forgetting it's a strong owner. In the original code the HashMap held s, so those apps could never be freed — the map itself was the reason they stayed alive. This is exactly the situation a garbage collector can't rescue you from either: a GC won't collect an object the map still strongly references, so a Java HashMap here would leak the same way. The tool for "reference it, but don't be the reason it lives" is a weak reference — Weak<T> in Rust, WeakHashMap in Java. Reaching for it turns an ever-growing map into a self-cleaning one, and the safety of upgrade() returning an Option means the "it might be gone" case is impossible to forget.

    Related lessons

    • PR #24 owns the single-flight boot itself — the per-name lock and the double-check that make concurrent first-requests boot an app exactly once. This lesson is the follow-up that stops the lock map from growing forever; read #24 first for what the map is for.
    • Same PR, second thread (monotonic time): ensure_running now captures its boot deadline as let deadline = tokio::time::Instant::now() + boot_timeout; before calling up(), so a slow spawn counts against the boot budget instead of being added on top of it. Instant is a monotonic clock — it only moves forward and is immune to wall-clock jumps (NTP steps, DST), which is why it's the right type for a deadline. Prefer it over SystemTime for measuring elapsed time, and reach for saturating_duration_since / checked arithmetic when subtracting two Instants so a reordering can't underflow.

    Dig deeper

    +

    Why it matters

    The trap is thinking of a map as a passive container and forgetting it's a strong owner. In the original code the held s, so those apps could never be freed — the map itself was the reason they stayed alive. This is exactly the situation a garbage collector can't rescue you from either: a GC won't collect an object the map still strongly references, so a Java here would leak the same way. The tool for "reference it, but don't be the reason it lives" is a weak reference — in Rust, WeakHashMap in Java. Reaching for it turns an ever-growing map into a self-cleaning one, and the safety of upgrade() returning an means the "it might be gone" case is impossible to forget.

    Related lessons

    • PR #24 owns the single-flight boot itself — the per-name lock and the double-check that make concurrent first-requests boot an app exactly once. This lesson is the follow-up that stops the lock map from growing forever; read #24 first for what the map is for.
    • Same PR, second thread (monotonic time): ensure_running now captures its boot deadline as let deadline = tokio::time::Instant::now() + boot_timeout; before calling up(), so a slow spawn counts against the boot budget instead of being added on top of it. is a monotonic clock — it only moves forward and is immune to wall-clock jumps (NTP steps, DST), which is why it's the right type for a deadline. Prefer it over for measuring elapsed time, and reach for saturating_duration_since / checked arithmetic when subtracting two s so a reordering can't underflow.

    Dig deeper

    diff --git a/ent/lessons/51-typed-http-header-values.html b/ent/lessons/51-typed-http-header-values.html index 43d7e35..6532af7 100644 --- a/ent/lessons/51-typed-http-header-values.html +++ b/ent/lessons/51-typed-http-header-values.html @@ -19,11 +19,11 @@

    PR #51 — Forward X-Forwarded-* headers from the proxy

    -

    In the http crate a header value is a HeaderValue — a validated byte-string newtype, not a String — so building one is fallible (HeaderValue::from_str returns a Result), and that fallibility is how the type system blocks header injection.

    +

    In the http crate a header value is a — a validated byte-string newtype, not a — so building one is fallible (HeaderValue::from_str returns a ), and that fallibility is how the type system blocks header injection.

    newtypestyped-headersfallible-construction

    2026-06-14 · +247/−13 · View PR

    -

    The situation

    The proxy rewrites the incoming Host header to 127.0.0.1:<port> so upstream dev servers with host-allowlist checks accept the request. That rewrite erases the browser's real origin. This PR restores it with the three standard reverse-proxy headers — X-Forwarded-Host, X-Forwarded-For, X-Forwarded-Proto — so an app can still reconstruct where the request actually came from. To set them, the code has to construct header values, and that is where Rust's types show up.

    The Rust idea

    The http crate (re-exported through hyper) does not model headers as HashMap<String, String>. Keys are HeaderName, values are HeaderValue, and the collection is HeaderMap. A HeaderValue is a newtype wrapping validated bytes: a header value may not contain a carriage return, line feed, or NUL. Those are exactly the bytes an attacker would use to split one header into two — the classic header/response-injection attack. Encoding the rule in a type makes the illegal value unrepresentable: the only way to get a HeaderValue is through a constructor that checks.

    So construction is fallible. HeaderValue::from_str(s) returns Result<HeaderValue, InvalidHeaderValue> — hand it a string with a stray \r\n and you get Err, never a poisoned value. When the input is a compile-time constant you already know is legal, HeaderValue::from_static("http") skips the runtime check and hands back a HeaderValue directly (no Result to unwrap; it only panics if you wrote an illegal literal).

    Reading is fallible too. HeaderValue::to_str() returns a Result, because the wrapped bytes are opaque and aren't guaranteed to be valid UTF-8 — a HeaderValue is not a Rust String, so getting a &str back out can fail.

    Contrast a String-typed header map in Python, JS, or Go: nothing stops you writing a newline into a value, and the injection surfaces (if ever) as a runtime security bug. Rust makes the unsafe value impossible to build in the first place.

    In this PR

    The whole helper, from crates/adj/src/proxy.rs:

    // crates/adj/src/proxy.rs
    +      

    The situation

    The proxy rewrites the incoming Host header to 127.0.0.1:<port> so upstream dev servers with host-allowlist checks accept the request. That rewrite erases the browser's real origin. This PR restores it with the three standard reverse-proxy headers — X-Forwarded-Host, X-Forwarded-For, X-Forwarded-Proto — so an app can still reconstruct where the request actually came from. To set them, the code has to construct header values, and that is where Rust's types show up.

    The Rust idea

    The http crate (re-exported through hyper) does not model headers as HashMap<String, String>. Keys are HeaderName, values are , and the collection is HeaderMap. A is a newtype wrapping validated bytes: a header value may not contain a carriage return, line feed, or NUL. Those are exactly the bytes an attacker would use to split one header into two — the classic header/response-injection attack. Encoding the rule in a type makes the illegal value unrepresentable: the only way to get a is through a constructor that checks.

    So construction is fallible. HeaderValue::from_str(s) returns Result<HeaderValue, InvalidHeaderValue> — hand it a string with a stray \r\n and you get , never a poisoned value. When the input is a compile-time constant you already know is legal, HeaderValue::from_static("http") skips the runtime check and hands back a directly (no to unwrap; it only panics if you wrote an illegal literal).

    Reading is fallible too. HeaderValue::to_str() returns a , because the wrapped bytes are opaque and aren't guaranteed to be valid UTF-8 — a is not a Rust , so getting a back out can fail.

    Contrast a -typed header map in Python, JS, or Go: nothing stops you writing a newline into a value, and the injection surfaces (if ever) as a runtime security bug. Rust makes the unsafe value impossible to build in the first place.

    In this PR

    The whole helper, from crates/adj/src/proxy.rs:

    // crates/adj/src/proxy.rs
     fn set_forwarded_headers(
         headers: &mut hyper::HeaderMap,
         original_host: &str,
    @@ -50,7 +50,7 @@ 

    The situation

    The proxy rewrites the incoming Host heade hyper::header::HeaderValue::from_static(proto), ); } -

    Three constructions, three different type stories:

    • from_str(original_host) is fallible, so it's wrapped in if let Ok(v). A malformed Host produces Err, the insert is skipped, and the request still forwards — the bad bytes never reach the socket.
    • proto is "http" or "https", both compile-time-safe, so it uses from_static — no Result. That's also why the parameter is typed proto: &'static str: from_static demands a &'static str, and the accept loops thread in string literals to satisfy it.
    • Reading the existing chain uses .to_str().ok(). to_str is the read-side Result; .ok() inside filter_map drops any value that isn't valid UTF-8 instead of blowing up.

    One HeaderMap detail worth naming: insert replaces every existing value for a name, while append adds another line — a HeaderMap is a multimap that can hold several values under one key. The helper reads them all with get_all (not get, which would see only the first line), joins them, then inserts once to collapse the chain back to a single line.

    Why it matters

    Header injection is the trap this avoids. Picture the string-concatenation version: "X-Forwarded-Host: " + original_host written straight to the socket. A Host of evil\r\nX-Admin: true smuggles in a whole extra header. HeaderValue::from_str rejects the \r\n before it can ever become a value, and the diff's if let Ok(v) turns that rejection into "quietly skip the header," never "forward the attack." Nobody had to remember to sanitize — the type simply refuses to hold the dangerous bytes.

    Related lessons

    • PR #40 introduced the generic serve_plain that this PR threads client_ip and proto through — same file, same serve loop.
    • PR #48 is the same "a Result is a value you must handle" discipline, one layer up; here the fallible values are HeaderValue::from_str and .to_str().

    Dig deeper

    +

    Three constructions, three different type stories:

    • from_str(original_host) is fallible, so it's wrapped in if let Ok(v). A malformed Host produces , the insert is skipped, and the request still forwards — the bad bytes never reach the socket.
    • proto is "http" or "https", both compile-time-safe, so it uses from_static — no . That's also why the parameter is typed proto: &'static str: from_static demands a &'static str, and the accept loops thread in string literals to satisfy it.
    • Reading the existing chain uses .to_str().ok(). to_str is the read-side ; inside filter_map drops any value that isn't valid UTF-8 instead of blowing up.

    One HeaderMap detail worth naming: insert replaces every existing value for a name, while append adds another line — a HeaderMap is a multimap that can hold several values under one key. The helper reads them all with get_all (not get, which would see only the first line), joins them, then inserts once to collapse the chain back to a single line.

    Why it matters

    Header injection is the trap this avoids. Picture the string-concatenation version: "X-Forwarded-Host: " + original_host written straight to the socket. A Host of evil\r\nX-Admin: true smuggles in a whole extra header. HeaderValue::from_str rejects the \r\n before it can ever become a value, and the diff's if let Ok(v) turns that rejection into "quietly skip the header," never "forward the attack." Nobody had to remember to sanitize — the type simply refuses to hold the dangerous bytes.

    Related lessons

    • PR #40 introduced the generic serve_plain that this PR threads client_ip and proto through — same file, same serve loop.
    • PR #48 is the same "a is a value you must handle" discipline, one layer up; here the fallible values are HeaderValue::from_str and .to_str().

    Dig deeper

    diff --git a/ent/lessons/52-abort-detached-tokio-tasks.html b/ent/lessons/52-abort-detached-tokio-tasks.html index dc9fe5c..c169538 100644 --- a/ent/lessons/52-abort-detached-tokio-tasks.html +++ b/ent/lessons/52-abort-detached-tokio-tasks.html @@ -19,18 +19,18 @@

    PR #52 — Abort the readiness probe's connection task when the probe resolves

    -

    tokio::spawn returns a JoinHandle, and dropping that handle detaches the task so it keeps running on its own — to stop a background task once you're done with it, hold the handle and call .abort() (a no-op if it already finished), and for the leak-proof version let a Drop guard fire the abort on every exit path.

    +

    returns a JoinHandle, and dropping that handle detaches the task so it keeps running on its own — to stop a background task once you're done with it, hold the handle and call .abort() (a no-op if it already finished), and for the leak-proof version let a guard fire the abort on every exit path.

    tokio-spawnjoin-handle-aborttask-cancellation

    2026-06-14 · +86/−5 · View PR

    -

    The situation

    The HTTP readiness probe (added in #29) opens a TCP connection to the app and speaks HTTP/1 through hyper. Hyper's client splits into two halves: a sender you push the request into, and a conn future that must be driven to actually pump bytes on the socket. The idiom is to tokio::spawn that conn future so it runs in the background while you await the response on sender.

    But the whole probe is wrapped in a 500ms tokio::time::timeout. If the timer wins, the attempt future is dropped mid-flight — and that spawned connection task, which owns a real TCP socket, is left running with nobody watching it. This PR keeps a handle to that task and aborts it once the probe resolves, timeout or not, so the task and its socket can't outlive the probe.

    The Rust idea

    Two facts a newcomer needs about spawned tasks.

    *1. Dropping a JoinHandle does not stop the task — it detaches it. tokio::spawn(fut) hands the future to the runtime and returns a JoinHandle<T>. Await the handle and you get the task's output. But here's the surprise if you're coming from threads, or from "a future is cancelled when you drop it": dropping the handle* to a spawned task doesn't cancel anything. The task keeps running to completion on its own. A bare tokio::spawn(...) that throws the handle away is fire-and-forget — fine for work that finishes by itself, a leak for work that might not.

    2. Cancellation is explicit: JoinHandle::abort(). It doesn't kill the task synchronously. It flags the task so the runtime drops it at its next .await point (or never starts it, if it hadn't run yet). Dropping the task runs the destructors of everything it owns — here, the hyper conn and its TCP socket. And abort() on an already-finished task is a harmless no-op, so calling it unconditionally after the probe is always safe: either it reclaims a lingering task, or it does nothing.

    The subtle part is guaranteeing the abort runs. If you call it by hand, every exit path has to reach that call — every early return, every ?, every panic. Miss one and the task leaks on that path. The RAII fix is the same one the language uses for locks and files: wrap the handle in a tiny struct whose Drop impl calls abort(), so the compiler runs it at scope exit on every path.

    // Sketch — the general RAII form (not the exact shape #52 shipped; see below).
    +      

    The situation

    The HTTP readiness probe (added in #29) opens a TCP connection to the app and speaks HTTP/1 through hyper. Hyper's client splits into two halves: a sender you push the request into, and a conn future that must be driven to actually pump bytes on the socket. The idiom is to that conn future so it runs in the background while you await the response on sender.

    But the whole probe is wrapped in a 500ms . If the timer wins, the attempt future is dropped mid-flight — and that spawned connection task, which owns a real TCP socket, is left running with nobody watching it. This PR keeps a handle to that task and aborts it once the probe resolves, timeout or not, so the task and its socket can't outlive the probe.

    The Rust idea

    Two facts a newcomer needs about spawned tasks.

    *1. Dropping a JoinHandle does not stop the task — it detaches it. tokio::spawn(fut) hands the future to the runtime and returns a JoinHandle<T>. Await the handle and you get the task's output. But here's the surprise if you're coming from threads, or from "a future is cancelled when you drop it": dropping the handle* to a spawned task doesn't cancel anything. The task keeps running to completion on its own. A bare tokio::spawn(...) that throws the handle away is fire-and-forget — fine for work that finishes by itself, a leak for work that might not.

    2. Cancellation is explicit: JoinHandle::abort(). It doesn't kill the task synchronously. It flags the task so the runtime drops it at its next point (or never starts it, if it hadn't run yet). Dropping the task runs the destructors of everything it owns — here, the hyper conn and its TCP socket. And abort() on an already-finished task is a harmless no-op, so calling it unconditionally after the probe is always safe: either it reclaims a lingering task, or it does nothing.

    The subtle part is guaranteeing the abort runs. If you call it by hand, every exit path has to reach that call — every early return, every , every panic. Miss one and the task leaks on that path. The RAII fix is the same one the language uses for locks and files: wrap the handle in a tiny struct whose impl calls abort(), so the compiler runs it at scope exit on every path.

    // Sketch — the general RAII form (not the exact shape #52 shipped; see below).
     struct AbortOnDrop(tokio::task::JoinHandle<()>);
     impl Drop for AbortOnDrop {
         fn drop(&mut self) {
             self.0.abort();
         }
     }
    -

    Drop running on every normal exit is exactly what makes this leak-proof — and it's the flip side of PR #37, which is the deep dive on Drop's reach and its one blind spot (a signal walks right past it).

    In this PR

    The handle is parked on a slot declared outside both the attempt future and the timeout, so it survives even when the timer drops attempt mid-flight. Option because the spawn happens partway through attempt — the connect might fail with ? before we ever spawn, so the slot is Some only once we got far enough to have a task.

    // crates/adj/src/readiness.rs
    +

    running on every normal exit is exactly what makes this leak-proof — and it's the flip side of PR #37, which is the deep dive on 's reach and its one blind spot (a signal walks right past it).

    In this PR

    The handle is parked on a slot declared outside both the attempt future and the timeout, so it survives even when the timer drops attempt mid-flight. because the spawn happens partway through attempt — the connect might fail with before we ever spawn, so the slot is only once we got far enough to have a task.

    // crates/adj/src/readiness.rs
     let mut conn_task: Option<tokio::task::JoinHandle<()>> = None;
     let attempt = async {
         let addr = SocketAddr::from(([127, 0, 0, 1], port));
    @@ -55,7 +55,7 @@ 

    The situation

    The HTTP readiness probe (added in #29) opens a TCP con task.abort(); } ready -

    Two honest notes on the shape it actually shipped:

    • It's a manual abort, not the Drop guard from the sketch. That's fine here because http_ready has a single linear tail: nothing between the timeout resolving and the if let ... abort() can return early or ? out, so every path reaches the abort. The day a function grows several ? early returns between grabbing a task handle and finishing with it, that's the signal to promote this manual call into the AbortOnDrop guard — same story as a lock, where hand-written unlock works until someone adds an early return and the guard makes it structural.
    • The PR couldn't even reproduce the leak. As the author's write-up notes, hyper 1.x happens to resolve the conn future when sender is dropped, so the detached task was already exiting on its own. The explicit abort() converts "cancellation that works by accident, because of a dependency's internals" into "cancellation we own" — and the two new tests pin the no-lingering-socket behavior against future hyper changes.

    Why it matters

    The trap is reading tokio::spawn(fut) with the handle discarded as fire-and-forget cleanup, when it's the opposite: the task outlives the handle. Someone from a GC language expects the task to be collected once nothing references it; someone who's learned "drop a future to cancel it" gets bitten because a spawned task is detached, not a bare future. One leaked socket per probe, multiplied by a poll loop, is a genuine file-descriptor leak. Holding the JoinHandle and aborting it — ideally from a Drop guard so no exit path can skip it — is how you make the background task's lifetime a strict subset of the work that needed it.

    Related lessons

    • PR #29 built the tokio::time::timeout this abort partners with. #29 bounds how long the probe waits; #52 cleans up what the probe spawned when that bound fires. Timeout + abort are the two halves of a cancel-safe async operation — the first stops waiting, the second stops the work.
    • PR #37 is the converse-and-complement on Drop. #37: Drop runs on normal scope exit and panic unwinding, but a signal skips it. #52: because Drop runs on every normal exit path, it's the leak-proof home for an abort(). Read them together for the full map of what Drop covers and what it doesn't.

    Dig deeper

    • JoinHandle::abort — the cancellation semantics, and the note that dropping a JoinHandle detaches (does not cancel) the task.
    • The Rust Book, ch. 15.3 — Running Code on Cleanup with the Drop Trait, the pattern behind the AbortOnDrop guard.
    +

    Two honest notes on the shape it actually shipped:

    • It's a manual abort, not the guard from the sketch. That's fine here because http_ready has a single linear tail: nothing between the timeout resolving and the if let ... abort() can return early or out, so every path reaches the abort. The day a function grows several early returns between grabbing a task handle and finishing with it, that's the signal to promote this manual call into the AbortOnDrop guard — same story as a lock, where hand-written unlock works until someone adds an early return and the guard makes it structural.
    • The PR couldn't even reproduce the leak. As the author's write-up notes, hyper 1.x happens to resolve the conn future when sender is dropped, so the detached task was already exiting on its own. The explicit abort() converts "cancellation that works by accident, because of a dependency's internals" into "cancellation we own" — and the two new tests pin the no-lingering-socket behavior against future hyper changes.

    Why it matters

    The trap is reading tokio::spawn(fut) with the handle discarded as fire-and-forget cleanup, when it's the opposite: the task outlives the handle. Someone from a GC language expects the task to be collected once nothing references it; someone who's learned "drop a future to cancel it" gets bitten because a spawned task is detached, not a bare future. One leaked socket per probe, multiplied by a poll loop, is a genuine file-descriptor leak. Holding the JoinHandle and aborting it — ideally from a guard so no exit path can skip it — is how you make the background task's lifetime a strict subset of the work that needed it.

    Related lessons

    • PR #29 built the this abort partners with. #29 bounds how long the probe waits; #52 cleans up what the probe spawned when that bound fires. Timeout + abort are the two halves of a cancel-safe async operation — the first stops waiting, the second stops the work.
    • PR #37 is the converse-and-complement on . #37: runs on normal scope exit and panic unwinding, but a signal skips it. #52: because runs on every normal exit path, it's the leak-proof home for an abort(). Read them together for the full map of what covers and what it doesn't.

    Dig deeper

    • JoinHandle::abort — the cancellation semantics, and the note that dropping a JoinHandle detaches (does not cancel) the task.
    • The Rust Book, ch. 15.3 — Running Code on Cleanup with the Trait, the pattern behind the AbortOnDrop guard.
    diff --git a/ent/lessons/53-http-upgrade-stream-splicing.html b/ent/lessons/53-http-upgrade-stream-splicing.html index 24b2083..d78c031 100644 --- a/ent/lessons/53-http-upgrade-stream-splicing.html +++ b/ent/lessons/53-http-upgrade-stream-splicing.html @@ -23,7 +23,7 @@

    PR #53 — Propagate WebSocket upgrades through the proxy

    http-upgradecopy-bidirectional

    2026-06-14 · +238/−2 · View PR

    -

    The situation

    The proxy forwarded ordinary HTTP fine, but WebSocket requests (Vite/Webpack/Next HMR) died at the proxy boundary. A WebSocket starts as a normal GET carrying Upgrade: websocket; the app answers 101 Switching Protocols and from that byte onward the connection is no longer HTTP — it's a raw, full-duplex byte stream. The proxy has to notice the handshake, let it through, then get out of the way and shovel bytes both directions until someone hangs up.

    The Rust idea

    An upgrade is a handoff. Normally hyper owns the socket and parses framed HTTP messages for you. After a 101 there are no more messages to parse — there's a raw TCP stream underneath that hyper is finished with. hyper::upgrade::on(&mut req) hands you a future (OnUpgrade) that resolves to that raw stream once the upgrade completes. Think of it as a claim ticket: you reserve the stream now and collect it later.

    Two Rust facts make the timing of that claim load-bearing:

    • send_request(req) moves the request. Sending it upstream consumes req by value, and the upgrade handle lives inside the request's extension map. Once the request is moved, you can never reach back in for it. So you must claim the handle before the move — that's the only window where the server half is still yours.
    • The downstream upgrade only resolves after you return the 101. hyper's server loop completes the client-side upgrade only once forward hands back the 101 response. So awaiting the downstream future inline — before returning — would wait for something that can't happen until you return. Deadlock. The fix is to tokio::spawn a detached task: return the 101, and let that task collect both halves whenever they're ready.

    Once you hold both raw streams, tokio::io::copy_bidirectional(&mut a, &mut b) runs the two copies concurrently — a → b and b → a — and returns when both directions hit EOF. It replaces a hand-rolled "spawn two copy loops and coordinate the shutdown" dance with one call.

    In this PR

    Claim the downstream handle up front, gated on the Upgrade header so non-upgrade requests pay nothing (.then(|| …) only runs the closure when the bool is true):

    // crates/adj/src/proxy.rs
    +      

    The situation

    The proxy forwarded ordinary HTTP fine, but WebSocket requests (Vite/Webpack/Next HMR) died at the proxy boundary. A WebSocket starts as a normal GET carrying Upgrade: websocket; the app answers 101 Switching Protocols and from that byte onward the connection is no longer HTTP — it's a raw, full-duplex byte stream. The proxy has to notice the handshake, let it through, then get out of the way and shovel bytes both directions until someone hangs up.

    The Rust idea

    An upgrade is a handoff. Normally hyper owns the socket and parses framed HTTP messages for you. After a 101 there are no more messages to parse — there's a raw TCP stream underneath that hyper is finished with. hyper::upgrade::on(&mut req) hands you a future (OnUpgrade) that resolves to that raw stream once the upgrade completes. Think of it as a claim ticket: you reserve the stream now and collect it later.

    Two Rust facts make the timing of that claim load-bearing:

    • send_request(req) moves the request. Sending it upstream consumes req by value, and the upgrade handle lives inside the request's extension map. Once the request is moved, you can never reach back in for it. So you must claim the handle before the move — that's the only window where the server half is still yours.
    • The downstream upgrade only resolves after you return the 101. hyper's server loop completes the client-side upgrade only once forward hands back the 101 response. So awaiting the downstream future inline — before returning — would wait for something that can't happen until you return. Deadlock. The fix is to a detached task: return the 101, and let that task collect both halves whenever they're ready.

    Once you hold both raw streams, tokio::io::copy_bidirectional(&mut a, &mut b) runs the two copies concurrently — a → b and b → a — and returns when both directions hit EOF. It replaces a hand-rolled "spawn two copy loops and coordinate the shutdown" dance with one call.

    In this PR

    Claim the downstream handle up front, gated on the Upgrade header so non-upgrade requests pay nothing (.then(|| …) only runs the closure when the bool is true):

    // crates/adj/src/proxy.rs
     let downstream_upgrade = req
         .headers()
         .contains_key(hyper::header::UPGRADE)
    @@ -49,7 +49,7 @@ 

    The situation

    The proxy forwarded ordinary HTTP fine, but WebSocket r }); } } -

    tokio::join!(downstream, upstream) awaits both claim tickets concurrently and yields a tuple of Results; only when both raw streams are in hand does the pump start. The TokioIo::new wrappers matter: hyper's upgraded stream speaks hyper's own IO traits, but copy_bidirectional is a tokio function expecting AsyncRead + AsyncWrite, so TokioIo adapts one to the other.

    Why it matters

    The trap is claiming the stream too late. It's natural to reach for the upgrade handle after you have the response — that's when a WebSocket "feels" established. But send_request already moved the request by then, and Rust's ownership rules make that a hard compile error, not a subtle runtime bug: the handle is simply gone. The type system forces you to grab it during the one valid window. The second trap — awaiting the downstream upgrade inline — compiles fine and then hangs forever; the tokio::spawn is what keeps it from deadlocking, and the comment in the diff spells out why. A garbage-collected proxy in another language would let you hold a dangling reference to the consumed request and discover the mistake only when the tunnel silently never opens.

    Related lessons

    • PR #40 taught the generic serve_plain<S> loop these connections ride on — note it already calls .with_upgrades() on the server side. #53 is the client-side other half: without both, the upgrade completes on neither end.
    • PR #51 taught typed headers; the hyper::header::UPGRADE constant here is that same idea — a typed name instead of a stringly-typed "upgrade" lookup.

    Dig deeper

    +

    tokio::join!(downstream, upstream) awaits both claim tickets concurrently and yields a tuple of s; only when both raw streams are in hand does the pump start. The TokioIo::new wrappers matter: hyper's upgraded stream speaks hyper's own IO traits, but copy_bidirectional is a tokio function expecting AsyncRead + AsyncWrite, so TokioIo adapts one to the other.

    Why it matters

    The trap is claiming the stream too late. It's natural to reach for the upgrade handle after you have the response — that's when a WebSocket "feels" established. But send_request already moved the request by then, and Rust's ownership rules make that a hard compile error, not a subtle runtime bug: the handle is simply gone. The type system forces you to grab it during the one valid window. The second trap — awaiting the downstream upgrade inline — compiles fine and then hangs forever; the is what keeps it from deadlocking, and the comment in the diff spells out why. A garbage-collected proxy in another language would let you hold a dangling reference to the consumed request and discover the mistake only when the tunnel silently never opens.

    Related lessons

    • PR #40 taught the generic serve_plain<S> loop these connections ride on — note it already calls .with_upgrades() on the server side. #53 is the client-side other half: without both, the upgrade completes on neither end.
    • PR #51 taught typed headers; the hyper::header::UPGRADE constant here is that same idea — a typed name instead of a stringly-typed "upgrade" lookup.

    Dig deeper

    diff --git a/ent/lessons/55-races-beyond-the-borrow-checker.html b/ent/lessons/55-races-beyond-the-borrow-checker.html index 7f92a00..f525745 100644 --- a/ent/lessons/55-races-beyond-the-borrow-checker.html +++ b/ent/lessons/55-races-beyond-the-borrow-checker.html @@ -23,7 +23,7 @@

    PR #55 — Close the test-sandbox port race behind connection-reset flakesrace-conditionTOCTOU

    2026-06-14 · +176/−72 · View PR

    -

    The situation

    Integration tests each spin up their own daemon in a sandbox and need a free proxy port. The old trick: bind 127.0.0.1:0, let the kernel pick a port, read the number, close the socket, then hand that number to the daemon to re-bind. Under cargo test seven suites run at once, all drawing from the same kernel ephemeral-port range. Another process could claim the port in the gap between the test closing it and the daemon re-binding it. The readiness probe's bare TCP connect then hit a foreign listener, called it ready, and the real request landed on a stranger's socket: "Connection reset by peer". It flaked roughly 1-in-45.

    The Rust idea

    Rust's concurrency guarantees are famous, and it's easy to over-read them. Here's the precise boundary.

    A data race is two threads touching the same memory location at the same time, at least one of them writing, with no synchronization. That is undefined behavior in C, a heisenbug in Go, and a compile error in Rust. The mechanism is two marker traits the compiler tracks for every type:

    • — safe to move to another thread.
    • — safe to share by reference (&T) across threads.

    is ; Rc<RefCell<T>> is neither. So tokio::spawn accepts the first and rejects the second, and the borrow checker won't let you reach shared memory except through the lock. Data races: gone, before the program ever runs.

    But note the load-bearing phrase: the same memory location. The guarantee is about memory your process owns. The moment two processes contend over something outside that model — a TCP port in the kernel's table, a file on disk, a network resource — the compiler has no view of it. No &mut crosses the boundary, no trait describes it, so there is nothing to check. This bug is a TOCTOU race — time-of-check to time-of-use: you check a fact (this port is free), and by the time you use it (bind it), the fact has changed. Classic, and completely invisible to /.

    In this PR

    The old code even confessed the race in its own doc comment:

    // crates/adj/tests/proxy.rs  (deleted)
    +      

    The situation

    Integration tests each spin up their own daemon in a sandbox and need a free proxy port. The old trick: bind 127.0.0.1:0, let the kernel pick a port, read the number, close the socket, then hand that number to the daemon to re-bind. Under cargo test seven suites run at once, all drawing from the same kernel ephemeral-port range. Another process could claim the port in the gap between the test closing it and the daemon re-binding it. The readiness probe's bare TCP connect then hit a foreign listener, called it ready, and the real request landed on a stranger's socket: "Connection reset by peer". It flaked roughly 1-in-45.

    The Rust idea

    Rust's concurrency guarantees are famous, and it's easy to over-read them. Here's the precise boundary.

    A data race is two threads touching the same memory location at the same time, at least one of them writing, with no synchronization. That is undefined behavior in C, a heisenbug in Go, and a compile error in Rust. The mechanism is two marker traits the compiler tracks for every type:

    • — safe to move to another thread.
    • — safe to share by reference (&T) across threads.

    is ; Rc<RefCell<T>> is neither. So accepts the first and rejects the second, and the borrow checker won't let you reach shared memory except through the lock. Data races: gone, before the program ever runs.

    But note the load-bearing phrase: the same memory location. The guarantee is about memory your process owns. The moment two processes contend over something outside that model — a TCP port in the kernel's table, a file on disk, a network resource — the compiler has no view of it. No &mut crosses the boundary, no trait describes it, so there is nothing to check. This bug is a TOCTOU race — time-of-check to time-of-use: you check a fact (this port is free), and by the time you use it (bind it), the fact has changed. Classic, and completely invisible to /.

    In this PR

    The old code even confessed the race in its own doc comment:

    // crates/adj/tests/proxy.rs  (deleted)
     /// Bind :0, read the assigned port, close. The kernel may reissue this number to anyone before
     /// the daemon claims it, but for a localhost test that's rare enough to accept.
     fn pick_port() -> u16 {
    diff --git a/ent/lessons/56-split-once-structured-keys.html b/ent/lessons/56-split-once-structured-keys.html
    index 90c5f41..6841c25 100644
    --- a/ent/lessons/56-split-once-structured-keys.html
    +++ b/ent/lessons/56-split-once-structured-keys.html
    @@ -19,11 +19,11 @@
         

    PR #56 — Worktree instances: per-label registry keys, remove/prune, wildcard TLS SANs

    -

    str::split_once cuts a string at the first delimiter into two borrowed &str slices and returns an Option, so "there was no delimiter" is a case you handle instead of a bug you forget — the clean way to parse a structured key.

    -

    string-slicessplit-onceborrowing

    +

    cuts a string at the first delimiter into two borrowed slices and returns an , so "there was no delimiter" is a case you handle instead of a bug you forget — the clean way to parse a structured key.

    +

    string-slicessplit-once

    2026-06-13 · +3601/−112 · View PR

    -

    The situation

    The registry maps a key to a path. This PR lets one base app register under several keys: a bare site is the app itself, and feature-x.site is a worktree instance that routes at feature-x.site.adj.ac. The key encodes structure — one dot separates the instance label from the base name. Every routed request has to pull those two pieces back out of a &str key, and it has to be cheap.

    The Rust idea

    str::split_once(delimiter) splits at the first match and hands back Option<(&str, &str)>. Two things are worth internalizing.

    It returns Option, so the "no delimiter" case can't be forgotten. In many languages you write "feature-x.site".split("."), get an array, and reach for [0] and [1] — hoping there was a dot. When there isn't, [1] is undefined, an out-of-bounds panic, or a silent empty string, depending on the language. That missing-delimiter path is a landmine. split_once turns it into a value: Some((before, after)) when the delimiter is present, None when it isn't. A bare key like "site" isn't an edge case to defend against — it's simply the None arm of a match.

    The two pieces are borrowed slices — no allocation. A &str is a pointer + length: a window into bytes someone else owns. The pair split_once returns points straight into the original string, so splitting a key costs two integer offsets, not two heap allocations. (Contrast a String, which owns its bytes on the heap. &str borrows; String owns.) The price of that speed is a lifetime: the slices are valid only while the input lives — which is exactly why the helper below returns borrows and lets the caller decide whether to .to_string() them.

    (split_once is the precise cousin of split, which returns a lazy iterator over every piece. When a string has a known two-part shape, you want the one that gives you exactly two.)

    In this PR

    The parser is six lines. split_key maps a registry key to (Option<label>, base):

    // crates/adj/src/registry.rs
    +      

    The situation

    The registry maps a key to a path. This PR lets one base app register under several keys: a bare site is the app itself, and feature-x.site is a worktree instance that routes at feature-x.site.adj.ac. The key encodes structure — one dot separates the instance label from the base name. Every routed request has to pull those two pieces back out of a key, and it has to be cheap.

    The Rust idea

    str::split_once(delimiter) splits at the first match and hands back Option<(&str, &str)>. Two things are worth internalizing.

    It returns , so the "no delimiter" case can't be forgotten. In many languages you write "feature-x.site".split("."), get an array, and reach for [0] and [1] — hoping there was a dot. When there isn't, [1] is undefined, an out-of-bounds panic, or a silent empty string, depending on the language. That missing-delimiter path is a landmine. turns it into a value: Some((before, after)) when the delimiter is present, when it isn't. A bare key like "site" isn't an edge case to defend against — it's simply the arm of a .

    The two pieces are borrowed slices — no allocation. A is a pointer + length: a window into bytes someone else owns. The pair returns points straight into the original string, so splitting a key costs two integer offsets, not two heap allocations. (Contrast a , which owns its bytes on the heap. borrows; owns.) The price of that speed is a lifetime: the slices are valid only while the input lives — which is exactly why the helper below returns borrows and lets the caller decide whether to .to_string() them.

    ( is the precise cousin of split, which returns a lazy iterator over every piece. When a string has a known two-part shape, you want the one that gives you exactly two.)

    In this PR

    The parser is six lines. split_key maps a registry key to (Option<label>, base):

    // crates/adj/src/registry.rs
     /// Split a registry key into `(label, base)`. Keys are either a bare app name (`site`) or a
     /// worktree-instance key (`feature-x.site`). `add` enforces at most one dot, so `split_once`
     /// is total here.
    @@ -39,7 +39,7 @@ 

    The situation

    The registry maps a key to a path. This PR lets one bas pub fn base_name(key: &str) -> &str { split_key(key).1 } -

    Read the match: the Some arm is an instance key — wrap the label in Some, keep the base. The None arm is a bare app name — there's no label, and the whole key is the base. base_name just projects the second field (.1). Every return value is a borrow into key; nothing is copied.

    Now the load-bearing detail in that doc comment: "so split_once is total here." split_once always cuts at the first dot. On "a.b.c" it yields ("a", "b.c") — everything after the first delimiter lands in the second slice. So if a dotted app name ever slipped through, feature.my.app would misparse as label feature, base my.app. The claim that "the first dot is the structural boundary" only holds because of an invariant enforced somewhere else: app names can't contain dots. That check lives at the point where a config enters the system:

    // crates/adj/src/registry.rs
    +

    Read the : the arm is an instance key — wrap the label in , keep the base. The arm is a bare app name — there's no label, and the whole key is the base. base_name just projects the second field (.1). Every return value is a borrow into key; nothing is copied.

    Now the load-bearing detail in that doc comment: "so is total here." always cuts at the first dot. On "a.b.c" it yields ("a", "b.c") — everything after the first delimiter lands in the second slice. So if a dotted app name ever slipped through, feature.my.app would misparse as label feature, base my.app. The claim that "the first dot is the structural boundary" only holds because of an invariant enforced somewhere else: app names can't contain dots. That check lives at the point where a config enters the system:

    // crates/adj/src/registry.rs
     // Dots are structural in registry keys (`<label>.<name>` is a worktree instance), so a
     // dotted app name would make `feature-x.site` ambiguous. Checked before the general
     // DNS-label check so the dot case gets its own targeted message.
    @@ -57,7 +57,7 @@ 

    The situation

    The registry maps a key to a path. This PR lets one bas assert_eq!(base_name("site"), "site"); assert_eq!(base_name("feature-x.site"), "site"); } -

    Why it matters

    The trap this sidesteps is the array-index split: grab [0]/[1], forget the no-delimiter path, and ship a parser that panics or silently mangles a bare key. split_once makes "no dot" a branch the compiler forces you to write, and pairing it with the upstream contains('.') reject turns "the first dot is the only dot" from a hope into a guarantee. Because the two pieces are borrowed slices, this runs on every routed request without allocating a thing — the invariant lives in code, and reading it back out is free.

    Related lessons

    • PR #49 is the same file and the same move from the other end: the dotted-name reject above is a parse-don't-validate boundary check, refusing bad input at read_app_config so nothing downstream has to re-check it.
    • PR #45 leans on the same borrowing trick — its DashboardEntry<'a> holds &str views into each app summary instead of cloning strings, exactly like split_key returns windows into key.

    Dig deeper

    • str::split_once — the signature (Option<(&str, &str)>), plus rsplit_once for splitting on the last delimiter instead of the first.
    • The Rust Book, ch. 4.3 — The Slice Type: why a &str is a borrowed view, and how its lifetime is tied to the data it points into.
    +

    Why it matters

    The trap this sidesteps is the array-index split: grab [0]/[1], forget the no-delimiter path, and ship a parser that panics or silently mangles a bare key. makes "no dot" a branch the compiler forces you to write, and pairing it with the upstream contains('.') reject turns "the first dot is the only dot" from a hope into a guarantee. Because the two pieces are borrowed slices, this runs on every routed request without allocating a thing — the invariant lives in code, and reading it back out is free.

    Related lessons

    • PR #49 is the same file and the same move from the other end: the dotted-name reject above is a parse-don't-validate boundary check, refusing bad input at read_app_config so nothing downstream has to re-check it.
    • PR #45 leans on the same borrowing trick — its DashboardEntry<'a> holds views into each app summary instead of cloning strings, exactly like split_key returns windows into key.

    Dig deeper

    • — the signature (Option<(&str, &str)>), plus rsplit_once for splitting on the last delimiter instead of the first.
    • The Rust Book, ch. 4.3 — The Slice Type: why a is a borrowed view, and how its lifetime is tied to the data it points into.
    diff --git a/ent/lessons/60-compile-time-version-stamping.html b/ent/lessons/60-compile-time-version-stamping.html index db87b3d..d9a444f 100644 --- a/ent/lessons/60-compile-time-version-stamping.html +++ b/ent/lessons/60-compile-time-version-stamping.html @@ -19,16 +19,16 @@

    PR #60 — Alpha Homebrew distribution: release CI, in-repo tap formula, version stamping

    -

    A build.rs script runs before your crate compiles and can hand values to the compiler via cargo:rustc-env=… stdout directives, which env! (build fails if the var is missing) and option_env! (yields an Option, never fails) then bake into the binary as &'static str literals.

    +

    A script runs before your crate compiles and can hand values to the compiler via cargo:rustc-env=… stdout directives, which (build fails if the var is missing) and (yields an , never fails) then bake into the binary as &'static str literals.

    build-scriptscompile-time-envmacros

    2026-06-14 · +243/−1 · View PR

    -

    The situation

    Shipping adj via Homebrew means adj --version has to report something true. A tagged release should say 0.1.0-alpha.1; a build off a random local commit should say which commit (and whether the tree was dirty). None of that lives in the source — it lives in git and in CI env vars. This PR is mostly release plumbing (a Homebrew formula, a tag-triggered CI workflow, a just release target), but the one genuinely instructive Rust piece is how the binary learns its own version at compile time.

    The Rust idea

    Rust has no runtime reflection for build metadata. A binary can't ask "what version am I?" from thin air — anything it knows must be baked in at compile time or read from disk at runtime. For a version string you want it baked in: a self-contained binary with no sidecar VERSION file to lose.

    Rust gives you two macros that read the compiler's environment:

    • env!("NAME") — expands to the value of environment variable NAME at compile time, as a &'static str. If NAME isn't set, the build fails with a clear error. Use it when the value must exist. Cargo always sets CARGO_PKG_VERSION from Cargo.toml, so env!("CARGO_PKG_VERSION") is guaranteed to compile.
    • option_env!("NAME") — same idea, but expands to Option<&'static str>: Some(v) if the var was set at compile time, None if not. It never fails the build. Use it for metadata that may or may not be there.

    The ! marks these as macros, not functions. A function's argument is evaluated at runtime; these run during compilation and leave a string literal behind in the compiled code. That's the only way to reach the environment the compiler saw.

    But where does a var like ADJ_VERSION come from during compilation? That's what build.rs is for. Cargo compiles and runs build.rs before compiling the crate, and the script talks back to Cargo by printing specially-formatted lines to stdout — cargo:rustc-env=KEY=VALUE sets an env var that env!/option_env! can then read while the crate compiles.

    In this PR

    The const in main.rs picks the git/CI-derived version when present, else the Cargo version:

    // crates/adj/src/main.rs
    +      

    The situation

    Shipping adj via Homebrew means adj --version has to report something true. A tagged release should say 0.1.0-alpha.1; a build off a random local commit should say which commit (and whether the tree was dirty). None of that lives in the source — it lives in git and in CI env vars. This PR is mostly release plumbing (a Homebrew formula, a tag-triggered CI workflow, a just release target), but the one genuinely instructive Rust piece is how the binary learns its own version at compile time.

    The Rust idea

    Rust has no runtime reflection for build metadata. A binary can't ask "what version am I?" from thin air — anything it knows must be baked in at compile time or read from disk at runtime. For a version string you want it baked in: a self-contained binary with no sidecar VERSION file to lose.

    Rust gives you two macros that read the compiler's environment:

    • env!("NAME") — expands to the value of environment variable NAME at compile time, as a &'static str. If NAME isn't set, the build fails with a clear error. Use it when the value must exist. Cargo always sets CARGO_PKG_VERSION from Cargo.toml, so env!("CARGO_PKG_VERSION") is guaranteed to compile.
    • option_env!("NAME") — same idea, but expands to Option<&'static str>: Some(v) if the var was set at compile time, if not. It never fails the build. Use it for metadata that may or may not be there.

    The ! marks these as macros, not functions. A function's argument is evaluated at runtime; these run during compilation and leave a string literal behind in the compiled code. That's the only way to reach the environment the compiler saw.

    But where does a var like ADJ_VERSION come from during compilation? That's what is for. Cargo compiles and runs before compiling the crate, and the script talks back to Cargo by printing specially-formatted lines to stdout — cargo:rustc-env=KEY=VALUE sets an env var that / can then read while the crate compiles.

    In this PR

    The const in main.rs picks the git/CI-derived version when present, else the Cargo version:

    // crates/adj/src/main.rs
     const VERSION: &str = match option_env!("ADJ_VERSION") {
         Some(v) => v,
         None => env!("CARGO_PKG_VERSION"),
     };
    -

    The whole expression is a const, so it's evaluated at compile time — match works in const context. option_env!("ADJ_VERSION") is Some only if the build set that var; otherwise it falls through to env!("CARGO_PKG_VERSION"), which can't fail because Cargo always provides it. Then clap uses it:

    // crates/adj/src/main.rs
    +

    The whole expression is a const, so it's evaluated at compile time — works in const context. option_env!("ADJ_VERSION") is only if the build set that var; otherwise it falls through to env!("CARGO_PKG_VERSION"), which can't fail because Cargo always provides it. Then clap uses it:

    // crates/adj/src/main.rs
     #[command(
         name = "adj",
         version = VERSION,   // was: version,  (which defaults to CARGO_PKG_VERSION)
    @@ -64,7 +64,7 @@ 

    The situation

    Shipping adj via Homebrew means adj println!("cargo:rerun-if-changed=../../.git/HEAD"); } } -

    Walk the chain:

    1. CI sets ADJ_VERSION to the release tag (the workflow's Derive version from tag step strips the leading v). Locally that var is unset, so the script runs git describe --tags --always --dirty — which yields 0.1.0-alpha.1 on the exact tag, or an abbreviated commit like 61b2f6e off a plain commit, with a -dirty suffix appended when the working tree has uncommitted changes.
    2. The script prints cargo:rustc-env=ADJ_VERSION=<v>, which sets that var for the crate compile that follows.
    3. option_env!("ADJ_VERSION") in main.rs sees it and bakes it into const VERSION.

    The two trailing directives are cache control. Build scripts are cached aggressively, so without a hint Cargo would keep the stale value forever. cargo:rerun-if-env-changed=ADJ_VERSION re-runs the script when that var changes; cargo:rerun-if-changed=../../.git/HEAD re-runs it when you commit (HEAD moves), so git describe refreshes. Build scripts run with their working directory set to the package root (crates/adj), which is why the repo's .git/HEAD is ../../ up.

    Note the .ok()? calls in git_describe: they intentionally throw away git's errors and return None, which makes the whole thing fall back to the Cargo version. That's the legitimate face of swallowing a Result (see #48) — a source build with no .git directory shouldn't fail to compile just because git describe can't run.

    The rest of the PR is distribution tooling, not Rust: .github/workflows/release.yml (tag-triggered arm64 build + GitHub prerelease), Formula/adj.rb and render-formula.sh (the in-repo Homebrew tap), and a just release target that tags the next alpha. Worth knowing they exist; not where the Rust lesson lives. The user-facing brew install / cargo install side is #69's subject.

    Why it matters

    In many ecosystems you'd read the version from a file shipped next to the binary (a package.json, a bundled VERSION) or hardcode a string you forget to bump. Both drift, and the file can go missing. Compile-time stamping makes the binary self-describing with zero runtime dependencies. And the env! vs option_env! split encodes intent in the type: env! says "this must exist, fail the build if it doesn't" and hands you a &str; option_env! says "this might exist" and hands you an Option<&str> you're forced to handle. Typo CARGO_PKG_VERSION and the build stops immediately — you never ship a binary that reports the wrong version because a lookup silently returned empty.

    Related lessons

    • PR #48 — Warn instead of silently defaulting. build.rs here uses .ok()? to deliberately drop git's errors and fall back; #48 is about the cases where that silent drop is a bug. Same combinator, opposite verdict — the difference is whether the Err means "someone made a mistake."

    Dig deeper

    • The Cargo Book — Build Scripts — what build.rs can do and the full list of cargo: output directives (rustc-env, rerun-if-changed, rerun-if-env-changed).
    • std — env! and option_env! — the compile-time environment macros, and why one fails the build while the other returns Option.
    +

    Walk the chain:

    1. CI sets ADJ_VERSION to the release tag (the workflow's Derive version from tag step strips the leading v). Locally that var is unset, so the script runs git describe --tags --always --dirty — which yields 0.1.0-alpha.1 on the exact tag, or an abbreviated commit like 61b2f6e off a plain commit, with a -dirty suffix appended when the working tree has uncommitted changes.
    2. The script prints cargo:rustc-env=ADJ_VERSION=<v>, which sets that var for the crate compile that follows.
    3. option_env!("ADJ_VERSION") in main.rs sees it and bakes it into const VERSION.

    The two trailing directives are cache control. Build scripts are cached aggressively, so without a hint Cargo would keep the stale value forever. cargo:rerun-if-env-changed=ADJ_VERSION re-runs the script when that var changes; cargo:rerun-if-changed=../../.git/HEAD re-runs it when you commit (HEAD moves), so git describe refreshes. Build scripts run with their working directory set to the package root (crates/adj), which is why the repo's .git/HEAD is ../../ up.

    Note the .ok()? calls in git_describe: they intentionally throw away git's errors and return , which makes the whole thing fall back to the Cargo version. That's the legitimate face of swallowing a (see #48) — a source build with no .git directory shouldn't fail to compile just because git describe can't run.

    The rest of the PR is distribution tooling, not Rust: .github/workflows/release.yml (tag-triggered arm64 build + GitHub prerelease), Formula/adj.rb and render-formula.sh (the in-repo Homebrew tap), and a just release target that tags the next alpha. Worth knowing they exist; not where the Rust lesson lives. The user-facing brew install / cargo install side is #69's subject.

    Why it matters

    In many ecosystems you'd read the version from a file shipped next to the binary (a package.json, a bundled VERSION) or hardcode a string you forget to bump. Both drift, and the file can go missing. Compile-time stamping makes the binary self-describing with zero runtime dependencies. And the vs split encodes intent in the type: says "this must exist, fail the build if it doesn't" and hands you a ; says "this might exist" and hands you an Option<&str> you're forced to handle. Typo CARGO_PKG_VERSION and the build stops immediately — you never ship a binary that reports the wrong version because a lookup silently returned empty.

    Related lessons

    • PR #48 — Warn instead of silently defaulting. here uses .ok()? to deliberately drop git's errors and fall back; #48 is about the cases where that silent drop is a bug. Same combinator, opposite verdict — the difference is whether the means "someone made a mistake."

    Dig deeper

    • The Cargo Book — Build Scripts — what can do and the full list of cargo: output directives (rustc-env, rerun-if-changed, rerun-if-env-changed).
    • std — and — the compile-time environment macros, and why one fails the build while the other returns .
    diff --git a/ent/lessons/63-what-clippy-teaches.html b/ent/lessons/63-what-clippy-teaches.html index 9ddfac6..f583135 100644 --- a/ent/lessons/63-what-clippy-teaches.html +++ b/ent/lessons/63-what-clippy-teaches.html @@ -19,14 +19,14 @@

    PR #63 — Add a fmt + clippy lint job to CI

    -

    Clippy is a Rust teacher wearing a linter's badge — each lint names an idiom (single_match → if let) or a footgun (await_holding_lock, dead_code), and the honest reply is either to adopt the idiom or to #[allow] it with a reason.

    +

    Clippy is a Rust teacher wearing a linter's badge — each lint names an idiom (single_match → ) or a footgun (await_holding_lock, dead_code), and the honest reply is either to adopt the idiom or to #[allow] it with a reason.

    clippyrustfmtlintsidioms

    2026-06-14 · +604/−248 · View PR

    The situation

    The repo had never been gated on formatting or lints. This PR adds a CI job that runs two commands and fails the build if either complains:

    # .github/workflows/ci.yml
     - run: cargo fmt --all -- --check
     - run: cargo clippy --workspace --all-targets -- -D warnings
    -

    To land that gate green it had to fix everything the two commands surfaced. Be honest about the split: the vast majority of the +604/−248 is one mechanical cargo fmt --all commit — struct literals expanded to multi-line, long calls wrapped — with no behavior change and nothing to learn. The interesting part is a tiny second commit (+46/−7) where clippy -D warnings forced three real changes. That's the lesson: not the formatter, but what clippy taught.

    The Rust idea

    Clippy ships ~700 lints, and almost none of them are about whitespace — rustfmt owns that. Clippy is about idiom and correctness. Each lint encodes a small piece of "here's how a seasoned Rust programmer would write this, and why." Running it is less like a spell-check and more like pairing with someone who keeps saying "there's a cleaner way." -D warnings (deny all warnings) turns that advice into a hard gate: the build fails until you either take the advice or explicitly, in writing, decline it.

    Declining is a first-class move. #[allow(clippy::some_lint)] says "I read this lint, I understand what it's warning about, and it doesn't apply here." A clippy pass done right leaves behind a mix of both: code you improved, and a few #[allow]s each carrying a comment that explains the disagreement. This PR has one of each kind.

    In this PR

    1. single_match → if let. Clippy's single_match fires when a match has one arm that does real work and a second arm that does nothing (_ => {}). Rust already has a construct for "run this code only if the value matches one pattern, otherwise skip" — that's if let. A two-arm match with an empty catch-all is if let wearing a costume, and reading it forces you to scan to the bottom to confirm the _ really is empty.

    // crates/adj/src/tls.rs — ensure_leaf
    +

    To land that gate green it had to fix everything the two commands surfaced. Be honest about the split: the vast majority of the +604/−248 is one mechanical cargo fmt --all commit — struct literals expanded to multi-line, long calls wrapped — with no behavior change and nothing to learn. The interesting part is a tiny second commit (+46/−7) where clippy -D warnings forced three real changes. That's the lesson: not the formatter, but what clippy taught.

    The Rust idea

    Clippy ships ~700 lints, and almost none of them are about whitespace — rustfmt owns that. Clippy is about idiom and correctness. Each lint encodes a small piece of "here's how a seasoned Rust programmer would write this, and why." Running it is less like a spell-check and more like pairing with someone who keeps saying "there's a cleaner way." -D warnings (deny all warnings) turns that advice into a hard gate: the build fails until you either take the advice or explicitly, in writing, decline it.

    Declining is a first-class move. #[allow(clippy::some_lint)] says "I read this lint, I understand what it's warning about, and it doesn't apply here." A clippy pass done right leaves behind a mix of both: code you improved, and a few #[allow]s each carrying a comment that explains the disagreement. This PR has one of each kind.

    In this PR

    1. single_match → . Clippy's single_match fires when a has one arm that does real work and a second arm that does nothing (_ => {}). Rust already has a construct for "run this code only if the value matches one pattern, otherwise skip" — that's . A two-arm match with an empty catch-all is wearing a costume, and reading it forces you to scan to the bottom to confirm the _ really is empty.

    // crates/adj/src/tls.rs — ensure_leaf
     // before:
     match (fs::read_to_string(&cert_path), fs::read_to_string(&key_path)) {
         (Ok(cert), Ok(key)) => match leaf_covers(&cert, sans) {
    @@ -45,7 +45,7 @@ 

    The situation

    The repo had never been gated on formatting or lints. T return Ok((cert, key)); } } -

    Both matches collapse the same way. Semantics are identical — only a readable, SAN-covering cached leaf short-circuits; everything else (a read error, a parse error, a cert that doesn't cover the wanted names) falls through to re-issue. But the if let version says that: "if both files read, and the leaf covers the SANs, use it." The _ => {} and Ok(false) | Err(_) => {} filler arms are gone, so there's nothing to double-check.

    2. await_holding_lock — the documented decline. This lint flags holding a std::sync::MutexGuard across an .await. It's a real footgun: on a multi-threaded async runtime the task can be parked mid-await while still holding the lock, and another task on that same thread can block forever trying to acquire it — a deadlock. Clippy is right to warn by default. Here it's a false alarm, and the fix is to say so, not to restructure working test code:

    // crates/adj/tests/tls.rs
    +

    Both matches collapse the same way. Semantics are identical — only a readable, SAN-covering cached leaf short-circuits; everything else (a read error, a parse error, a cert that doesn't cover the wanted names) falls through to re-issue. But the version says that: "if both files read, and the leaf covers the SANs, use it." The _ => {} and Ok(false) | Err(_) => {} filler arms are gone, so there's nothing to double-check.

    2. await_holding_lock — the documented decline. This lint flags holding a std::sync::MutexGuard across an . It's a real footgun: on a multi-threaded async runtime the task can be parked mid-await while still holding the lock, and another task on that same thread can block forever trying to acquire it — a deadlock. Clippy is right to warn by default. Here it's a false alarm, and the fix is to say so, not to restructure working test code:

    // crates/adj/tests/tls.rs
     #[cfg(target_os = "macos")]
     #[tokio::test]
     // The keychain guard intentionally spans the test's awaits to serialize on LOGIN_KEYCHAIN_LOCK;
    @@ -64,7 +64,7 @@ 

    The situation

    The repo had never been gated on formatting or lints. T pub fn sign_canary(&self) -> Result<()> { Err(unsupported()) } -

    Same test scaffolding (with_temp_home, HOME_LOCK, the curl_available helper) got the same #[cfg(target_os = "macos")] treatment.

    Why it matters

    Two takeaways a linter-skeptic misses.

    First, dead_code under --all-targets -D warnings on a Linux runner is a free cross-compilation check. Adjacent is mostly developed on a Mac, and it's easy to leave a helper unused on platforms you never build locally. The CI job compiles the non-macOS cfg branches you'd otherwise never exercise, and catches the drift. The lint isn't nagging about tidiness — it's proving that every target still hangs together.

    Second, a green clippy run doesn't mean "no lints fired." It means "every lint that fired was either fixed or explicitly declined with a reason." That's the discipline -D warnings enforces: you can't ignore advice by scrolling past it. You either take it, or you write down why you didn't — and the next reader gets that reasoning for free. An #[allow] with a comment is worth more than clean code that silently dodged the question.

    Related lessons

    • PR #40 shipped the HTTPS + local-CA machinery. The ensure_leaf match and the keychain helpers clippy is cleaning up here are exactly that code — this PR is #40's neighborhood one linter-pass later.
    • PR #48 is the same spirit in a different tool: it's about surfacing a Result's Err instead of swallowing it. Clippy and the type system are both in the business of making the invisible visible — this PR just does it at the linter layer.

    Dig deeper

    • The Clippy lint index — every lint, searchable, each with a before/after and the reasoning behind it. single_match, await_holding_lock, and the rustc dead_code/unused_imports lints all have entries worth reading.
    +

    Same test scaffolding (with_temp_home, HOME_LOCK, the curl_available helper) got the same #[cfg(target_os = "macos")] treatment.

    Why it matters

    Two takeaways a linter-skeptic misses.

    First, dead_code under --all-targets -D warnings on a Linux runner is a free cross-compilation check. Adjacent is mostly developed on a Mac, and it's easy to leave a helper unused on platforms you never build locally. The CI job compiles the non-macOS cfg branches you'd otherwise never exercise, and catches the drift. The lint isn't nagging about tidiness — it's proving that every target still hangs together.

    Second, a green clippy run doesn't mean "no lints fired." It means "every lint that fired was either fixed or explicitly declined with a reason." That's the discipline -D warnings enforces: you can't ignore advice by scrolling past it. You either take it, or you write down why you didn't — and the next reader gets that reasoning for free. An #[allow] with a comment is worth more than clean code that silently dodged the question.

    Related lessons

    • PR #40 shipped the HTTPS + local-CA machinery. The ensure_leaf match and the keychain helpers clippy is cleaning up here are exactly that code — this PR is #40's neighborhood one linter-pass later.
    • PR #48 is the same spirit in a different tool: it's about surfacing a 's instead of swallowing it. Clippy and the type system are both in the business of making the invisible visible — this PR just does it at the linter layer.

    Dig deeper

    • The Clippy lint index — every lint, searchable, each with a before/after and the reasoning behind it. single_match, await_holding_lock, and the rustc dead_code/unused_imports lints all have entries worth reading.
    diff --git a/ent/lessons/75-hand-render-config-to-disk.html b/ent/lessons/75-hand-render-config-to-disk.html index 25bb146..4aa102b 100644 --- a/ent/lessons/75-hand-render-config-to-disk.html +++ b/ent/lessons/75-hand-render-config-to-disk.html @@ -23,7 +23,7 @@

    PR #75 — Scaffold a default adjacent.toml in `adj add`

    std::fstomlfile-generation

    2026-06-21 · +1552/−6 · View PR

    -

    The situation

    adj add . used to dead-end when a directory had no adjacent.toml. This PR makes it generate a sensible default instead — filling in name and cmd when it can detect the stack, leaving # TODO guidance when it can't. The generated file's whole reason for existing is that a person opens it next and edits it. So two questions: how do you produce a config file that's friendly to a human editor, and how do you write it to disk without ever overwriting one the user already wrote?

    The Rust idea

    Rust's toml crate can serialize a struct straight to a config string — toml::to_string(&cfg) — the write-side mirror of the #[derive(Deserialize)] that reads adjacent.toml back in (PR #19). It's the obvious reach, and for a machine-to-machine file it's the right one: faithful, round-trippable, no hand-maintained text.

    But to_string serializes data, and a struct holds no comments. Feed it an AppConfig and you get back exactly:

    name = "myapp"
    +      

    The situation

    adj add . used to dead-end when a directory had no adjacent.toml. This PR makes it generate a sensible default instead — filling in name and cmd when it can detect the stack, leaving # TODO guidance when it can't. The generated file's whole reason for existing is that a person opens it next and edits it. So two questions: how do you produce a config file that's friendly to a human editor, and how do you write it to disk without ever overwriting one the user already wrote?

    The Rust idea

    Rust's toml crate can serialize a struct straight to a config string — toml::to_string(&cfg) — the write-side mirror of the that reads adjacent.toml back in (PR #19). It's the obvious reach, and for a machine-to-machine file it's the right one: faithful, round-trippable, no hand-maintained text.

    But to_string serializes data, and a struct holds no comments. Feed it an AppConfig and you get back exactly:

    name = "myapp"
     cmd = "npm run dev"
     

    No header explaining where the file came from. No # Optional: block showing the fields the user could set. No # TODO next to a value they still need to fill in. For a scaffold, those comments aren't decoration — they're the entire payload. The file is a teaching aid disguised as config.

    So the tool for a human-facing config file is the opposite of a serializer: you render the text by hand, comments and all, and treat the struct as something you'll parse the result back into later, not something you serialize from. The cost is that you're now responsible for producing valid TOML yourself — which the PR pins down with a test, below.

    In this PR

    render builds the file as a string. Every line the user benefits from — the header, the # TODOs, the commented-out optional block — is text serde would never emit:

    // crates/adj/src/scaffold.rs
     fn render(name: Option<&str>, cmd: Option<&str>) -> String {
    @@ -64,7 +64,7 @@ 

    The situation

    adj add . used to dead-end when a director .with_context(|| format!("writing {}", manifest.display()))?; // ... branch on detected name/cmd ... } -

    std::fs::write is the one-liner form — open (creating or truncating), write the whole buffer, close, all in a call. The if !manifest.exists() in front of it is the not-clobbering rule: a user who already hand-wrote an adjacent.toml never has it silently replaced by a generated one. (An integration test asserts exactly this — an existing manifest comes back byte-identical after adj add.)

    Why it matters

    Two traps, one on each side.

    Reaching for the serializer feels like the disciplined choice and quietly produces a worse file. toml::to_string(&cfg) is faithful and valid — and strips the comments that were the point. The user opens a two-line file with no hint of what else they can set or what they still need to fill in. Knowing when not to serialize is the actual skill here: struct-to-TOML for data you'll read back by machine, hand-rendered text for a file a human edits next.

    Skipping the existence check is the destructive one. std::fs::write truncates — point it at a path that already holds a carefully-tuned manifest and the old contents are gone, no prompt, no backup. The !exists() guard is what keeps a convenience feature from eating the user's work.

    One honest caveat: exists()-then-write() is check-then-act, so there's a hair-thin window where the file could appear between the two calls. For an interactive command a person runs at a shell, that window doesn't matter. If you needed it to matter — a concurrent or hostile setting — the atomic form is std::fs::OpenOptions::new().create_new(true), which fails outright if the file already exists rather than checking first. adj add doesn't need it; knowing the upgrade path exists is the point.

    Related lessons

    • PR #41 is the sibling technique — it also builds a document with format! string templating, and teaches the raw-string-literal (r#"…"#) mechanics that make a multi-line template readable. This lesson is the decision that sits above that mechanism: why you hand-render a config at all instead of serializing a struct, plus the disk write. #41 is how to template; #75 is when (and how not to clobber).
    • PR #19 is the exact mirror: it reads adjacent.toml back in with #[derive(Deserialize)] and Option fields. #75 writes the file #19 parses — and #19's strict "missing cmd is an error" is what the # TODO: set your dev command placeholder is steering the user away from tripping.
    • PR #22 serializes DTOs to JSON with serde — because that output is consumed by jq, a machine, where a stable derived shape is exactly right. Put next to #75 they're the clean contrast: serialize for machines, hand-render for humans.

    Dig deeper

    • std::fs::write — the create-or-truncate one-shot write used here.
    • std::fs::OpenOptions::create_new — the atomic "fail if it already exists" alternative to a separate exists() check.
    • The toml crate — toml::from_str (used in the round-trip test) and toml::to_string (the serializer this PR deliberately doesn't use for generation, because it drops comments).
    +

    std::fs::write is the one-liner form — open (creating or truncating), write the whole buffer, close, all in a call. The if !manifest.exists() in front of it is the not-clobbering rule: a user who already hand-wrote an adjacent.toml never has it silently replaced by a generated one. (An integration test asserts exactly this — an existing manifest comes back byte-identical after adj add.)

    Why it matters

    Two traps, one on each side.

    Reaching for the serializer feels like the disciplined choice and quietly produces a worse file. toml::to_string(&cfg) is faithful and valid — and strips the comments that were the point. The user opens a two-line file with no hint of what else they can set or what they still need to fill in. Knowing when not to serialize is the actual skill here: struct-to-TOML for data you'll read back by machine, hand-rendered text for a file a human edits next.

    Skipping the existence check is the destructive one. std::fs::write truncates — point it at a path that already holds a carefully-tuned manifest and the old contents are gone, no prompt, no backup. The !exists() guard is what keeps a convenience feature from eating the user's work.

    One honest caveat: exists()-then-write() is check-then-act, so there's a hair-thin window where the file could appear between the two calls. For an interactive command a person runs at a shell, that window doesn't matter. If you needed it to matter — a concurrent or hostile setting — the atomic form is std::fs::OpenOptions::new().create_new(true), which fails outright if the file already exists rather than checking first. adj add doesn't need it; knowing the upgrade path exists is the point.

    Related lessons

    • PR #41 is the sibling technique — it also builds a document with string templating, and teaches the raw-string-literal (r#"…"#) mechanics that make a multi-line template readable. This lesson is the decision that sits above that mechanism: why you hand-render a config at all instead of serializing a struct, plus the disk write. #41 is how to template; #75 is when (and how not to clobber).
    • PR #19 is the exact mirror: it reads adjacent.toml back in with and fields. #75 writes the file #19 parses — and #19's strict "missing cmd is an error" is what the # TODO: set your dev command placeholder is steering the user away from tripping.
    • PR #22 serializes DTOs to JSON with serde — because that output is consumed by jq, a machine, where a stable derived shape is exactly right. Put next to #75 they're the clean contrast: serialize for machines, hand-render for humans.

    Dig deeper

    • std::fs::write — the create-or-truncate one-shot write used here.
    • std::fs::OpenOptions::create_new — the atomic "fail if it already exists" alternative to a separate exists() check.
    • The toml crate — toml::from_str (used in the round-trip test) and toml::to_string (the serializer this PR deliberately doesn't use for generation, because it drops comments).
    diff --git a/ent/lessons/76-derived-env-precedence-order.html b/ent/lessons/76-derived-env-precedence-order.html index 5ae8e31..7209e0f 100644 --- a/ent/lessons/76-derived-env-precedence-order.html +++ b/ent/lessons/76-derived-env-precedence-order.html @@ -19,11 +19,11 @@

    PR #76 — Inject an ADJ_* boot environment into supervised apps

    -

    Compute a namespace of derived values into a Vec<(String, String)> with format!, then express precedence as insertion order — inject that layer last so Command::env's last-writer-wins overwrites anything the user set.

    -

    format!last-writer-winsderived-data

    +

    Compute a namespace of derived values into a Vec<(String, String)> with , then express precedence as insertion order — inject that layer last so Command::env's last-writer-wins overwrites anything the user set.

    +

    last-writer-winsderived-data

    2026-06-24 · +1303/−12 · View PR

    -

    The situation

    PR #17 injected one variable: $PORT. That let an app bind, but not address itself. A dev server behind the proxy still defaults its own links to localhost:<port> — wrong for every URL a user clicks through *.adj.ac. The daemon knows the real facts (the name, the .adj.ac host, the listener ports); the app can't reconstruct them. So the daemon now hands the app a whole reserved namespace — ADJ_NAME, ADJ_HOST, and four base URLs — computed at boot. And because they're authoritative, they have to beat anything the user set in env_file or [env].

    The Rust idea

    Two ideas, tied together.

    Deriving data with format!. format! is println!'s sibling that returns instead of prints: it allocates a brand-new owned String from a template. format!("https://{host}") interpolates the host variable directly (Rust's inline captures — the name in the braces is a real variable in scope). Owned is the key word: the returned String carries its own bytes, so a function can return it or push it into a Vec without any borrow outliving the stack frame it came from. That's what makes a pure "build the values" helper possible — feed it a name and two ports, get back owned pairs, no daemon or socket required to test it.

    Precedence as insertion order. Command::env(key, value) inserts-or-overrides a single entry (see #17). When you apply several layers to the same command, the order you apply them in is the precedence policy — the last write for a given key wins. There's no merge function, no priority flag, no "if not already set" check. To make one layer authoritative, you run it last. Precedence stops being a rule buried in a helper and becomes a visible fact about the sequence of statements.

    In this PR

    A pure helper builds the derived pairs. Note it returns Vec<(String, String)> — an ordered list of owned key/value tuples, not a map — because the caller only iterates it, and order is exactly what the precedence step needs:

    // crates/adj/src/supervisor.rs
    +      

    The situation

    PR #17 injected one variable: $PORT. That let an app bind, but not address itself. A dev server behind the proxy still defaults its own links to localhost:<port> — wrong for every URL a user clicks through *.adj.ac. The daemon knows the real facts (the name, the .adj.ac host, the listener ports); the app can't reconstruct them. So the daemon now hands the app a whole reserved namespace — ADJ_NAME, ADJ_HOST, and four base URLs — computed at boot. And because they're authoritative, they have to beat anything the user set in env_file or [env].

    The Rust idea

    Two ideas, tied together.

    Deriving data with . is println!'s sibling that returns instead of prints: it allocates a brand-new owned from a template. format!("https://{host}") interpolates the host variable directly (Rust's inline captures — the name in the braces is a real variable in scope). Owned is the key word: the returned carries its own bytes, so a function can return it or push it into a without any borrow outliving the stack frame it came from. That's what makes a pure "build the values" helper possible — feed it a name and two ports, get back owned pairs, no daemon or socket required to test it.

    Precedence as insertion order. Command::env(key, value) inserts-or-overrides a single entry (see #17). When you apply several layers to the same command, the order you apply them in is the precedence policy — the last write for a given key wins. There's no merge function, no priority flag, no "if not already set" check. To make one layer authoritative, you run it last. Precedence stops being a rule buried in a helper and becomes a visible fact about the sequence of statements.

    In this PR

    A pure helper builds the derived pairs. Note it returns Vec<(String, String)> — an ordered list of owned key/value tuples, not a map — because the caller only iterates it, and order is exactly what the precedence step needs:

    // crates/adj/src/supervisor.rs
     fn adj_env(name: &str, http: Option<u16>, https: Option<u16>) -> Vec<(String, String)> {
         let host = format!("{name}{}", crate::proxy::HOST_SUFFIX);
         let mut vars = vec![
    @@ -43,20 +43,20 @@ 

    The situation

    PR #17 injected one variable: $PORT. That } vars } -

    • host is computed once, then reused. host.clone() hands one owned copy to the ADJ_HOST pair while the original stays live for the three format! calls below it — each of those borrows host (&host under the hood) to read its bytes, so host must still exist.
    • The four unconditional entries go in a vec![…] literal. The two _DIRECT URLs are conditional: if let Some(p) = https unwraps the port only when it resolved, and .pushes a pair carrying it. A None port means that URL is simply absent — no entry pointing at a dead port. Optionality is expressed by whether the pair exists, not by an empty string.
    • Everything is an owned String. "ADJ_NAME".to_string() and each format! produce data the Vec owns outright, so the whole thing can be returned by value.

    Then up() applies it as the final env layer:

    // crates/adj/src/supervisor.rs
    +
    • host is computed once, then reused. host.clone() hands one owned copy to the ADJ_HOST pair while the original stays live for the three calls below it — each of those borrows host (&host under the hood) to read its bytes, so host must still exist.
    • The four unconditional entries go in a vec![…] literal. The two _DIRECT URLs are conditional: if let Some(p) = https unwraps the port only when it resolved, and .pushes a pair carrying it. A port means that URL is simply absent — no entry pointing at a dead port. Optionality is expressed by whether the pair exists, not by an empty string.
    • Everything is an owned . "ADJ_NAME".to_string() and each produce data the owns outright, so the whole thing can be returned by value.

    Then up() applies it as the final env layer:

    // crates/adj/src/supervisor.rs
     command.env(port_env, port.to_string());
     // Daemon-owned ADJ_* namespace: the app's own external identity and URLs. Injected
     // after env_file/[env] so these authoritative values win over anything a user set.
     for (k, v) in adj_env(&name, self.proxy_ports.http(), self.proxy_ports.https()) {
         command.env(k, v);
     }
    -

    The full precedence chain, top to bottom, is env_file → [env] → PORT → ADJ_*. Each command.env call overwrites the same key from an earlier layer. The ADJ_* loop runs last, so if a user set ADJ_HOST in their [env] table, the daemon's value silently overwrites it. That's the entire enforcement of "reserved, daemon-owned namespace" — no validation, no rejection, just position in the sequence.

    Because adj_env is pure and returns plain data, the unit tests skip the daemon entirely and collect the pairs into a HashMap for random-access lookup by key:

    // crates/adj/src/supervisor.rs
    +

    The full precedence chain, top to bottom, is env_file → [env] → PORT → ADJ_*. Each command.env call overwrites the same key from an earlier layer. The ADJ_* loop runs last, so if a user set ADJ_HOST in their [env] table, the daemon's value silently overwrites it. That's the entire enforcement of "reserved, daemon-owned namespace" — no validation, no rejection, just position in the sequence.

    Because adj_env is pure and returns plain data, the unit tests skip the daemon entirely and collect the pairs into a for random-access lookup by key:

    // crates/adj/src/supervisor.rs
     let vars: std::collections::HashMap<String, String> =
         adj_env("alannorton-com", Some(8080), Some(8443))
             .into_iter()
             .collect();
     assert_eq!(vars["ADJ_URL_DIRECT"], "https://alannorton-com.adj.ac:8443");
    -

    The builder returns a Vec (order matters, for precedence and for iteration); the test .collect()s it into a HashMap because it only wants "give me the value for this key." Same pairs, two container shapes, each chosen for what the code at hand does with them.

    Why it matters

    In a language where you mutate a shared, global environment or reach for a merge(defaults, overrides) helper, precedence is implicit and easy to get backwards. Someone reorders two blocks, or flips an argument, and user config silently starts winning over values that were supposed to be authoritative — a bug that type-checks and passes most tests. Here the policy is one readable fact: the ADJ_* loop is the last thing that touches the environment before spawn, so it wins by construction. Move it above the [env] layer and the precedence inverts — but you'd see that in the diff, because it's a line order, not a hidden flag.

    The derived-data half pays off in testing. format! returning owned Strings lets adj_env be a pure function of (name, http, https) — no I/O, no clock, no socket. Every value format, the worktree-key case, and the "port unresolved → drop the URL" rule get asserted in plain unit tests that run in microseconds, with the end-to-end test left to prove only that the wiring reaches a real child.

    Related lessons

    • PR #17 is the one-variable version of this exact mechanic: Command::env as insert-or-override, and why the value must be an owned String. #17 injects PORT alone; #76 generalizes it into a computed namespace and makes the layer order load-bearing. Read #17 first for the builder and inheritance model; this lesson is about deriving a set of values and using order as precedence.
    • PR #45 builds rows the cousin way — map + collect into a collection — where order doesn't carry meaning the way it does here.

    Dig deeper

    • std::fmt / the format! macro — the template syntax, inline argument captures ({host}), and that it returns an owned String.
    • std::collections::HashMap — the "Which collection?" preamble at the top of the std::collections module is the canonical guide to Vec vs HashMap: sequence-with-order vs lookup-by-key, exactly the choice this PR makes twice.
    +

    The builder returns a (order matters, for precedence and for iteration); the test s it into a because it only wants "give me the value for this key." Same pairs, two container shapes, each chosen for what the code at hand does with them.

    Why it matters

    In a language where you mutate a shared, global environment or reach for a merge(defaults, overrides) helper, precedence is implicit and easy to get backwards. Someone reorders two blocks, or flips an argument, and user config silently starts winning over values that were supposed to be authoritative — a bug that type-checks and passes most tests. Here the policy is one readable fact: the ADJ_* loop is the last thing that touches the environment before spawn, so it wins by construction. Move it above the [env] layer and the precedence inverts — but you'd see that in the diff, because it's a line order, not a hidden flag.

    The derived-data half pays off in testing. returning owned s lets adj_env be a pure function of (name, http, https) — no I/O, no clock, no socket. Every value format, the worktree-key case, and the "port unresolved → drop the URL" rule get asserted in plain unit tests that run in microseconds, with the end-to-end test left to prove only that the wiring reaches a real child.

    Related lessons

    • PR #17 is the one-variable version of this exact mechanic: Command::env as insert-or-override, and why the value must be an owned . #17 injects PORT alone; #76 generalizes it into a computed namespace and makes the layer order load-bearing. Read #17 first for the builder and inheritance model; this lesson is about deriving a set of values and using order as precedence.
    • PR #45 builds rows the cousin way — map + into a collection — where order doesn't carry meaning the way it does here.

    Dig deeper

    • std::fmt / the macro — the template syntax, inline argument captures ({host}), and that it returns an owned .
    • std::collections::HashMap — the "Which collection?" preamble at the top of the std::collections module is the canonical guide to vs : sequence-with-order vs lookup-by-key, exactly the choice this PR makes twice.
    diff --git a/ent/lessons/build.mjs b/ent/lessons/build.mjs index 717a1c3..3d06db1 100644 --- a/ent/lessons/build.mjs +++ b/ent/lessons/build.mjs @@ -368,8 +368,8 @@ table.index td.takeaway { color: var(--soft); } table.index tr.sparse td { color: var(--dim); } table.index .mark { color: var(--accent-dim); } /* Drawer (built by drawer.js) */ -.drawer-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.15s; z-index: 10; } -.drawer-scrim.open { opacity: 1; } +.drawer-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 10; } +.drawer-scrim.open { opacity: 1; pointer-events: auto; } .drawer { position: fixed; top: 0; right: 0; height: 100%; width: min(360px, 90vw); background: var(--ink); border-left: 1px solid var(--rule); transform: translateX(100%); transition: transform 0.18s ease-out; z-index: 11; padding: 1.75rem 1.5rem; overflow-y: auto; } .drawer.open { transform: none; } .drawer .close { position: absolute; top: 1rem; right: 1.1rem; background: none; border: none; color: var(--dim); font: inherit; font-size: 1.2rem; cursor: pointer; } diff --git a/ent/lessons/glossary.js b/ent/lessons/glossary.js index 17d9e1e..399dfa1 100644 --- a/ent/lessons/glossary.js +++ b/ent/lessons/glossary.js @@ -1,2 +1,2 @@ /* generated by build.mjs; edit docs/lessons/*.md */ -window.__GLOSSARY__ = {"arc":{"term":"Arc","aliases":["Arc","Arc>","Arc>"],"short":"Atomically reference-counted shared pointer. Cloning it bumps a refcount and hands back a second owner of the same value, not a copy.","why":"Lets two threads or tasks own one value at once. It is how a detached task can touch state the spawner still holds.","prereqs":["ownership","move","send-sync"],"link":{"label":"Rust Book 16.3","url":"https://doc.rust-lang.org/book/ch16-03-shared-state.html"}},"mutex":{"term":"Mutex","aliases":["Mutex"],"short":"A lock guarding interior data so only one holder mutates at a time. tokio's async Mutex yields the thread while waiting instead of blocking it.","why":"Shared ownership alone still races on writes. The Mutex is what makes the shared mutation safe, checked at compile time.","prereqs":["ownership","send-sync"],"link":{"label":"Rust Book 16.3","url":"https://doc.rust-lang.org/book/ch16-03-shared-state.html"}},"ownership":{"term":"ownership","aliases":[],"short":"Every value has exactly one owner; when the owner goes out of scope the value is dropped. No garbage collector, no manual free.","why":"The single-owner rule is the root of Rust's memory safety and the reason sharing has to be made explicit.","prereqs":[],"link":{"label":"Rust Book 4.1","url":"https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html"}},"move":{"term":"move","aliases":["move semantics"],"short":"Assigning or passing a non-Copy value transfers ownership; the source can no longer be used. `move` on a closure forces it to own its captures.","why":"A spawned task must own what it touches. `move` is how you hand ownership into it so it can outlive the caller.","prereqs":["ownership"],"link":{"label":"Rust Book 4.1","url":"https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html#variables-and-data-interacting-with-move"}},"send-sync":{"term":"Send / Sync","aliases":["Send","Sync","Send + Sync"],"short":"Marker traits the compiler infers: Send means a value can move to another thread; Sync means it can be shared by reference across threads.","why":"They are what let the compiler reject data races before the program runs. Arc is Send/Sync; Rc is not.","prereqs":["ownership"],"link":{"label":"Rust Book 16.4","url":"https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html"}}}; +window.__GLOSSARY__ = {"ownership":{"term":"ownership","aliases":[],"short":"Every value has exactly one owner. When the owner goes out of scope the value is dropped and its memory freed. No garbage collector, no manual free().","why":"The single-owner rule is the root of Rust's memory safety, and the reason sharing, borrowing, and moving all have to be made explicit rather than assumed.","prereqs":[],"link":{"label":"Rust Book 4.1","url":"https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html"}},"borrowing":{"term":"borrowing","aliases":["reference"],"short":"Accessing a value through a reference (&T) without taking ownership. You can have many shared &T reads, or exactly one exclusive &mut T, but never both at once.","why":"Borrowing lets code use a value without moving it, and the one-writer-or-many-readers rule is what stops data races at compile time instead of in production.","prereqs":["ownership"],"link":{"label":"Rust Book 4.2","url":"https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html"}},"move":{"term":"move","aliases":["move semantics"],"short":"Assigning or passing a non-Copy value transfers ownership; the source can no longer be used. `move` on a closure forces it to take ownership of what it captures.","why":"A spawned task must own what it touches. `move` is how you hand ownership into a closure so it can outlive the function that created it.","prereqs":["ownership"],"link":{"label":"Rust Book 4.1","url":"https://doc.rust-lang.org/book/ch04-01-what-is-ownership.html#variables-and-data-interacting-with-move"}},"lifetime":{"term":"lifetime","aliases":[],"short":"The span of code for which a reference stays valid. The compiler checks that no reference outlives the value it points at, sometimes with named annotations like 'a.","why":"Lifetimes are how Rust guarantees a reference can never dangle. Most are inferred; you only write them when the compiler can't tell which input a returned reference came from.","prereqs":["borrowing"],"link":{"label":"Rust Book 10.3","url":"https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html"}},"static-lifetime":{"term":"'static","aliases":["'static"],"short":"A lifetime meaning \"lives for the entire program.\" A value is 'static if it owns all its data (borrows nothing) or points at data baked into the binary.","why":"A task handed to a background executor must be 'static: it may outlive the function that spawned it, so it can't borrow anything on that function's stack.","prereqs":["lifetime"],"link":{"label":"Rust Book 10.3","url":"https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html#the-static-lifetime"}},"send-sync":{"term":"Send / Sync","aliases":["Send","Sync","Send + Sync"],"short":"Marker traits the compiler infers automatically: Send means a value can be moved to another thread; Sync means it can be shared by reference across threads.","why":"They are what let the compiler reject cross-thread data races before the program runs. Arc is Send + Sync; Rc is neither, which is why spawn rejects it.","prereqs":["ownership"],"link":{"label":"Rust Book 16.4","url":"https://doc.rust-lang.org/book/ch16-04-extensible-concurrency-sync-and-send.html"}},"interior-mutability":{"term":"interior mutability","aliases":["interior mutability","RefCell"],"short":"A pattern where you mutate data through a shared (&T) reference, with a wrapper type (Cell, RefCell, Mutex) enforcing the borrow rules at runtime instead of compile time.","why":"Sometimes you genuinely need to mutate shared state; interior mutability moves the one-writer check to runtime so the compiler will allow it, and a Mutex is its thread-safe form.","prereqs":["ownership","borrowing"],"link":{"label":"Rust Book 15.5","url":"https://doc.rust-lang.org/book/ch15-05-interior-mutability.html"}},"reference-counting":{"term":"reference counting","aliases":[],"short":"Sharing ownership of one value among several owners by keeping a count of how many exist; the value is dropped when the last owner goes away. Rc for one thread, Arc across threads.","why":"It's the escape hatch for when a single owner isn't enough. The count is the trade you make: cheap clones that share, at the cost of a runtime counter and no compile-time single-owner guarantee.","prereqs":["ownership"],"link":{"label":"Rust Book 15.4","url":"https://doc.rust-lang.org/book/ch15-04-rc.html"}},"rc":{"term":"Rc","aliases":["Rc"],"short":"A single-threaded reference-counted pointer. Cloning it bumps a non-atomic count and hands back another owner of the same value.","why":"Cheaper than Arc because the count isn't atomic, but that's exactly why it isn't Send: hand it to another thread and the compiler stops you. Reach for Arc when threads are involved.","prereqs":["reference-counting","send-sync"],"link":{"label":"Rust Book 15.4","url":"https://doc.rust-lang.org/book/ch15-04-rc.html"}},"arc":{"term":"Arc","aliases":["Arc","Arc>","Arc>","Arc"],"short":"Atomically reference-counted shared pointer. Cloning it bumps a thread-safe refcount and hands back a second owner of the same value, not a copy.","why":"Lets two threads or tasks own one value at once. It is how a detached task can touch state the spawner still holds. Wrap it around a Mutex when the shared value also needs mutation.","prereqs":["reference-counting","move","send-sync"],"link":{"label":"Rust Book 16.3","url":"https://doc.rust-lang.org/book/ch16-03-shared-state.html"}},"weak":{"term":"Weak","aliases":["Weak"],"short":"A non-owning reference to an Arc/Rc value. It doesn't keep the value alive; upgrade() returns None once the last strong owner is gone.","why":"Breaks reference cycles that would otherwise leak, and lets a map hold entries that clean themselves up: store Weak values and the map can drop the dead ones.","prereqs":["arc"],"link":{"label":"Rust Book 15.6","url":"https://doc.rust-lang.org/book/ch15-06-reference-cycles.html"}},"mutex":{"term":"Mutex","aliases":["Mutex","Mutex"],"short":"A lock guarding interior data so only one holder mutates at a time. tokio's async Mutex yields the task while waiting instead of blocking the whole thread.","why":"Shared ownership alone still races on writes. The Mutex is what makes the shared mutation safe, and the compiler won't let you reach the data without taking the lock first.","prereqs":["interior-mutability","send-sync"],"link":{"label":"Rust Book 16.3","url":"https://doc.rust-lang.org/book/ch16-03-shared-state.html"}},"async-await":{"term":"async / .await","aliases":[".await","async"],"short":"async fn returns a future that does nothing until awaited; .await drives it and yields control back to the runtime while waiting, so one thread can juggle many tasks.","why":"It's cooperative concurrency: awaiting a slow I/O call frees the thread to run other tasks instead of blocking, which is how a single process can supervise many servers at once.","prereqs":[],"link":{"label":"Rust Book 17.1","url":"https://doc.rust-lang.org/book/ch17-01-futures-and-syntax.html"}},"tokio-spawn":{"term":"tokio::spawn","aliases":["tokio::spawn"],"short":"Hands a future to the tokio runtime to run concurrently, returning a JoinHandle. The task may run on any worker thread and may outlive the caller, so it must be 'static and Send.","why":"The go-to way to run work in the background. Its 'static + Send bounds are exactly why detached tasks reach for Arc> instead of borrowing the caller's state.","prereqs":["async-await","send-sync","static-lifetime","arc"],"link":{"label":"tokio docs","url":"https://docs.rs/tokio/latest/tokio/task/fn.spawn.html"}},"timeout":{"term":"tokio::time::timeout","aliases":["tokio::time::timeout"],"short":"Wraps a future with a deadline; it resolves to Err once the duration elapses, cancelling the inner future.","why":"Bounds how long async work may take so a hung boot or a wedged health check turns into a clean, reportable failure instead of a task that waits forever.","prereqs":["async-await","result"],"link":{"label":"tokio docs","url":"https://docs.rs/tokio/latest/tokio/time/fn.timeout.html"}},"option":{"term":"Option","aliases":["Option","Option","Some","None"],"short":"An enum that is either Some(value) or None. Rust has no null; absence is a value you must handle, checked by the compiler.","why":"\"Might be missing\" becomes a case the type forces you to consider, so you can't accidentally use a value that isn't there. Serde fills a missing field with None for you.","prereqs":[],"link":{"label":"Rust Book 6.1","url":"https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html#the-option-enum-and-its-advantages-over-null-values"}},"result":{"term":"Result","aliases":["Result","Result","Ok","Err"],"short":"An enum that is either Ok(value) or Err(error). Rust has no exceptions; a fallible operation returns its error as a value you must handle.","why":"Failure is in the type signature, so you can't ignore it by accident. How you handle the Err (surface it, default it, propagate it) becomes a deliberate choice at each call site.","prereqs":[],"link":{"label":"Rust Book 9.2","url":"https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html"}},"question-mark":{"term":"the ? operator","aliases":["?"],"short":"On a Result or Option, ? unwraps the Ok/Some value or early-returns the Err/None from the enclosing function. It replaces a match that would just propagate the error.","why":"Turns manual error-forwarding into one character, keeping the happy path readable while still handling every failure explicitly.","prereqs":["result","option"],"link":{"label":"Rust Book 9.2","url":"https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html#a-shortcut-for-propagating-errors-the--operator"}},"combinators":{"term":"combinators","aliases":[".ok()","unwrap_or","unwrap_or_else","and_then","as_deref"],"short":"Methods on Option/Result that transform them without a match: map, and_then, ok, unwrap_or, unwrap_or_else, and friends. Each takes a closure or a default.","why":"They express \"do this if present, else that\" as a chain instead of a branch. The distinction matters: unwrap_or throws the error away, unwrap_or_else hands it to you first.","prereqs":["option","result","closure"],"link":{"label":"Option docs","url":"https://doc.rust-lang.org/std/option/enum.Option.html"}},"match":{"term":"match / if let","aliases":["match","if let"],"short":"match compares a value against patterns and must cover every case; if let is the shorthand for when you only care about one pattern and ignore the rest.","why":"Exhaustive matching means adding a new enum variant makes the compiler point you at every place that must handle it, so \"forgot a case\" becomes a build error.","prereqs":["option"],"link":{"label":"Rust Book 6.2","url":"https://doc.rust-lang.org/book/ch06-02-match.html"}},"closure":{"term":"closure","aliases":["closure"],"short":"An anonymous function written with |args| body that can capture variables from its surrounding scope, by reference or (with move) by value.","why":"Closures are how you pass behavior around: a combinator's transform, a task's body, a Drop guard's cleanup. What they capture, and whether they move it, decides their lifetime.","prereqs":["ownership"],"link":{"label":"Rust Book 13.1","url":"https://doc.rust-lang.org/book/ch13-01-closures.html"}},"iterator":{"term":"iterator","aliases":["Iterator",".iter()",".map()"],"short":"A lazy sequence: .iter() and adapters like .map() build a pipeline that does no work until something drives it. Nothing is computed until you consume it.","why":"Laziness means you can chain transforms cheaply and only pay for what you actually take. The pipeline stays declarative; collect (or a for loop) is what makes it run.","prereqs":["closure"],"link":{"label":"Rust Book 13.2","url":"https://doc.rust-lang.org/book/ch13-02-iterators.html"}},"collect":{"term":"collect","aliases":[".collect()","collect"],"short":"Consumes an iterator and gathers its items into a collection (Vec, HashMap, String, ...). It's the driver that finally runs a lazy iterator pipeline.","why":"Turns one collection into another in a single expression, with the target type telling collect what to build. It's where a map/filter chain stops being lazy and produces a value.","prereqs":["iterator"],"link":{"label":"Iterator::collect","url":"https://doc.rust-lang.org/std/iter/trait.Iterator.html#method.collect"}},"trait":{"term":"trait","aliases":["trait"],"short":"A named set of methods a type can implement, like an interface. Code can then require \"any type that implements this trait\" rather than one concrete type.","why":"Traits are Rust's main tool for shared behavior and abstraction, and implementing a library's trait is how you plug your own type into that library's machinery.","prereqs":[],"link":{"label":"Rust Book 10.2","url":"https://doc.rust-lang.org/book/ch10-02-traits.html"}},"generics":{"term":"generics","aliases":["generic","generics"],"short":"Writing code over a type parameter (like T or S) instead of a concrete type, so one function or struct works for many types.","why":"Write the logic once and let the compiler specialize it per type. Combined with trait bounds, generics give you abstraction with no runtime cost.","prereqs":["trait"],"link":{"label":"Rust Book 10.1","url":"https://doc.rust-lang.org/book/ch10-01-syntax.html"}},"trait-bound":{"term":"trait bound","aliases":["trait bound","trait bounds"],"short":"A constraint on a generic type, written like T: Display, saying \"T must implement this trait.\" It's what lets generic code actually call methods on T.","why":"The bound is the contract: it tells the compiler which methods are available on the type parameter, and tells callers which types they're allowed to pass.","prereqs":["generics"],"link":{"label":"Rust Book 10.2","url":"https://doc.rust-lang.org/book/ch10-02-traits.html#traits-as-parameters"}},"static-dispatch":{"term":"static dispatch","aliases":["static dispatch"],"short":"Resolving which concrete method to call at compile time. Generic code is monomorphized into a specialized copy per type, so calls are direct with no runtime lookup.","why":"It's abstraction for free: the generic loop over a stream type compiles down to the same machine code you'd have written by hand for each concrete type.","prereqs":["generics","monomorphization"],"link":{"label":"Rust Book 17.2","url":"https://doc.rust-lang.org/book/ch17-02-trait-objects.html#trait-objects-perform-dynamic-dispatch"}},"dynamic-dispatch":{"term":"dynamic dispatch","aliases":["dyn","dynamic dispatch","trait object"],"short":"Resolving the method at runtime through a trait object (dyn Trait), which carries a pointer to the right implementation. One code path handles many types.","why":"Trades a tiny runtime indirection for flexibility: you can store different implementing types together or choose one at runtime, which generics can't do.","prereqs":["trait"],"link":{"label":"Rust Book 17.2","url":"https://doc.rust-lang.org/book/ch17-02-trait-objects.html"}},"monomorphization":{"term":"monomorphization","aliases":["monomorphization","monomorphized"],"short":"The compiler's process of turning generic code into concrete code by stamping out a specialized copy for each type it's used with.","why":"It's why generics have zero runtime overhead: by the time the program runs, each generic use is just ordinary type-specific code. The cost is paid in compile time and binary size.","prereqs":["generics"],"link":{"label":"Rust Book 10.1","url":"https://doc.rust-lang.org/book/ch10-01-syntax.html#performance-of-code-using-generics"}},"drop":{"term":"Drop / RAII","aliases":["Drop","RAII"],"short":"The Drop trait runs cleanup code automatically when a value goes out of scope. Tie a resource's release to a value's lifetime and you can't forget to free it.","why":"Files close, locks release, and guards fire without a manual step. The catch: a signal-killed process skips destructors, so external cleanup can't rely on Drop alone.","prereqs":["ownership"],"link":{"label":"Rust Book 15.3","url":"https://doc.rust-lang.org/book/ch15-03-drop.html"}},"newtype":{"term":"newtype pattern","aliases":["newtype","HeaderValue"],"short":"Wrapping a value in a single-field struct to give it a distinct type, so a validated or domain-specific value can't be confused with a plain one.","why":"The wrapper is where you enforce an invariant once (a HeaderValue is a validated byte-string, not any String), and the type system carries that guarantee everywhere after.","prereqs":["trait"],"link":{"label":"Rust Book 19.3","url":"https://doc.rust-lang.org/book/ch19-03-advanced-traits.html#using-the-newtype-pattern-to-implement-external-traits"}},"derive-macro":{"term":"derive macro","aliases":["derive","#[derive(Deserialize)]","#[derive(Serialize, Deserialize)]"],"short":"A #[derive(...)] attribute that generates a trait implementation for your type at compile time, so you don't hand-write boilerplate impls.","why":"One line gives you the code for common traits (Debug, Clone, and serde's Serialize/Deserialize). You only hand-write the impl when the derived shape isn't exactly what you need.","prereqs":["trait"],"link":{"label":"Rust Book 19.5","url":"https://doc.rust-lang.org/book/ch19-05-macros.html#how-to-write-a-custom-derive-macro"}},"serde":{"term":"Serialize / Deserialize","aliases":["Serialize","Deserialize","serde"],"short":"serde's two traits for converting Rust values to and from formats like JSON or TOML. Usually derived; #[serde(...)] attributes tune field names, defaults, and shape.","why":"Deriving them on a dedicated DTO type pins your public wire format to a Rust type, so the contract lives in code and the compiler keeps the two in sync.","prereqs":["derive-macro"],"link":{"label":"serde.rs","url":"https://serde.rs/derive.html"}},"unsafe-ffi":{"term":"unsafe / FFI","aliases":["unsafe","FFI"],"short":"unsafe marks code where you, not the compiler, uphold the safety rules, typically to call across a foreign function interface (FFI) into C or a system library.","why":"It's the escape hatch for things the borrow checker can't verify, like calling into the OS keychain. The unsafe block is a deliberate, auditable boundary, not a free-for-all.","prereqs":["ownership"],"link":{"label":"Rust Book 19.1","url":"https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html"}},"instant":{"term":"Instant vs SystemTime","aliases":["Instant","SystemTime"],"short":"Instant is a monotonic clock for measuring elapsed time; SystemTime is the wall clock that can jump backward when the OS adjusts it.","why":"For deadlines and timeouts you want Instant, because it never goes backward. Reach for SystemTime only when you need an actual calendar time.","prereqs":[],"link":{"label":"std::time::Instant","url":"https://doc.rust-lang.org/std/time/struct.Instant.html"}},"vec":{"term":"Vec","aliases":["Vec","Vec"],"short":"A growable, heap-allocated array of T. It owns its elements and frees them when it's dropped.","why":"The default owned collection. Building one with the vec![] macro or by collecting an iterator is the everyday way to hold a variable number of values.","prereqs":["ownership"],"link":{"label":"std::vec::Vec","url":"https://doc.rust-lang.org/std/vec/struct.Vec.html"}},"hashmap":{"term":"HashMap","aliases":["HashMap"],"short":"A hash-table mapping owned keys to owned values, with average O(1) insert and lookup.","why":"The standard key-value store. It owns what you put in it, and methods like retain let you edit it in place without rebuilding it.","prereqs":["ownership"],"link":{"label":"std::collections::HashMap","url":"https://doc.rust-lang.org/std/collections/struct.HashMap.html"}},"hashmap-retain":{"term":"HashMap::retain","aliases":["HashMap::retain","retain"],"short":"Keeps only the entries for which a closure returns true, removing the rest in one in-place pass over the map.","why":"The idiomatic way to garbage-collect a map (for example, dropping entries whose Weak value has expired) without allocating a new one or iterating twice.","prereqs":["closure","hashmap"],"link":{"label":"HashMap::retain","url":"https://doc.rust-lang.org/std/collections/struct.HashMap.html#method.retain"}},"str-vs-string":{"term":"&str vs String","aliases":["String","&str"],"short":"String is an owned, growable UTF-8 buffer; &str is a borrowed view into one (or into a string literal). String owns, &str borrows.","why":"The distinction is ownership again: take &str in function arguments to borrow without allocating, and return String when the caller needs to own the result.","prereqs":["ownership","borrowing"],"link":{"label":"Rust Book 8.2","url":"https://doc.rust-lang.org/book/ch08-02-strings.html"}},"split-once":{"term":"str::split_once","aliases":["split_once","str::split_once"],"short":"Cuts a string at the first occurrence of a delimiter into two borrowed &str slices, returning Option so \"delimiter not found\" is a None case, not a panic.","why":"Exactly right for parsing key=value or label.name: you get both halves as borrows of the original, and the Option makes the missing-delimiter case something you handle, not a bug.","prereqs":["option","str-vs-string"],"link":{"label":"str::split_once","url":"https://doc.rust-lang.org/std/primitive.str.html#method.split_once"}},"format-macro":{"term":"format!","aliases":["format!"],"short":"Builds a new String from a template, filling {} placeholders with values. {name} captures a variable of that name directly from scope.","why":"The everyday way to assemble strings. Its inline-capture form keeps templated text readable, and it's how derived data like a URL or an env value gets composed.","prereqs":["str-vs-string"],"link":{"label":"std::format!","url":"https://doc.rust-lang.org/std/macro.format.html"}},"build-rs":{"term":"build.rs","aliases":["build.rs"],"short":"A build script: Rust code the compiler runs before compiling your crate. Printing cargo:rustc-env=KEY=VALUE injects a value the crate can read at compile time.","why":"It's how compile-time facts (a git SHA, a build timestamp) get baked into the binary, so the program can report exactly which build it is without any runtime lookup.","prereqs":[],"link":{"label":"Cargo Book","url":"https://doc.rust-lang.org/cargo/reference/build-scripts.html"}},"env-macro":{"term":"env! / option_env!","aliases":["env!","option_env!"],"short":"Compile-time macros that read an environment variable and bake it into the binary as a &'static str. env! fails the build if it's unset; option_env! yields an Option instead.","why":"Paired with build.rs, they turn build-time environment values into constants compiled into the program, so there's nothing to read or fail at runtime.","prereqs":["build-rs","static-lifetime"],"link":{"label":"std::env!","url":"https://doc.rust-lang.org/std/macro.env.html"}},"include-str":{"term":"include_str!","aliases":["include_str!"],"short":"A macro that reads a file at compile time and embeds its contents into the binary as a &'static str.","why":"Bakes a whole document (a template, a help text) into the executable, so it ships as one self-contained file with nothing to load from disk at runtime.","prereqs":["static-lifetime"],"link":{"label":"std::include_str!","url":"https://doc.rust-lang.org/std/macro.include_str.html"}},"command":{"term":"Command","aliases":["Command"],"short":"The builder for spawning a child process: chain .env(), .arg(), .stdout() to configure it, then .spawn(). It inherits the parent's environment by default.","why":"A textbook builder pattern, and the surface where a supervisor injects a child's port and environment before starting it, one .env() layer at a time.","prereqs":[],"link":{"label":"std::process::Command","url":"https://doc.rust-lang.org/std/process/struct.Command.html"}}}; diff --git a/ent/lessons/lessons.css b/ent/lessons/lessons.css index 2132359..fc20c78 100644 --- a/ent/lessons/lessons.css +++ b/ent/lessons/lessons.css @@ -39,8 +39,8 @@ table.index td.takeaway { color: var(--soft); } table.index tr.sparse td { color: var(--dim); } table.index .mark { color: var(--accent-dim); } /* Drawer (built by drawer.js) */ -.drawer-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.15s; z-index: 10; } -.drawer-scrim.open { opacity: 1; } +.drawer-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.4); opacity: 0; pointer-events: none; transition: opacity 0.15s; z-index: 10; } +.drawer-scrim.open { opacity: 1; pointer-events: auto; } .drawer { position: fixed; top: 0; right: 0; height: 100%; width: min(360px, 90vw); background: var(--ink); border-left: 1px solid var(--rule); transform: translateX(100%); transition: transform 0.18s ease-out; z-index: 11; padding: 1.75rem 1.5rem; overflow-y: auto; } .drawer.open { transform: none; } .drawer .close { position: absolute; top: 1rem; right: 1.1rem; background: none; border: none; color: var(--dim); font: inherit; font-size: 1.2rem; cursor: pointer; } From a09100948c1a36fcff65b8cf0f4c86417909c3a9 Mon Sep 17 00:00:00 2001 From: Alan Norton Date: Sun, 12 Jul 2026 09:28:22 -0700 Subject: [PATCH 16/19] Add the Rust lessons source series (one file per merged PR) The teaching series behind the generated ent/lessons/ site: 31 per-PR lessons plus _TEMPLATE.md and the README index. Committing the source so the site is regenerable from a fresh clone via `node ent/lessons/build.mjs`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01Ej8GZ5gZEaB2nsXG7aMxqn --- docs/lessons/16-supervised-app-with-logs.md | 97 ++++++++++ docs/lessons/17-inject-port-into-child-env.md | 87 +++++++++ docs/lessons/19-optional-fields-with-serde.md | 101 +++++++++++ .../lessons/22-serialize-wire-dto-contract.md | 125 +++++++++++++ docs/lessons/24-single-flight-boot-gate.md | 92 ++++++++++ .../lessons/29-deadlines-and-failure-enums.md | 117 ++++++++++++ docs/lessons/34-fix-landing-page-redirect.md | 99 +++++++++++ docs/lessons/36-docs-as-config-contract.md | 85 +++++++++ .../lessons/37-signals-bypass-drop-cleanup.md | 74 ++++++++ docs/lessons/38-deterministic-async-tests.md | 102 +++++++++++ docs/lessons/40-serve-https-local-ca.md | 121 +++++++++++++ docs/lessons/41-raw-string-templated-doc.md | 117 ++++++++++++ docs/lessons/43-trait-as-extension-point.md | 145 +++++++++++++++ docs/lessons/45-iterator-map-collect-rows.md | 108 ++++++++++++ .../46-revamp-landing-positioning-page.md | 131 ++++++++++++++ .../47-integration-tests-separate-crate.md | 124 +++++++++++++ docs/lessons/48-warn-dont-swallow-errors.md | 80 +++++++++ ...9-parse-dont-validate-actionable-errors.md | 87 +++++++++ .../lessons/50-weak-refs-self-cleaning-map.md | 84 +++++++++ docs/lessons/51-typed-http-header-values.md | 77 ++++++++ docs/lessons/52-abort-detached-tokio-tasks.md | 91 ++++++++++ .../53-http-upgrade-stream-splicing.md | 114 ++++++++++++ .../55-races-beyond-the-borrow-checker.md | 92 ++++++++++ docs/lessons/56-split-once-structured-keys.md | 90 ++++++++++ .../60-compile-time-version-stamping.md | 107 +++++++++++ docs/lessons/63-what-clippy-teaches.md | 108 ++++++++++++ docs/lessons/65-health-files-and-metadata.md | 119 +++++++++++++ docs/lessons/68-lean-agent-docs.md | 23 +++ docs/lessons/69-distributing-a-rust-cli.md | 118 +++++++++++++ docs/lessons/75-hand-render-config-to-disk.md | 166 ++++++++++++++++++ .../76-derived-env-precedence-order.md | 93 ++++++++++ docs/lessons/README.md | 85 +++++++++ docs/lessons/_TEMPLATE.md | 44 +++++ 33 files changed, 3303 insertions(+) create mode 100644 docs/lessons/16-supervised-app-with-logs.md create mode 100644 docs/lessons/17-inject-port-into-child-env.md create mode 100644 docs/lessons/19-optional-fields-with-serde.md create mode 100644 docs/lessons/22-serialize-wire-dto-contract.md create mode 100644 docs/lessons/24-single-flight-boot-gate.md create mode 100644 docs/lessons/29-deadlines-and-failure-enums.md create mode 100644 docs/lessons/34-fix-landing-page-redirect.md create mode 100644 docs/lessons/36-docs-as-config-contract.md create mode 100644 docs/lessons/37-signals-bypass-drop-cleanup.md create mode 100644 docs/lessons/38-deterministic-async-tests.md create mode 100644 docs/lessons/40-serve-https-local-ca.md create mode 100644 docs/lessons/41-raw-string-templated-doc.md create mode 100644 docs/lessons/43-trait-as-extension-point.md create mode 100644 docs/lessons/45-iterator-map-collect-rows.md create mode 100644 docs/lessons/46-revamp-landing-positioning-page.md create mode 100644 docs/lessons/47-integration-tests-separate-crate.md create mode 100644 docs/lessons/48-warn-dont-swallow-errors.md create mode 100644 docs/lessons/49-parse-dont-validate-actionable-errors.md create mode 100644 docs/lessons/50-weak-refs-self-cleaning-map.md create mode 100644 docs/lessons/51-typed-http-header-values.md create mode 100644 docs/lessons/52-abort-detached-tokio-tasks.md create mode 100644 docs/lessons/53-http-upgrade-stream-splicing.md create mode 100644 docs/lessons/55-races-beyond-the-borrow-checker.md create mode 100644 docs/lessons/56-split-once-structured-keys.md create mode 100644 docs/lessons/60-compile-time-version-stamping.md create mode 100644 docs/lessons/63-what-clippy-teaches.md create mode 100644 docs/lessons/65-health-files-and-metadata.md create mode 100644 docs/lessons/68-lean-agent-docs.md create mode 100644 docs/lessons/69-distributing-a-rust-cli.md create mode 100644 docs/lessons/75-hand-render-config-to-disk.md create mode 100644 docs/lessons/76-derived-env-precedence-order.md create mode 100644 docs/lessons/README.md create mode 100644 docs/lessons/_TEMPLATE.md 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 +`