v0.1.139
A graceful stop, an image with nothing left to fetch, and a guard that had died in silence.
docs/RELEASING.md allows one train per day; 0.1.138 shipped this morning.
This is not a security fix, not a broken package on the registry, and not a repair of the release
pipeline — 0.1.138 published all five of its artefacts. It ships today because the maintainer
decided it does, which is what that page says the decision is. Nothing here forced it.
Nothing an operator runs changes shape. Measured on the two tarballs rather than claimed:
64 files → 64, none added, none removed, five changed — package.json, docs/HOST-CONTRACT.md,
server/cache.js, server/handler.js, bin/serve.js. context, types, database and browser
are untouched, and the bundle a visitor's page executes is byte-identical to 0.1.138
(server/browser.generated.js, sha256 ad3af9dc56479147…, 16 809 bytes; shared.generated.js and
dist/bridge.js likewise).
it contains:
docker stopnow drains instead of cutting. In-flight requests get up to
PLAYER_SHUTDOWN_GRACE_MS(default 8 s) to finish. Keep it below your orchestrator's kill
delay —docker stopwaits 10 s by default.- The image no longer carries
dumb-init. Node runs as PID 1 and handles the signal itself. If
you extend the image with something that spawns child processes, add an init back or start it
withdocker run --init.
Fixed
⚠️ The.env.examplecheck read prose as data. It lived inline inci.ymland pulled every
backtick-quoted uppercase token out ofdocs/CONFIGURATION.md. The day that page mentioned
SIGTERM,SIGINTandSIGKILL— signal names, in a sentence — it demanded them in
.env.example. A check that asks you to bend your prose to please it teaches its readers to write
for the machine.⚠️ The obvious remedy was worse, and measuring said so before it was written. "Read only
the### \NAME`` headings" looked clean: the page carries two, while documenting
thirty-nine variables elsewhere in tables and inline mentions. The guard would have gone green
by looking at almost nothing — the too-tight pattern, fourth time this week.- What actually tells a variable from a word is that it exists elsewhere: the code reads it,
or the example file carries it. The rule now lives intools/env-exemple.mjs, which asks
env-lues.mjsfor its AST inventory instead of keeping a second one — and counts and names
what it sets aside, because a guard that hides what it did not look at claims coverage it does
not have. - The set-aside is not an escape hatch: a variable the code reads is kept, even when both files
forget it. Otherwise the exception would swallow the rule.
- The startup line printed the port it was asked for, not the one it got. With
PORT=0— where
the OS picks a free one — it announcedlocalhost:0, an address that leads nowhere, at exactly
the moment you need to know where to knock. Found by the shutdown bench, which could not reach
the server it had just started. Dockerfile:dumb-init's stated justification no longer held. It said Node had no default
signal handler, which was true and is not any more. What remains is zombie reaping — real in
general, empty here: this runtime spawns no subprocess (checked: nospawn,execFileor
forkinserver/,bin/orcontext/). It is kept out of caution rather than demonstrated
need, and the comment now says so instead of asserting a reason that has been fixed elsewhere.
It also records thatdumb-initis the one unpinned input of that image, and why the version
could not be resolved from where this was prepared.⚠️ The hourly publication guard had been dead for nineteen hours, and nobody could have seen
it.publication.ymlonly does acheckout— nonpm ci— because none of the tools it ran
had ever needednode_modules. Thenexemples-epingles.mjsgained a dependency onsemverin
0.1.137, to compare version intervals instead of demanding a literal string. That was the right
change; the workflow did not move with it. From that publication on, the step threw
ERR_MODULE_NOT_FOUNDbefore measuring anything.- It is the worst place to break. The job goes red — on the scheduled runs page, which
nobody opens. Meanwhile the issue that step maintains stayed frozen on its last true state:
it still announced0.1.128while the registry served0.1.138. A stale alert that looks alive
is worse than an absent one — it isAGENTS.md's third storey, an action that resembles a
success. - The breakage was contained: the earlier steps of the same job kept working, which is why
the version-gap alert for 0.1.138 opened and closed itself correctly. Found by reading that
job's log after noticing it was red on three consecutive runs — not by an alert.
- It is the worst place to break. The job goes red — on the scheduled runs page, which
⚠️ The changelog carried two### Fixedsections under one version. Same shape as the doubled
## [Unreleased]closed a day earlier, one level down: two branches each opened their own
subsection, git merged both without a conflict. The guard added for the first case reads only
##titles, so it stayed green — a rule fixed at one level does not protect the level below.
It now refuses a repeated###inside a version too. Found the same way as the first: by a merge,
not by the guard.docs/RELEASING.mdgave a command that returns404. Its post-release checklist said
docker manifest inspect ghcr.io/…:<version>, butimage.ymlpushes the git tag verbatim, so the
image is:v0.1.138. Thegh release view v<version>two lines above already carried thev—
the inconsistency lived four lines apart. Found by following the page during the 0.1.138 release
and getting the 404, which is the only way it could have been found: a registry answers404for
does not exist and for you asked for the wrong name with the same three digits. The page's own
closing rule applies to it — a procedure that cannot be carried out is worse than no procedure.
Removed
⚠️ dumb-initis gone from the container image, and the image now fetches nothing at build
time. Its written justification — "Node is PID 1 and has no default signal handler, so
docker stopwould wait ten seconds before killing" — was correct, and died whenbin/serve.js
gained aSIGTERMhandler: the kernel discards a signal on PID 1 only when no handler exists.
What remained was zombie reaping, a real job that is empty here — this runtime spawns no
subprocess.- It was the one unpinned input of the image.
apk addfetched the package over the network
with no version: same Dockerfile, same base digest, two differentdumb-initthree months
apart. Pinning it by checksum was written, then discarded: four moving parts — a build-time
network dependency, two digests to maintain, an architecture branch, and a hand-rolled
downloader because the alpine image carries no certificate store — for a component whose job is
empty. Removing it deletes the problem instead of checking it, and makes the build reproducible
unconditionally. ⚠️ The assumption is guarded, not left in a comment.bin/__tests__/sansSousProcessus.test.js
refuses the firstchild_processadded toserver/,bin/orcontext/— and its message
says the gesture: put an init back, or documentdocker run --init, then update the bench. The
decision is re-asked at the exact moment it becomes true again, instead of sleeping.⚠️ TheCMDstays in exec form, and that now matters.CMD node bin/serve.jswould put
/bin/shat PID 1, and a shell does not relay signals to its child: the handler would never
run and the graceful shutdown would be worthless. The bench holds that too.- Operators who need reaping are not stuck:
docker run --initinjects one without touching the
image, anddocs/CONFIGURATION.mdsays so.
- It was the one unpinned input of the image.
Changed
claude[bot]joins the CLA exemption list, on the maintainer's explicit decision. It is the
same agent asclaude, under the identity GitHub assigns depending on how the contribution
arrives: a pull request opened through the API comes out authored byclaude[bot], the same one
opened otherwise comes out asclaude. Measured on 25/08 — PR #392 was refused by this check for
that reason alone, on content identical to what had passed the day before.⚠️ This is not a technical fix, and it waited on purpose. Widening a CLA exemption list
decides who contributes without signing — governance, not tooling. On the day the refusal
landed, the tempting move was to loosen the guard to unblock a pull request; that is precisely
what one does not do. The PR was closed and reopened through the normal path, the gap was
reported, and the list moved only once the maintainer decided (26/08) — by the same reasoning as
its two neighbours, which are already there in their[bot]form.- The widening stops at named identities. A bench holds that
claude-fork[bot],
notclaude[bot]andclaudebotstill have to sign: the exemption covers accounts, never a
shape. Without it the list could drift toward anything ending in[bot], which would let
through any third-party app installed on the repository.
Added
⚠️ The standalone server shuts down gracefully — nothing listened forSIGTERMbefore. The
choice was between slow and abrupt, and the third way had never been put. Without a handler,
Node as PID 1 ignoresSIGTERM(the kernel discards a signal on PID 1 only when no handler
exists), sodocker stopwaited ten seconds and killed.dumb-initfixed that by being PID 1
itself, making Node a child — where the relayed signal triggers the default action: immediate
termination. Fast, but a document being relayed, a presentation read, a heartbeat were cut
mid-flight at every deployment.- It now stops accepting, closes idle keep-alive connections, lets in-flight requests finish, and
exits — with or withoutdumb-init. ⚠️ close()alone never completes: it waits for every connection, and keep-alive holds idle
sockets open for seconds after their last request. A shutdown that waits for those overruns the
orchestrator's deadline and gets killed anyway — an abrupt shutdown, only slower.
closeIdleConnections()closes what is no longer serving, without touching what is.⚠️ The grace period is bounded, and deliberately underdocker stop's ten-second default: a
deadline that falls after the axe is no deadline.PLAYER_SHUTDOWN_GRACE_MS(default 8000)
is documented, along with the rule that raising it means raising the orchestrator's too.- A second signal exits at once — pressing Ctrl-C twice asks for a stop, not an explanation. The
handlers are installed only on direct execution, likelisten: installed on import they
would hijack the Ctrl-C of whatever imported the module. - Benched against a real child process and real signals — a simulated signal proves nothing.
- It now stops accepting, closes idle keep-alive connections, lets in-flight requests finish, and
- The identity card reports what the read cache actually refused.
lectureSatureegives
{ total, fenetreS, derniereIlYaS }onGET /api/doc?contract=1. The admission ceiling has
answered503 Retry-After: 1for a long time — a refusal, deliberately distinguishable from a
500— but nothing counted how often it was reached, so "do we actually saturate?" had no
observable answer. That is the one question §2 of the CODEX 5.6 audit turns on, and the audit
itself said to wait for a measurement before merging anything on the hot path. This is that
measurement; no behaviour changes.⚠️ totalnever travels withoutfenetreS.total: 0does not mean we do not saturate
— on a process that started four seconds ago it means nobody has looked yet. That is the same
trap aspresenceFusion: "inconnu", and it is why the three keys are one object rather than
three fields a host could read apart.derniereIlYaSisnullwhen there has been no refusal, not0— which would read as
just now.- Process-local, like the
presence*fields: behind a load balancer this is the count of the
instance that answered. Aggregating is the host's job, and implying otherwise would be worse
than returning nothing. - Additive, so the
contractnumber does not move (rule 2).
- A guard that refuses a workflow step running a tool the job cannot serve. If a job runs
node tools/…and that tool needs an installed package, the job must carrynpm ci.⚠️ It follows the dependency through the imports, because that is where the defect lived:
exemples-en-retard.mjsdoes not importsemver— it importsexemples-epingles.mjs, which
does. A probe reading only the file named on the command line would have gone green on the exact
fault it was written for.- It is not an oversight rule, it is a distance rule. Whoever adds an
importto a tool does
not re-read nine workflows to see which ones run it, and is right not to: that is not work done
from memory. Three tools legitimately run with no installation, need nothing, and the guard says
so in its green line rather than staying silent about them.
- A guard that refuses a documented image reference the registry would not serve. Outside the
workflows, everyghcr.io/…reference must be untagged,latest, orv-prefixed — the form
image.ymlactually publishes. It does not hold a second copy of that fact: it confronts
image.yml, and if the workflow stops requiring^v[0-9]+\.[0-9]+\.[0-9]+$the guard reports
inconclusive rather than keep enforcing a rule the forge no longer applies.⚠️ Its first version missed the very defect it was written for.<was not in the tag's
character class, so:<version>— a documentation placeholder, not a real tag — read as
"no tag at all" and passed. Green on the exact line that had prompted it. The bench caught it;
this is the third pattern written too tight this week.- The rule is deliberately permissive: a tag without the
vis the fault, an unexpected tag is
not. Demanding an exact version shape would accuselatest, an example, or a shell variable.
Registry API URLs (https://ghcr.io/v2/…) are excluded by the://before them, and the
changelog's own quotation of the bad form excludes itself —…is not an OCI character, so no
filename exception has to be remembered.
What changed in the package, by zone — 0.1.138 → 0.1.139
Measured on the two tarballs, by the release workflow. Not written by hand.
| Zone | What it is | Added | Removed | Changed |
|---|---|---|---|---|
documents |
what a human reads | 0 | 0 | 1 |
manifest |
package.json — version, exports, dependencies | 0 | 0 | 1 |
server |
the code the host executes | 0 | 0 | 2 |
context |
the injected-context implementations | 0 | 0 | 0 |
browser-types |
the declarations the host's tsc reads for « ./bridge » — breaks a build, never a page | 0 | 0 | 0 |
browser |
what the visitors' page executes | 0 | 0 | 0 |
cli |
the command-line entry point | 0 | 0 | 1 |
types |
declarations for the server and context entry points — breaks a build, never runtime | 0 | 0 | 0 |
database |
the schema and the migrations the host applies itself | 0 | 0 | 0 |
The files themselves
~ docs/HOST-CONTRACT.md
~ package.json
~ server/cache.js
~ server/handler.js
~ bin/serve.js