Skip to main content

max / makenotwork

Close the door a generated file used to re-arm The recompile-on-every-invocation bug has landed twice through two doors. bd448cbe left a deleted file in the watched static_files list; d5728b5a fixed it and added an assert so a watched path that stops existing fails loudly. a52ec579 then started writing generated scripts into a directory markup_files globs, one day later, and the assert did not apply because those files exist. bd44f102 fixed that instance with write_if_changed. An instance is not the class. A generated file under static/ is a file this script both writes and watches, because check_vocabulary_use emits a rerun-if-changed for every file it reads, and the symptom is a gate that is quietly twice as slow with nothing failing. GENERATED_STATIC_JS is now one list with two consumers: the write loop iterates it and markup_files excludes it, so the generated scripts are not in the watch set at all. Two assertions keep the two in step. The first catches a listed entry markup_files cannot see, such as one named .mjs or written outside static/. The second reads .gitignore, which is the other place a generated file must be named, and refuses any static/*.js entry there that the const does not carry. Read as a file rather than asked of git, so it costs no subprocess and still works where there is no git. Excluding the four costs nothing measurable: the dead-vocabulary count is 44 of 88 either way, so the seal is unchanged. They are quasi-webview's emitted output rather than markup this repo writes, so measuring them against this server's own vocabulary seal was answering a question about another crate. Both assertions were tested by making each condition true. The first deliberately does not catch a deleted or renamed file and its comment says so: the write loop regenerates every entry before markup_files runs, so a missing one is recreated rather than absent.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-26 16:02 UTC
Signed with PGP, not checked
Commit: 92f28c0a47d00cc0b7faf19d697bed14ac2051f3
Parent: 4fb8d5a
1 file changed, +100 insertions, -6 deletions
M server/build.rs +100 -6
@@ -38,12 +38,7 @@
38 38 // Written from the crate's own constants for the reason they are constants:
39 39 // the script reads hooks the emitter writes, so a copy checked in here goes
40 40 // stale against the next bump in silence. Gitignored, like the stylesheets.
41 - for (name, source) in [
42 - ("quasi-selection.js", quasi_webview::SELECTION_JS),
43 - ("quasi-clock.js", quasi_webview::CLOCK_JS),
44 - ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
45 - ("quasi-fill.js", quasi_webview::FILL_JS),
46 - ] {
41 + for (name, source) in GENERATED_STATIC_JS {
47 42 // `write_if_changed`, not `fs::write`, and this is load-bearing rather
48 43 // than a micro-optimisation. `markup_files` globs `static/*.js`, and
49 44 // `check_vocabulary_use` emits a `rerun-if-changed` for every file it
@@ -501,11 +496,45 @@
501 496 /// It tightens as screens convert, exactly as the lines above did.
502 497 const DEAD_VOCABULARY_HIGH_WATER: usize = 44;
503 498
499 + /// The scripts this build script generates into `static/`.
500 + ///
501 + /// One list, two consumers, and that is the whole point of it being a const.
502 + /// The write loop iterates it, and [`markup_files`] excludes it. Those two have
503 + /// to agree or the build recompiles on every cargo invocation forever.
504 + ///
505 + /// # Why a generated file may never reach `markup_files`
506 + ///
507 + /// `markup_files` globs `static/*.js`, and `makeover_build::check_vocabulary_use`
508 + /// emits a `cargo::rerun-if-changed` for every file it reads. So a generated file
509 + /// under `static/` is a file this script both **writes and watches**: the write
510 + /// moves its mtime, cargo then sees a watched file newer than the build script's
511 + /// own output, and the fingerprint can never settle. Nothing fails, nothing warns;
512 + /// the crate just recompiles every time, which cost a second full 5m25s compile
513 + /// inside every Sando `cargo_test` gate for five days.
514 + ///
515 + /// That has now happened twice through two different doors — `bd448cbe` left a
516 + /// deleted file in the watched list, and `a52ec579` added generated ones to a
517 + /// globbed directory the day after the first was fixed. `write_if_changed` fixes
518 + /// an instance. This list plus the assertion in `markup_files` closes the door.
519 + ///
520 + /// **Adding a generated file under `static/`? Add it here too.**
521 + const GENERATED_STATIC_JS: [(&str, &str); 4] = [
522 + ("quasi-selection.js", quasi_webview::SELECTION_JS),
523 + ("quasi-clock.js", quasi_webview::CLOCK_JS),
524 + ("quasi-download.js", quasi_webview::DOWNLOAD_JS),
525 + ("quasi-fill.js", quasi_webview::FILL_JS),
526 + ];
527 +
504 528 /// Every file that can carry a class name.
505 529 ///
506 530 /// Sorted within each group, so two machines read the same set in the same
507 531 /// order. It makes no difference to the count and every difference to reading a
508 532 /// diff of the warning.
533 + ///
534 + /// Generated files are excluded: see [`GENERATED_STATIC_JS`] for why watching one
535 + /// is a build that never settles. These four are also quasi-webview's emitted
536 + /// output rather than markup this repo writes, so measuring them against this
537 + /// server's own vocabulary seal was answering a question about another crate.
509 538 fn markup_files() -> Vec<std::path::PathBuf> {
510 539 let mut files = Vec::new();
511 540 for (dir, extension) in [
@@ -519,6 +548,71 @@
519 548 found.sort();
520 549 files.extend(found);
521 550 }
551 +
552 + // The door, closed. Excluding by file name rather than by full path because
553 + // `collect` walks recursively and the generated ones sit at the root of
554 + // `static/`; a name collision deeper in the tree would be a second file
555 + // called `quasi-clock.js`, which is its own problem.
556 + let before = files.len();
557 + files.retain(|path| {
558 + !path
559 + .file_name()
560 + .and_then(|name| name.to_str())
561 + .is_some_and(|name| GENERATED_STATIC_JS.iter().any(|(g, _)| *g == name))
562 + });
563 +
564 + // Catches a listed script that `markup_files` cannot see: an entry given a
565 + // name `collect` does not glob, such as a `.mjs` or one written somewhere
566 + // other than `static/`. It would be excluded from nothing while still being
567 + // written and watched.
568 + //
569 + // It deliberately does NOT catch a deleted or renamed file, and the comment
570 + // here used to claim it did. It cannot: the write loop iterates this same
571 + // const and regenerates every entry before this function runs, so a missing
572 + // one is recreated rather than absent. Verified by deleting
573 + // `static/quasi-fill.js` and watching the build stay green. The `.gitignore`
574 + // cross-check below is the half that does the real work.
575 + let excluded = before - files.len();
576 + assert_eq!(
577 + excluded,
578 + GENERATED_STATIC_JS.len(),
579 + "markup_files excluded {excluded} generated scripts but GENERATED_STATIC_JS \
580 + names {}. A generated file under static/ that reaches markup_files is one \
581 + this build script both writes and watches, which recompiles the crate on \
582 + every cargo invocation. Update GENERATED_STATIC_JS to match what is written.",
583 + GENERATED_STATIC_JS.len(),
584 + );
585 +
586 + // The other half of the guard, and the one that catches the likelier
587 + // mistake. The assertion above catches a listed script that stopped
588 + // existing; this catches a NEW generated script nobody added to the list.
589 + //
590 + // `.gitignore` is the cross-check because it is the other place a generated
591 + // file has to be named: it is not committed, so it either gets an entry or
592 + // it shows up as untracked in every `git status` until someone adds one.
593 + // Read rather than asked of `git`, so this costs no subprocess and still
594 + // works where there is no git at all -- and where the file is unreadable it
595 + // fails open, which is the right direction for a guard rather than a gate.
596 + let ignored = fs::read_to_string("../.gitignore").unwrap_or_default();
597 + for name in ignored
598 + .lines()
599 + .map(str::trim)
600 + .filter_map(|line| line.strip_prefix("server/static/"))
601 + .filter(|name| {
602 + Path::new(name)
603 + .extension()
604 + .is_some_and(|ext| ext.eq_ignore_ascii_case("js"))
605 + })
606 + {
607 + assert!(
608 + GENERATED_STATIC_JS.iter().any(|(g, _)| *g == name),
609 + "`.gitignore` names generated script static/{name}, which \
610 + GENERATED_STATIC_JS does not. markup_files therefore watches a file \
611 + this build script writes, and the crate will recompile on every cargo \
612 + invocation with no other symptom. Add it to GENERATED_STATIC_JS.",
613 + );
614 + }
615 +
522 616 files
523 617 }
524 618