Public: anyone can read this; the crew can change it.
A full disk makes the forge read-only with one plain sentence; it never exits… #12147
Check · Review · Land · Live done — Landed and live in production.
A full disk (`SQLITE_FULL` or `ENOSPC`) now puts the forge in one read-only state. It answers every write with one sentence and leaves the state by itself once space returns. The tests I ran pass, but none of them runs against a real full filesystem, and I did not record the `keep_plan` or `estimate` entries.
shipped production is serving it deploy 9328ecc
9 earlier comments · 1 event
A full disk (SQLITE_FULL or ENOSPC) now puts the forge in one read-only state. It answers every write with one sentence and leaves the state by itself once space returns. The tests I ran pass, but none of them runs against a real full filesystem, and I did not record the keep_plan or estimate entries.
What changed
- New module:
src/kernel/full_disk.tsrecognisesSQLITE_FULLandENOSPCby error code or message. - Write refusal: while the state holds, every non-read request gets a 503 with
Retry-After: 5and one sentence. The sentence says the disk is full, how much is free, what frees it, and that writes resume by themselves. Git pushes count as writes. - Where it triggers: the check sits at the front of the request handler, so no route can skip it. A request that throws a full-disk error in
failure()also enters the state. Reads and the internal hook listener are unaffected. - Needs-you line:
vitalsadds adisk-fullneed with the same sentence, so the home's needs-you line and the health endpoint show it. - Recovery: every 2 seconds while full, the forge tries a 64 KiB write under its data directory. When that fits, the state clears and writes work again.
- No unhandled
SQLITE_FULL:survivesFullis installed first insrc/main.ts, so boot, deploy and timer paths are covered. A full-disk rejection or exception enters the state and is logged instead of ending the process. Any other unhandled error still logs and exits with code 1. - Docs and tests:
docs/features/full-disk.mddescribes the behaviour.test/full-disk.test.tschecks the error recognition. It also takes the forge to the full state, checks that a JSON write and a form write both get the sentence, that reads still work and that vitals show the need, then frees space and waits for the forge's own timer to clear it.
Checks
- Passed:
bun scripts/gate.ts,bun x tsc --noEmit, and the tests for vitals, home vitals, read-only origins and the new file. - Not run:
scripts/fast.tscould not name the tests (the diff touchesvitals, which it treats as unbounded), so I picked those test files by hand.
Left out
- No real small filesystem: the sandbox cannot mount one, so the test replaces the recovery probe's write with one that fills and frees. The error shapes are the real ones. A real tmpfs run, like the owner's
chaosscripts, would be a good check on the preview. - No test for the
failure()path: a throwing handler entering the state is not covered by a test, nor issurvivesFull. - Sentence format: a full-disk error thrown inside a handler is answered with the plain-text sentence rather than JSON, because
failure()has no request to read. - Unconfirmed cause: I did not find why writes were still failing 60 seconds after space returned. If SQLite itself stays wedged after the probe clears, that needs the real-filesystem run.
To try it, push the branch and start the forge on a small volume. Fill the volume, and any POST answers the sentence with a 503. Free space, and writes work again within about 2 seconds.
🤖 Generated with Claude Code
I've pushed a fix for the failed check on #12147. bun scripts/gate.ts now prints nothing, bun x tsc --noEmit is clean, and test/full-disk.test.ts passes. I haven't seen the forge's own check on the new push yet.
The gate had four complaints, fixed as follows:
- Real timer: the 100 ms polling sleep at
test/full-disk.test.ts:87now ends with a// real-timer:note. The wait is for the forge's own 2-second recovery probe, so a fake clock wouldn't exercise it. - Wrapped comments: I shortened two comment lines in
src/kernel/full_disk.tsthat ran past 100 columns. - Pipeline first: the commit message now carries a
Direction:line. It says a full disk takes the forge read-only, and once exited it, which outranks the pipeline items.
On the first push I ran the gate before committing, which is why it reported nothing and I missed all four. This time I ran it again after the commit, and it is clean.
Nothing else about the change has moved from the original description. The recovery probe still stands in for a real small filesystem in the test, because the sandbox can't mount one.
A full data disk now makes the forge read-only with one sentence, and it recovers by itself. The new timer in src/kernel/full_disk.ts was breaking two guard tests, and I fixed both. I did not fix the other failures, which have different causes.
I ran test/job-slices.test.ts, test/timers-named.test.ts and test/full-disk.test.ts; all pass, and tsc is clean. I have not run the full check.
What changed in this push
- Timer wrapper: the 2-second recovery probe in
src/kernel/full_disk.tsnow runs insideduring("full-disk-probe", …).test/timers-named.test.tsrequires this for every server-side timer. - Job census: I listed
full-disk-probeas untimed intest/job-slices.test.ts, with the reason: it writes 64 KiB every 2 s only while the disk is full, and the full-disk test drivesprobeFulldirectly.
What I did not fix
- Of the 54 failures, I opened four guard tests. Two come from my change and are fixed above. Two come from other code:
src/crew/week_burn.tsis imported by nothing, andsrc/web/pages/home_flow.ts:486has a cookie site the cookie test doesn't know about. I did not look at the other failing tests, such as the 390 px page tests, so they may share those causes or have their own. src/crew/week_burn.tsandsrc/web/pages/home_flow.tsare not in this pull's diff.- The branch is 10 commits behind
main, which I did not look at. Rebasing may clear some of these; if the same failures come back after the next check, they need their own fix.
Left out
- The probe writes under the data directory only; it does not try to free space itself.
To try it
bun test test/full-disk.test.ts
Parked by the crew: the check stayed red through 2 automatic tries
I merged main into the branch and pushed it. test/job-slices.test.ts now passes (3 tests). I ran tsc --noEmit, but I didn't capture its output. I ran only that test file, not the wider suite, so the forge's check will be the first full run.
- Conflict: the
UNTIMEDlist intest/job-slices.test.tshad an entry from this pull (full-disk-probe) and one frommain(reach-asks). I kept both. - Second failure:
mainadded achild-db-deadlinejob insrc/spawn/child_db.tswith no entry in that list. That makes the "every job underduringis timed or named in UNTIMED" test fail onmaintoo. I added the entry, saying the fixture asks no child database. - A push I shouldn't have made: I pushed the merge commit before I'd seen that test fail. The next push fixed it, and the branch is green locally now.
The check failed because child-db-deadline appeared twice in the UNTIMED list in test/job-slices.test.ts. I had added an entry for it in my last push, and main has since added its own. I merged the latest main into the branch and deleted my entry, so the list has main's version only.
bun x tsc --noEmit is clean and test/job-slices.test.ts passes (3 tests). I ran only that file here, so the forge's check will be the first full run of the other tests. I've pushed this as 903d48e05.
A full disk now puts the forge in read-only mode. The two failures that were mine are fixed and pushed, but other failures in the check are not, and I haven't confirmed they come from main. test/full-disk.test.ts, test/unused-exports.test.ts and bun x tsc --noEmit pass locally. The full suite was not run here.
What changed
- SQLITE_FULL or ENOSPC from any write puts the forge in one read-only state. Writes get a 503 with one sentence and
retry-after: 5, reads keep working, and the needs-you line shows the same sentence. A small real write retried every 2 s ends the state once space returns. The code is insrc/kernel/full_disk.ts, wired intosrc/http/server.ts,src/kernel/vitals.tsandsrc/main.ts. - The state is held in process memory only, per data directory, so a restart reads the disk afresh.
The two failures I fixed
- Slow full-disk test: it waited about 2 s for the real probe timer. It now calls
probeFulldirectly. The timer itself is no longer exercised by a test. - Dead-code test:
forgetFullandprovideWriterForTestare test hooks with no caller outside tests. I added both toscripts/unused-exports-allow.json, as the other*ForTesthooks are.
Failures I did not touch
- The 429/503 test (
test/retry-after.test.ts) flagssrc/http/api/user.ts, which this pull doesn't change. - I did not look at the credit-spent page,
g fkeyboard-script, and hosted-forges failures at all.
To try it: bun test test/full-disk.test.ts. The test fakes the filesystem's writer, because the sandbox can't mount a small disk.
🤖 Generated with Claude Code
Changes needed (one round, all listed):
- HIGH, the terminal is a door and it is not gated. The write refusal sits only in the HTTP handler (
src/http/server.ts). SSH/TUI writes while the forge is full are not refused with the sentence; they hit SQLITE_FULL raw. Route the terminal's writes through the samefullRefusal/state check and have it show the sentence, with a test. - Behaviour without a test:
survivesFull(the exit-vs-survive split, including that other errors still exit 1) and thefailure()path that enters the state are both untested, and the 2 s probe timer is never exercised. The description admits this. Each needs a test that fails without it (inject the process hooks/exit; make a handler throw an ENOSPC error and assert 503 + state). isFullErrormatches/ENOSPC|SQLITE_FULL|.../against any error message. An error whose text echoes user input (a name, a path, a body) can put the whole forge read-only. Match oncode/errnoonly (and SQLite's exact message forbun:sqliteerrors with no code), not a free-text search of arbitrary messages; add a test for a user-text false positive./healthzand the 503 body are served to anyone, including how much disk is free and what to delete. Keep the public sentence generic and give the free-space figure only to the needs-you line for admins, or say why it is safe.- Docs claim
/healthzcarries the sentence; no test asserts it. Add one or drop the claim. Also confirm/systemexists as the need's href. - Direction: this is not on the pipeline-first list; the pull relies on a commit-message 'Direction:' line. State in the description why it outranks the list, and rebase on main (it has been merged forward repeatedly) so the check is run against current main.
The approach (one state, one sentence, self-recovering probe, reads still served) is right; fix the above and it should land.
A full disk now puts the forge in one read-only state that the web, git over SSH and the terminal UI all answer with the same sentence, and it ends by itself once space returns. I merged current main into the branch before pushing. test/full-disk.test.ts and test/unused-exports.test.ts pass, as do the vitals and job-slices tests. The test/retry-after.test.ts run still fails on src/http/api/user.ts, a file this pull doesn't touch. I did not run the whole suite.
What changed since the review (items 1–5)
- Terminal gated. An SSH exec other than
git-upload-pack(push,tell,talk, an app's verbs) prints the sentence and exits 1 while the forge is full. Inside the terminal UI,message()insrc/tui/screens/common.tsturns a full-disk error into the sentence and puts the forge in the state. A new test covers push,tell,talk, a fetch that still works, and the toast. I did not add a pre-check on each terminal screen's write, so a TUI write reaches the failure first and then gets the sentence. - Untested paths now tested.
- survivesFull takes injectable hooks. One test shows ENOSPC and SQLITE_FULL are survived, and that any other error or rejection still exits 1.
- A test makes the database throw ENOSPC under a request and asserts a 503 and that the state is entered.
- The probe timer is tested for real, with an interval that noteFull now accepts.
isFullErroris no longer a text search. It matchescode(ENOSPC, SQLITE_FULL*),errno(−28, 28), or SQLite's exact "database or disk is full" message on an error namedSQLiteError. Tests show that text echoing "ENOSPC" or "SQLITE_FULL", or that message on a plainError, does not put the forge read-only.- Public sentence is generic. The 503 body and the terminal's stderr say the disk is full, reading works, and writing resumes by itself. The free figure and what frees it appear only in the
disk-fullneed on/system, whichvitalsbuilds. - Docs corrected.
/healthzis a liveness check and does not carry the sentence, so I dropped that claim. A test asserts/healthzstays 200 while the forge is full./systemexists (GET /systeminsrc/http/server.ts).
Item 6 (direction): my reply I can't give a reason this outranks the pipeline-first list, and I'd leave that call to a person. The branch is merged forward onto current main.
🤖 Generated with Claude Code
This push fixes the three gate failures from the check log. After the fix, bun scripts/gate.ts, bun x tsc --noEmit and test/full-disk.test.ts pass locally (7 tests). I haven't seen the forge's check on the new push yet.
- Cast in the test: the fake terminal session in
test/full-disk.test.tsis now a typedSshSession, soas neveris gone. It has a real key record and thecols,rows,widthPxandheightPxfields. I also renameddrainedtodrain, which is what the interface calls it. - Timer wait: the probe-timer test's 80 ms
Bun.sleepnow carries a// real-timer:note saying why it waits. The test genuinely waits on the 20 ms probe timer, so I kept the wait and gave the reason instead of faking a clock. - Long comment: the comment at
src/kernel/full_disk.ts:130is rewrapped to 95 columns.
The gate only reported clean after I committed. Before the commit it kept showing the old line numbers, so it appears to read committed content.
I pushed a fix for the one failure this pull caused, in test/flake.test.ts. I haven't seen the forge's check on the new push yet.
flake.test.ts was red because the probe-timer test in test/full-disk.test.ts polled 50 times and then carried on if the state never ended, which a flake rule forbids. The loop now throws "the probe timer never ended the state" after 2 seconds. It still sleeps 20 ms between checks, under the // real-timer: note.
After the change, test/flake.test.ts and test/full-disk.test.ts pass together (43 tests). bun scripts/gate.ts and bun x tsc --noEmit are clean.
The check log's other failures are in files the forge says are red on main at b2c98db too, so it doesn't count them against this pull: retry-after, deadends, first-ask-new-app, data-browser, keys-goto and boundary-tenancy-cell. I left them alone.
Earlier asks met: the terminal is gated (SSH exec other than git-upload-pack refuses with the sentence, TUI toasts it, tested). survivesFull and the failure() path are tested. isFullError matches code, errno or SQLite exact message only, never free text. The public sentence carries no figure or remedy, and only the /system need does. The docs are corrected and live in docs/features/full-disk.md, not the README. The test gate and flake fixes are in. Direction: a full disk outage is a production-down failure, and I accept the commit Direction line. Non-blocking: failure() answers plain text to API callers because it has no request, and the TUI write gets the sentence only after hitting the failure.
A full disk (
SQLITE_FULLorENOSPC) now puts the forge in one read-only state. It answers every write with one sentence and leaves the state by itself once space returns. The tests I ran pass, but none of them runs against a real full filesystem, and I did not record thekeep_planorestimateentries.What changed
src/kernel/full_disk.tsrecognisesSQLITE_FULLandENOSPCby error code or message.Retry-After: 5and one sentence. The sentence says the disk is full, how much is free, what frees it, and that writes resume by themselves. Git pushes count as writes.failure()also enters the state. Reads and the internal hook listener are unaffected.vitalsadds adisk-fullneed with the same sentence, so the home's needs-you line and the health endpoint show it.SQLITE_FULL:survivesFullis installed first insrc/main.ts, so boot, deploy and timer paths are covered. A full-disk rejection or exception enters the state and is logged instead of ending the process. Any other unhandled error still logs and exits with code 1.docs/features/full-disk.mddescribes the behaviour.test/full-disk.test.tschecks the error recognition. It also takes the forge to the full state, checks that a JSON write and a form write both get the sentence, that reads still work and that vitals show the need, then frees space and waits for the forge's own timer to clear it.Checks
bun scripts/gate.ts,bun x tsc --noEmit, and the tests for vitals, home vitals, read-only origins and the new file.scripts/fast.tscould not name the tests (the diff touchesvitals, which it treats as unbounded), so I picked those test files by hand.Left out
chaosscripts, would be a good check on the preview.failure()path: a throwing handler entering the state is not covered by a test, nor issurvivesFull.failure()has no request to read.To try it, push the branch and start the forge on a small volume. Fill the volume, and any POST answers the sentence with a 503. Free space, and writes work again within about 2 seconds.
🤖 Generated with Claude Code
Closes #12144