Repository navigation
Commit 8731510
fix(tooling): check:type-check-debt's closure build takes the shared verify lock, and a call from inside a hold runs NESTED (#23486)
Fixes #23470
Clause-②: no
## What this changes
`pnpm check:type-check-debt` runs `--re-measure`, which rebuilds the
ledgered packages' dependency closure (`refreshBuiltClosure`: a full
workspace `turbo run build --filter=./packages/*
--filter=./packages/*/*`). It ran that build directly, outside the
shared verify lock, so it rewrote `dist/` under a dev's locked test
battery in the same tree. That is the card's measured false red:
`MODULE_NOT_FOUND` on `@objectstack/spec/*`, three re-runs. The rebuild
itself stays (it came from #8271). What changes is the lock contract
around it.
- **`scripts/check-type-check-coverage.mjs`.** The closure build now
runs through `scripts/pm/os-verify-lock.sh -- TURBO run build ...`. The
gate reads the lock's `VERDICT` line, never the bare exit code. There
are three refusals, all exit 3 (PREREQUISITE NOT MET):
- The build's own failure. The text is unchanged.
- A turn the lock never gave (`queue-timeout`, `lock-unusable`, a usage
or filter refusal). Nothing was built and nothing was measured. The
remedy names `OS_VERIFY_LOCK_SLOT` and wrapping the whole gate in the
lock.
- A wrapper that printed no verdict, or a verdict that disagrees with
its exit code.
The lock's own lines go to the caller on every outcome. The gate's tsc
reads after the build stay unlocked. One header paragraph (LOCKED, under
BUILT CLOSURE) says why the rebuild takes the lock.
- **`scripts/pm/os-verify-lock.sh`.** Nested calls. The wrapped command
gets `OS_VERIFY_LOCK_HELD_BY=HOLDERPID:LOCKFILE`. A call carrying it
runs its command at once, inside that hold, with `NESTED` on its VERDICT
line. That happens only when all three hold: the marker names this lock
file, the holder record names that pid, and that pid is an ancestor of
the call. Anything less queues as before, so a wrong "no" costs the old
bounded wait and never an unlocked run. A nested run writes no ticket,
no holder record and no ledger row: the outer call holds the lock and
records the hold.
## Why the nesting check sits in the entry point and not in the gate
Measured on `origin/main` at `cb9841aa2`, on a private lock file:
- The lock exports no marker. The child's environment named only the two
knobs the caller had set.
- It closes fd 9 in the child (fd count 0).
- A second call from inside a hold queued behind its own ancestor for
its whole budget: `VERDICT queue-timeout (exit 99)` after a 6s budget,
which is 540s by default.
So with the gate taking the lock itself, a dev who runs `bash
scripts/pm/os-verify-lock.sh -c 'pnpm check:type-check-debt'` would wait
540s and then be refused.
To decide "this process tree already holds the lock", you need the lock
file path, the holder record and a liveness rule. All three are the lock
script's own state, and reading them from the gate would make a second
owner of a private format. So the smallest lock-script change that makes
nesting decidable (exporting the marker) comes with the check, placed
beside the state it reads. That placement also fixes the same
self-deadlock for every other caller that nests.
After the change, the same nested call runs in 0.15s and prints `VERDICT
command-exit 3 · NESTED inside the hold of pid ...`. The ledger gets one
row, the outer call's.
End to end at `e69ee947f`:
- **M1: `bash scripts/pm/os-verify-lock.sh -c 'pnpm
check:type-check-debt'`.** Outer `ACQUIRED after 0s`. The gate's build
printed `VERDICT command-exit 0 · NESTED inside the hold of pid 13338`,
then `--re-measure: OK`. Exit 0, outer hold 15s.
- **M2: plain `pnpm check:type-check-debt`.** The gate's build printed
`queued: 0 ahead of you`, `ACQUIRED after 0s`, `VERDICT command-exit 0 ·
held the lock 0s · waited 0s`, then `--re-measure: OK`. Exit 0.
## CI
The `lint.yml` job `typecheck-debt` (`Type Check · debt ledger`,
ubuntu-latest) runs `pnpm check:type-check-debt` in step
`re-measure-the-type-check-debt-test-debt`, after its own closure
builds. `ubuntu-latest` ships util-linux `flock`, and nothing else holds
`/tmp/os-heavy-verify.lock` on a runner. So the gate takes the lock at
once: measured locally on a fresh private lock, one entry-point call
takes 0.13s with `waited 0s`.
A host with no usable `flock` runs the lock's declared unlocked mode.
The gate reads `UNLOCKED (declared)` as a build that ran, and passes the
disclosure through. No CI step waits on a lock nobody holds. The
lock-script change also schedules CI's `verify-lock` family (`bash
scripts/pm/os-verify-lock.sh --self-test`).
No `lint.yml` sentence about this gate turned false. At `:6072`-`:6126`,
the gate still runs "this exact command with these exact filters", now
under the lock. The `unbuiltClosure()` refusal still comes first. The
NODE_OPTIONS heap pairing is untouched.
## Pin and ablation
**Gate self-test.** New battery `verify-lock cases (#23470)`, 19 cases:
- 11 VERDICT-reader rows.
- 2 wiring pins: `refreshBuiltClosure` spawns nothing itself, and runs
turbo through `runUnderVerifyLock`.
- 6 real-lock cases on a private lock file in a temp dir:
- A stand-in build that writes a marker file must not run while another
locked run holds the lock. This is the card's pin.
- The stand-in must pass 0 and 7 out through the verdict.
- Run in the gate's own argv shape from inside a hold, it must run
NESTED.
Whether the host can lock at all is asked of the entry point directly,
so a wrapper that stopped locking cannot pass itself off as a host
without `flock`.
**Lock self-test.** Four new nested cases: the marker shape, a nested
call runs at once and passes its exit code out with a NESTED verdict,
only the outer call writes a ledger row, and a hold on another lock file
is not nesting. One forged-marker case: a marker naming the live holder
from outside its process tree queues and exits 99.
Ablations, each through `scripts/ablation-replace.mjs`: the anchor hit,
the blob changed, and the restore was proven (blob == HEAD, `git diff
HEAD` empty). No build or `dist/` is involved; both suites run from
source.
| # | mutation | suite | result |
|---|---|---|---|
| A1 | `refreshBuiltClosure` spawns turbo directly (the old shape) |
gate self-test | red, 2 failures: the wiring pins |
| A2 | `runUnderVerifyLock` bypasses the entry point | gate self-test |
red, 5 failures, including "the build RAN while another locked run held
the lock" |
| A3 | lock's nested recognition disabled | gate / lock self-test | red
2 / red 3 (the inner call exits 99 instead of 5, no NESTED, 2 ledger
rows) |
| A4 | lock's ancestry clause deleted | lock self-test | red 1 (the
forged marker nests: exit 0 instead of 99) |
The first A2 run went red only through the host probe, which then read
through the ablated wrapper. That run did not exercise the "build ran
while held" pin. Commit `e69ee947f` makes the probe independent of the
wrapper, and the re-run fired the pin directly.
## Local verification, at HEAD `e69ee947f`
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 31 commands. All 31 were run with
exit codes recorded, all 0. `--ran` printed: `31 derived, 31 run, 0
NOT-MEASURED, 0 UNRUN`, with the zero derived from the recorded codes.
- `pnpm check:type-check-coverage` passed with 19 verify-lock cases. The
self-test went from 0.4s to 2.1s; 1s of that is the contention case's
queue budget.
- `pnpm check:type-check-debt` exited 0 in both forms (M1 and M2 above).
The closure was first built under the lock, cold: 74 tasks, 9m20s,
`VERDICT command-exit 0`.
- `pnpm check:pm-dispatch-gates` exited 0 (fast tier, 1841 cases).
- `bash scripts/pm/os-verify-lock.sh --self-test` passed all cases (249
checks, 107s) at `9b48f035a`. The lock script is byte-identical at HEAD.
- `bash scripts/ci/select-gate-families.selftest.sh` passed: 59 cases.
How each ran: the closure build and M1 went through
`scripts/pm/os-verify-lock.sh -c`. M2 ran unlocked on purpose, because
the gate's own locking is what it measures. The `check:*` gates and the
two self-tests ran unlocked. Both self-tests use private lock files, and
the lock's own self-test is about locking.
## Acceptance notes
- **The mirror direction is not addressed (inference, not measured).**
The gate's tsc reads after the build still run unlocked, so a sibling's
locked build could rewrite `dist/` under this gate's measurement. Owner:
none. Noted, not filed.
- **A plain caller can now wait.** `pnpm check:type-check-debt` run
unlocked can now wait up to the lock's 540s budget before its build. The
refusal names a slot and the whole-gate wrap as remedies. The os-dev
role file's sentence that `check:*` gates do not go through the lock
stays true of the caller. It is not edited here (`.claude/**` is off
this card's surface).
- **The battery-roster floor now pins the real count.**
`SELF_TEST_BATTERY_FLOOR` read 62 against 64 declared batteries. It now
pins the 65 declared.
- **Changeset.** Both files are root `scripts/`, in the private root
package, and no package `files[]` ships them, so this PR takes
`skip-changeset` and no changeset file.
---
_Generated by [Claude
Code](https://claude.ai/code/session_012VGBku7tEi8DX2uvi1gtXk)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 0d3ea29 commit 8731510
2 files changed
Lines changed: 455 additions & 10 deletions
0 commit comments