One section per decision, in the order they were taken. Each says what the situation was, what was decided, and what that costs. A decision is never edited: a later one supersedes or refines it, and the earlier one’s status names it. A note under the status may correct what a decision foresaw and did not happen, such as a release or a package it planned; it decides nothing.
| Decision | Status | |
|---|---|---|
| 1 | Apache-2.0, and GSI holds the copyright | accepted, refined by 28 |
| 2 | The go line is the oldest Go release still supported | accepted |
| 3 | What the kit may require | accepted, refined by 22, 24, 26 and 43 |
| 4 | A release is a signed tag | accepted, superseded in part by 10 |
| 5 | Progress and its displays are the kit’s own | accepted |
| 6 | The pools are the kit’s own | accepted, refined by 12 |
| 7 | Escapes are for a reader, not for decoding | accepted, refined by 14 |
| 8 | Widths err wide, and follow Unicode 18.0 | accepted |
| 9 | The region comes off one line a row | accepted |
| 10 | A daily audit holds the releases to their record | accepted |
| 11 | A deadline ends a pool’s items as an interrupt does | accepted, refined by 12 and 13 |
| 12 | The program’s identity lives on the Bus | accepted, refined by 43 |
| 13 | The skip error lives in progress | accepted |
| 14 | The escape deny-list may grow in a minor release | accepted, refined by 42 |
| 15 | A new or stricter Check rule is a breaking change | accepted |
| 16 | A large set of targets is folded again once a second | accepted |
| 17 | The cost tests run in CI, alone | accepted |
| 18 | The live clocks tick in tenths of a second | accepted |
| 19 | The displays draw in themes the program picks | accepted, refined by 31, 36 and 46 |
| 20 | A theme’s look may change in a minor release | accepted, refined by 46 |
| 21 | The command line’s glue for progress is the kit’s | accepted |
| 22 | The kit may require golang.org/x/sys, in cliprogress alone | accepted, refined by 26 |
| 23 | A span reports its work, sampled, and a sink rolls it up | accepted, refined by 44 |
| 24 | The strict command tree is the kit’s, in cobratree | accepted, refined by 29 and 39 |
| 25 | The local web server is the kit’s, in localweb | accepted, refined by 34 and 35 |
| 26 | golang.org/x/sys also in fsutil and localweb | accepted, refined by 35 and 37 |
| 27 | Progress over MCP is built now, in mcpprogress | accepted, refined by 32, 33 and 44 |
| 28 | Code of the programs may move into the kit, under the kit’s licence | accepted |
| 29 | cobratree words flag values, carries exit statuses and shows help topics | accepted, refined by 39 |
| 30 | A command’s result is written by cliout, as a table or as JSON, escaped | accepted, refined by 42 |
| 31 | cliprogress keeps a default log, picks colours and asks questions | accepted |
| 32 | mcpprogress sends a line that counts targets, and stops on the first failure | accepted, refined by 44 |
| 33 | The kit reads its event log back, in eventlog | accepted, refined by 45 |
| 34 | localweb guards a local web server, keeps its sessions and streams its pages | accepted, refined by 35 and 40 |
| 35 | localweb starts a server once, opens the browser on it, and serves its files and pages | accepted, refined by 41 |
| 36 | The shapes and names of the API are settled before v1 | accepted |
| 37 | fsutil writes, locks and checks the files of a program | accepted, refined by 47 |
| 38 | doctor reports a program’s checks in fixed words and text | accepted |
| 39 | cobratree marks every error of the command line, and Strict takes options | accepted |
| 40 | localweb takes one form for each hook, and stops its streams with the server | accepted |
| 41 | launch waits under a context, page limits its includes, and htmx restores history as a page | accepted |
| 42 | termtext keeps a CRLF line end, and its writer and limits take one shape | accepted |
| 43 | fanout returns the error of Batches, names its limits, and commits to go-nodeset | accepted |
| 44 | progress names each thing once, and a nil Bus is safe | accepted |
| 45 | eventlog tells a newer log apart, and forwards one run | accepted |
| 46 | display’s themes cannot be changed, and its names follow decision 36 | accepted |
| 47 | fsutil gives a file that exists exactly perm when asked | accepted |
| 48 | The kit has a documentation site, beside pkg.go.dev | accepted |
1. Apache-2.0, and GSI holds the copyright#
Status: accepted, refined by 28
Context#
The kit’s packages were written for clusterctl,
which GSI licenses under LGPL-3.0-or-later as its copyright holder. As a
module of their own they are meant for any command-line program, and for
library packages that report progress through them, whose importers then
take the kit too. For all of them the Lesser GPL is a burden in Go, where
every program links its packages statically: a proprietary program that
imports the kit has to let its users relink it (section 4 of the LGPL).
Libraries in the Go ecosystem are permissive; the Go project’s own
modules, golang.org/x/..., are BSD-3-Clause.
Decision#
The module is licensed under the Apache License, Version 2.0, and GSI Helmholtz Centre for Heavy Ion Research GmbH http://www.gsi.de holds the copyright.
LICENSEholds the licence text verbatim, so that GitHub and pkg.go.dev recognise it. pkg.go.dev shows the documentation only of a module whose licence it recognises as redistributable.LICENSES/Apache-2.0.txtholds it again for the REUSE specification.- Every file carries
SPDX-FileCopyrightTextandSPDX-License-Identifierin its own comment style, andreuse lintchecks this in CI. - The year is 2026, when the work was first written, and it is not moved forward.
- There is no
NOTICEfile.
Why Apache-2.0#
- It grants a licence to every patent a contributor holds that the
contribution uses, and ends that licence for anyone who sues over one.
BSD-3-Clause, Go’s own licence, says nothing about patents; the Go project
adds a grant of Google’s in a separate
PATENTSfile. MIT is silent too. - It is compatible with LGPL-3.0 and GPL-3.0, so programs under either licence can import the module.
Moving code in#
GSI holds the copyright of the code that moves here, so it can license this
copy under Apache-2.0. The commit that moves a file changes its
SPDX-License-Identifier line. The code keeps its LGPL-3.0-or-later
licence in the history of the repository it comes from.
Costs#
- Apache-2.0 is not compatible with GPL-2.0-only, so a program under that licence cannot import the module.
- A redistributor keeps the copyright and licence notices of every file.
- A contributor from outside GSI keeps the copyright of their contribution
and licenses it under Apache-2.0 (section 5); a file they change then
names them in an
SPDX-FileCopyrightTextline of their own.
2. The go line is the oldest Go release still supported#
Status: accepted
Context#
Since Go 1.21 the go line in go.mod is a requirement, not a hint.
An older toolchain refuses the module, or downloads a newer one, and every
module that requires this one inherits at least this go line when it
runs go mod tidy. The line is therefore the oldest Go that every
importer has to use.
The Go project ships a release in February and in August. It supports the two newest, and only those receive security fixes. In September 2026 those are Go 1.27 and Go 1.26.
The kit’s code needs Go 1.25, the first release with sync.WaitGroup.Go,
which fanout uses. Go 1.25 has had no security fixes since August 2026,
and at 1.25 the kit would have to hold golang.org/x/text at v0.41.0,
since v0.42.0 requires Go 1.26.
Decision#
go.modnames the oldest Go release that the Go project still supports, as thegolang.org/xmodules do:go 1.26.0today. It names a.0release, never a patch release, andgo.modhas notoolchainline.- When a new Go release ships, the line moves up to the release before it, in a commit of its own, and the next release of the module is a minor version. Raising it further takes a record of its own.
mise.tomlnames the newest Go release line. Contributors and CI use its newest patch.- CI tests both ends: the newest patch of the release
go.modnames, and that of the onemise.tomlnames.
Costs#
- A program built with a Go release that is no longer supported cannot take a new version of the module. It keeps the version it has.
- A requirement whose newest release needs a newer Go than the floor is held back until the floor moves.
- The line is moved twice a year, by hand, since Dependabot does not; the
bump-goskill describes how.
3. What the kit may require#
Status: accepted, refined by 22, 24 and 43
Context#
Every requirement of the kit becomes a requirement of each tool that uses it, and, through library packages that report progress with it, of every program that imports those. Minimal version selection then raises their versions of shared modules to the highest any requirement asks for: a single package of the MCP Go SDK inside the kit would raise every such program’s version of that SDK to the kit’s.
Decision#
- The kit may require
github.com/GSI-HPC/go-nodeset, to fold node names, andgolang.org/x/text, for display widths. Anything else takes a record here first. - Never: the MCP Go SDK (an MCP bridge takes a send callback instead),
charmbracelet (the displays write to an
io.Writerthemselves), testify and OpenTelemetry (progress is reported as the kit’s own events). - cobra only in a package about cobra,
cobratree, when it arrives.termtext,progressandfanoutare imported by library packages too, and never import cobra. - Test helpers ship as
xxxtestpackages, such asprogress/progresstest, and report throughtesting.TB. Tests use the standard library. - golangci-lint’s depguard refuses the imports this record rules out.
With the Go floor at 1.26 (decision 2), golang.org/x/sys and
golang.org/x/term no longer need a newer Go than the kit; either one still
takes a record here before it is required.
Costs#
- A display, a test assertion or an exporter that a third-party module would offer is written here, or done without.
- A program’s exit codes stay in the program, behind a
Classifyhook, so that the kit needs no package of exit codes.
4. A release is a signed tag#
Status: accepted, superseded in part by decision 10
Context#
A version of a Go module is a tag. The module proxy serves any tag of a public repository as soon as someone asks for it, and the checksum database records its content for good: deleting or moving the tag afterwards does not unpublish it.
Decision#
- Nothing in the tree names a version: no
VERSIONfile, no constant, noCHANGELOG.md, andCITATION.cffhas noversion. - The maintainer releases by pushing an annotated tag
vX.Y.Z, signed with an SSH or an OpenPGP key. The body of the tag message, everything after its first line, is the release notes. - The Release workflow refuses a tag that no key in the
RELEASE_ALLOWED_SIGNERS(SSH) orRELEASE_ALLOWED_PGP_KEYS(OpenPGP) repository variable signed under the name it was pushed as, and a tag the go command would not accept as a version of this module. It tests the tagged commit on both Go lines, publishes the GitHub release with the tag body as its notes, and fetches the version through proxy.golang.org, so that pkg.go.dev lists it. - A tag is never moved or deleted. A broken release is withdrawn with a
retractdirective ingo.mod, which ships in the next release. - The module stays at v0 while its API settles, and a v0 minor release may break it; the release notes say how. A new package arrives in a minor release. v1.0.0 is a decision of its own, once programs have used the kit through a release without changing its API.
doc/release.md says how to cut a release and how to set up the
verification.
Costs#
- The workflow cannot stop the module proxy from serving a tag it refused.
Its failure is the alarm, and retraction is the remedy; a tag ruleset that
lets only the maintainers create
v*tags is what keeps others from pushing one. - The signing key becomes part of the release process. Two formats are accepted, so that a maintainer signs with the key they already use; the verification and its tests cover both.
- An OpenPGP key is trusted as the variable holds it: an expired key stops verifying on its own, a revoked one only once the variable holds its revocation.
5. Progress and its displays are the kit’s own#
Status: accepted
Context#
The kit’s packages came from clusterctl, whose
ADR 0021
chose events of its own over OpenTelemetry and measured the two in its
binary: the event model added 69,632 B, one package and no module;
OpenTelemetry’s sdk/trace 1,056,768 B, 39 packages, 12 modules and 9
requirements. A target’s lifecycle cost 1.7 µs and 8 allocations with a
sink, against 2.9 µs and 20 allocations with a span processor that does
nothing; and 19 ns and no allocation without a Bus. ADR 0021 compared no
display library, and a kit that sind and the libraries it exports import too
has to answer for both.
The design review of 27 September 2026 that preceded the kit built the candidates into sind and clusterctl and measured what each costs a program that links it:
| Candidate | What it costs | What it lacks |
|---|---|---|
The kit: progress alone | +65,536 B in sind | |
The kit: progress, display, fanout and go-nodeset | +233,472 B in sind (+2.2%), 5 packages, no third-party module | |
| OpenTelemetry’s API alone | 3 to 7 requirements | A display, a queued state, an in-process sink |
| mpb | +704 KB, and a go 1.26 line when the kit’s floor was lower | A tree of spans; its bars are the model |
| bubbletea | +1.17 MB, +15 modules | It owns the terminal and the event loop; charmbracelet is ruled out by decision 3 |
BuildKit’s progressui | +53 requirements | A module of its own; it lives in BuildKit’s |
| go-pretty’s progress | Little | A tree, and a region that yields to a question |
| kind’s spinner | None | It is internal to kind, and one line |
None of them keeps the promises the kit’s displays make to the command they
share the terminal with: a region taken off before every write the command
makes and never drawn over a question, lines from other goroutines held while
one is asked, a count that reaches its total after an interrupt, and nothing
but the command’s own output without a display. Nor does any escaper found
for the text a display draws: those of kenn/termtext, runesafe and
loglayer delete or replace characters, go-gh covers C0 and C1 alone,
Kubernetes’ EscapeTerminal ESC and CR alone, and strconv.Quote adds
quotes and escapes newlines.
Decision#
- The kit reports progress as its own events,
progress, and draws them with its own displays,progress/display, which write to anio.Writerthemselves. - It escapes text for a terminal with its own escaper,
termtext, whose policy its package comment records. - A program that wants OpenTelemetry converts the event log (event-log.md); the kit never imports it, and depguard keeps it so (decision 3).
Costs#
- The displays, the event model, the escaper and their fuzz targets are the kit’s to maintain, and a terminal UI among them has narrow terminals, resizes, multiplexers and locales to cope with.
- A change to how a display looks is a release of the kit, and a change to the text consumers compare in their tests is a breaking one.
- There is no exporter and no propagation beyond reading a
traceparent.
6. The pools are the kit’s own#
Status: accepted, refined by decision 12
Context#
A command on many hosts works on them side by side, a bounded number at a
time, and a display counts them. For the count to be right, the pool has to
announce every item as queued before the first runs, mark each running as it
takes its place, end each before it gives the place up, and end those it
never started, so that the count reaches its total after an interrupt too.
progresstest.Check holds every source of events to this.
The design review of 27 September 2026 tried the pools a Go program would reach for:
golang.org/x/sync/errgroupwithSetLimitwent on starting work after its context was cancelled, in 10 of 10 runs against none of 10 for the kit’sEach, and it does not recover a panic, so a panic in one item’s work ends the process. Its first error cancels the rest, which is right for some commands, but the step it reports has to be assembled around it: a prototype of sind’s that announced and ran the items in one loop brokeCheck’s rule that every item is queued before the first runs.sourcegraph/concrecovers panics, but has had no release since v0.3.0, in February 2023.alitto/pondis a pool that outlives the call, with workers to stop, where a command wants one bounded loop per step.
None of them reports its items, and none keeps the order of the results.
Decision#
- The kit’s pools are its own:
fanout.Each, the one bounded loop, which starts nothing once its context has ended;Map, which reports its items as the targets of a step, returns what each came to in the order given, and turns a panic in one into that item’s error; andBatches, which runs a node set in batches with a pause between them. - A pool knows no program: the program’s name, its rule for the class of an error and the error a step ends with are options.
- A pool that stops at the first failure, as errgroup does, is a later
option of
Map, specified by tests that compare it with errgroup, when a program needs it.
Costs#
- The pools are the kit’s to maintain, and their concurrency is the kit’s
to get right; the race detector,
testing/synctestandCheckare how it is tested. - A program that wants errgroup’s semantics, the first failure cancelling
the rest, keeps errgroup until
Maphas them, and announces its items queued itself.
7. Escapes are for a reader, not for decoding#
Status: accepted, refined by decision 14
Context#
termtext.EscapeText and EscapeCell show a control character, such as
ESC, as a visible escape, \x1b, and write a backslash as it is. Text that
holds the four characters \x1b therefore looks the same as text whose ESC
was escaped: the escapes are not injective, and what is shown cannot always
be decoded back to what came.
Escaping the backslash as well, as strconv.Quote does, would make them
injective, but would double every backslash in a Windows path, a regular
expression or a shell command a host reports, and escaped text would change
each time it was escaped again. progress.Sanitize, which the Bus
applies to every text it records, escapes with EscapeCell and may meet
text sanitised once already, and progresstest.Check accepts a text only
if EscapeCell leaves it as it is. Both rely on escaping twice changing
nothing, which termtext.FuzzEscape and progress.FuzzSanitize test.
Decision#
- A backslash is written as it is. The escapes are for a person reading a terminal, and escaping is idempotent.
- The package comment of
termtextsays that the escapes cannot always be decoded, and that a program that has to tell the two apart keeps the text as it came.
Costs#
- A host can print text that looks like an escaped control character without one. It cannot move the cursor or change the terminal with it, which is what the escaper guards against.
- A program that needs the original, to compare or store it, cannot take it
from a display, from escaped text or from the event log, whose texts
progress.Sanitizeescapes too, and keeps its own copy.
8. Widths err wide, and follow Unicode 18.0#
Status: accepted
Context#
A display cuts each row with termtext.Truncate to the columns of the
terminal, so that it never wraps: a row that wraps pushes the region down
and leaves a copy of it behind with each frame. A width that is less than a
terminal draws breaks this; one that is more only cuts a row short.
golang.org/x/text/width, the East Asian Widths RuneWidth reads, knows
Unicode 15.0, so emoji and other characters made wide since then were
counted as one column. A character followed by the variation selector
U+FE0F, such as a red heart or a keycap, is drawn as a two-column emoji
picture by terminals but was counted as one. A full grapheme clusterer, or
a module such as go-runewidth or uniseg, would need a record under
decision 3, and terminals do not agree among themselves on how wide a
joined emoji sequence is.
Decision#
- Widths are meant never to be less than a terminal draws: a row may be cut a little short, but never wraps.
RuneWidthadds to the tables ofgolang.org/x/texta table of its own, kept by hand intermtext/width.go, of the characters whose East Asian Width is wide in Unicode 18.0 but not in those tables. The soft hyphen and the prepended concatenation marks, format characters that are drawn, take one column, as does U+1171E, which the Unicode tables of Go, thatRuneWidthreads for the marks of no width, call a combining mark and Unicode 18.0 a spacing mark; it is the only such character.WidthandTruncatecount a character of one column followed by U+FE0F as two columns, andTruncatekeeps such a character without its selector when the selector would reach past the columns given.- Graphemes are not clustered: a sequence joined with U+200D, a skin tone modifier or a flag counts as the sum of its runes, which is more than a terminal shows, never less.
Costs#
- The table has to be brought up to date by hand when a version of Unicode
makes more characters wide, until
golang.org/x/textcatches up; a wide character assigned after Unicode 18.0 counts as one column until then. A mark that a later version of Unicode makes spacing has to be added toRuneWidthby hand in the same way. - A terminal that draws a character with U+FE0F as one column shows less
than
Widthcounts, and a row holding one is cut a column short there. - A joined emoji sequence is counted wider than it is drawn, and a row holding one is cut shorter than it need be.
9. The region comes off one line a row#
Status: accepted
Context#
A display takes its region off the terminal by moving the cursor up and clearing a line at a time, before every write the command makes and before every frame. It cannot ask the terminal where the region now is. Each row is cut to fit the terminal when it is drawn, but a terminal made narrower since does one of two things with a row wider than it now is. Most, VTE, Konsole, iTerm2, Terminal.app, Windows Terminal and tmux among them, reflow it onto as many lines as it takes; xterm, the Linux console and GNU screen cut it, and it stays one line. Neither says which it does.
Counting the lines a row takes at the new width takes the region off whole on a reflowing terminal, but on a cutting one it clears as many lines above the region as the count goes past, and those are the command’s own output. Counting one line a row takes the region off whole on a cutting terminal, and on a reflowing one leaves the first lines of each such row above the region.
Decision#
- The region comes off one line for each of its rows, as they were drawn, whatever the width is now. The region is all a display owns: it never clears a line it cannot be sure is its own.
- The frames drawn after a resize are cut to the new width, so the region is exact again from the next frame on.
Costs#
- On a reflowing terminal made narrower under a drawn region, the first lines of each row wider than the new width stay on the terminal, above the next frame and the command’s output, until they scroll away.
10. A daily audit holds the releases to their record#
Status: accepted
Context#
Decision 4 has the Release workflow verify a tag when it is pushed. A tag push runs the workflow as the tagged commit has it, so a tag on a commit from before the workflow, or on one that changes it, verifies nothing. The module proxy keeps what it fetched first under a version: a tag pushed, fetched through the proxy and deleted again leaves the version served without a tag, and a tag deleted and pushed again on another commit, even a signed one, leaves the proxy serving the first. The keys change as well: an OpenPGP key that expires stops verifying the tags it signed, and a key that is retired or leaked has to be removed without failing the releases it signed.
A bad version cannot be removed from the proxy, only withdrawn with a
retract directive, so an alarm that goes on failing for a version that
has been withdrawn would hide the next one. Withdrawing a version answers
for the content the proxy serves under it, though, not for whatever its
tag names later.
Decision#
- The Release workflow also runs every day from
main, and by hand, and then verifies everyv*tag against the listed keys. It reads the versions proxy.golang.org lists for the module, and fails for each that has no tag, and for each whose.infonames, asOrigin.Hash, another commit than its tag. A version whose.infohas noOrigin, which the proxy leaves out for a version it fetched long ago, is unverified, a warning, unless theTimein it is not the committer date of the commit the tag names, which fails. When the list cannot be read, the audit verifies the tags all the same, and fails. Its failure is the alarm. - The publishing job fetches the version through the proxy, checks that the proxy serves it from the verified commit, and only then publishes the GitHub release, so that no release is published for a version the proxy refused or serves from another commit. Run again, it leaves a release an earlier attempt published as it is.
- A published release is pinned in the
RELEASE_VERIFIED_TAGSrepository variable: its tag and the id of the tag object verified when it was pushed. The audit holds a pinned tag to that object instead of to today’s keys, and fails once the tag names another object or is gone. - A key is retired by pinning the releases it signed and then removing it;
a key that may have leaked is removed at once, and only the releases
known to be genuine are pinned. A key is never retired with
valid-before, which git checks against the date in the tag, a date the signer writes. - A version that
go.modretracts on a line of its own, not as part of a range, acknowledges the alarm for what the retraction withdraws, the content the proxy serves under it. The audit then reports as a warning, and passes, only a missing tag, a version the proxy lists without one or a pinned release whose tag is gone, and a tag that is neither pinned nor signed while the proxy records, asOrigin.Hash, that it serves the version from the commit the tag names. It still fails a retracted version whose tag names another commit than the proxy records, moved or deleted and pushed again; a pinned tag that is not its pinned object, whatever commit it names; and a bad tag whose version the proxy records no commit for, since nothing then says which commit was withdrawn. - A tag pushed as a release is still never moved or deleted, with two exceptions. A tag that was not pushed as a release, such as one an intruder moved, or deleted and pushed again, is restored to the pinned tag object for a pinned release, or to the tag the proxy fetched, or deleted. A bad tag of a retracted version whose commit nothing records is deleted, even one pushed as a release, since the retraction can acknowledge only a version without a tag then.
doc/release.md says how the audit is read, and how a release is pinned
and withdrawn.
Costs#
- The audit detects and does not prevent: it finds a bad tag up to a day
late. What keeps others from pushing or deleting a
v*tag is a tag ruleset that lets only the maintainers create one and nobody delete it. - It relies on proxy.golang.org’s list of versions for a tag deleted
between two runs, and on the proxy’s
Originfor the commit a version is served from. For a version withoutOriginthe time check is only a consistency check: a commit made with the same committer date passes it. - Every release is pinned by hand once it is published; until it is, the audit holds it to today’s keys, and an unpinned release fails once its key expires or is removed.
- GitHub stops the schedule of a repository with no activity for 60 days, and the workflow has to be enabled again by hand.
- A version is acknowledged one line at a time, so a range of bad versions
is retracted version by version. A retracted version’s tag that is
deleted is gone from the repository for good; the proxy, the checksum
database and the
retractline are then its only record. - What a retraction acknowledges stays in every run of the audit as a warning.
11. A deadline ends a pool’s items as an interrupt does#
Status: accepted, refined by decision 12 and decision 13
Context#
progress names ClassTimeout for work that ran out of time and
ClassCanceled for work that was interrupted, and progress.Classify
classes context.DeadlineExceeded as a timeout. A pool runs its items
under one context, though, and when that context ends, whether interrupted
or past its deadline, the pool starts no further item, and the work of
those running gives up with whatever error it returns, which need not say
why. Classed by their own errors, the items one deadline cut short would
end as timeouts, failures of their own, or as whatever their work
returned, and the step would end failed, although no item ran out of its
own time.
Decision#
- The end of a pool’s context ends its items canceled,
ClassCanceled, whether it was interrupted or ran out of time: thoseMapleft out, and those whoseAcquirerefused them or whose work returned an error other than ofSkiponce the context had ended. Their targets end with the context’s error, in a type that saysClassCanceled, and when every item that failed ended so,Summarizeis told that they were interrupted, whichFailureends canceled. The pool, not the item, stopped the work. Batchesends the batches the end of its context left out canceled in the same way. Its step, when no batch failed, and astaggerwait the context ended, end with the context’s error as it is, so that a deadline ends them failed, as timeouts, and an interrupt canceled; a batch that was run ends with what its run returned.- An item that runs out of its own time, a call whose own deadline passed while the pool’s context had not ended, is classed by its error as any other, and so stays a timeout, a failure of that item.
- A panic, or a call of
runtime.Goexit, fails its item even once the context has ended.
Costs#
- An event log or a display does not tell from the class of an item, or of
Map’s step, whether a pool was interrupted or ran out of time; the error text in the event log,context canceledorcontext deadline exceeded, does, and a program asks its context, withctx.Errorcontext.Cause, to tell the two apart, such as to exit with another code. Batchesclasses its step and its wait otherwise than its batches andMap’s step: under a deadline its step ends failed, as a timeout, while the batches it left out end canceled.- The outcome of an item cut short keeps the error its work returned, which may be a timeout of its own, while its target ends canceled.
- An item whose work fails for a reason of its own just as the context ends is counted as cut short, not as failed.
12. The program’s identity lives on the Bus#
Status: accepted, refined by 43
Context#
Decision 6 made the program’s name, its rule for the class of an error and
the error a step ends with options of each pool. The name, the panic log
and the class rule were so given separately to the Bus, the event log, the
terminal and every call of fanout.Map. A library that calls Map knows
none of them: a panic in its work named no program and wrote its stack
straight to standard error, over a live display, and Map told an item
canceled by its own Classify while the Bus classed the item’s target by
the program’s rule, so that the step and its targets could disagree.
Map worked out the error its step ended with and dropped it, and
Summarize was told of the items that failed in parallel slices and a
bool named interrupted, which a deadline sets as well (decision 11).
Decision#
- The program’s name, its class rule and its panic log are options of the
Bus, which
Bus.Program,Bus.PanicLogandBus.Classifyhand on. A pool takes them from the Bus its context carries; its own options,MapOptions.Program,PanicLogandClassify, override the Bus’s, and without either it falls back to “the program”, standard error andClassTarget. - The rule that tells an item canceled, for the step’s summary, is the one that classes the item’s target: the pool’s override, or else the Bus’s.
- The options stay, as overrides: a program whose exit code follows a
pool’s result sets
Classify, so that the result does not depend on whether a Bus is there, as it is not with no display. Recovered(log, program, target, v)stays the explicit form, for code that has a log and no Bus; a library with a Bus calls it with the Bus’sPanicLogandProgram.Batchesrecovers no panic and has none of these options.Mapreturns the error its step ended with.SummarizeandFailuretake aSummary, a struct that can grow, and decision 11’sinterruptedis itsCanceled.
Costs#
- Without its own
Classify, what a pool’s step andSummary.Canceledsay follows the Bus’s rule when there is a Bus, andClassTargetotherwise; a program that needs one answer either way sets the option. - The program’s identity is still given in more than one place for the
parts that do not take it from a Bus:
Recovered, and the terminal a display draws on.
13. The skip error lives in progress#
Status: accepted
Context#
fanout.Skip made the error of an item left out on purpose, and
fanout.IsSkipped told it from a failure. Only Map knew the error, though:
Span.End ended a span with it failed, so code that ended its own spans had
to set the error and call Span.Skip as well, and a program’s rule for
classes could turn it into a failure. Commands passed the error on beyond
Map too, in results of their own. Batches ended a batch whose run
returned it failed, stopped the run, and ended the batches after it as not
tried, “an earlier batch failed”, although nothing had failed.
IsSkipped(err) bool is the style of os.IsNotExist, which Go has moved
away from for errors.Is and a sentinel.
Decision#
progressholds the error:ErrSkipped, andSkip(reason), an error whose text is the reason and in whicherrors.IsfindsErrSkipped.Span.Endends a span whose error isErrSkipped, aserrors.Istells, skipped, with no class and the error’s text, beforeClassifyor the Bus’s fallback is asked, so that no program’s rule turns a skip into a failure.Span.Skip(reason)isEnd(Skip(reason)).fanout.Skipandfanout.IsSkippedare gone, without aliases; the pools testerrors.Is(err, progress.ErrSkipped).- A batch whose run returns a skip ends skipped and does not stop
Batches, which ends its step ok when nothing else failed. fanout.ErrNotTriedstays a distinct error, no skip: a batch not tried ends skipped on a display, but the error says that a failure left it out, and a command that counts failures byerrors.Is(err, progress.ErrSkipped)still counts it.
Costs#
Span.Endbehaves otherwise for an error that isErrSkipped, which an error opts into; one that wrapsErrSkippedby accident ends skipped. So does one that joins a skip with a failure, sinceerrors.Islooks through every error joined: aBatchesrun that joins its nodes' errors returns a skip only when every node was skipped, which the doc comments ofErrSkipped,Skip,Span.End,MapandBatchessay.- An item whose skip is wrapped,
fmt.Errorf("exe01: %w", progress.Skip(r)), ends with the whole text as its Err, not the reason alone as before. Bus.Classifyandprogress.Classifyknow nothing of skips; onlyEndtells them apart, so code that classes an error itself askserrors.Isfirst.- Programs that called
fanout.Skiporfanout.IsSkippedchange, each call mechanically.
14. The escape deny-list may grow in a minor release#
Status: accepted, refined by 42
Context#
termtext escapes by a deny-list: the runes that can move the cursor,
change the state of the terminal, or change the order or the lines text is
shown in. Terminals keep adding sequences, and a character the list leaves
out today may turn out to act on one tomorrow. The text the displays draw is
an interface consumers compare in their tests, and so are the escapes in
it. If the set of runes escaped were frozen with that text, adding a rune to
the list, a fix for a terminal’s safety, would be a breaking change, and
would wait for a major release or be weighed against one.
Decision#
- The forms of the escapes are stable:
\r, and\nand\twhereEscapeescapes them;\xand two digits for any other C0 control, DEL and a byte that is not UTF-8;\uand four digits for any other rune; hexadecimal digits in lower case. Changing a form is a breaking change. - The set of runes escaped may grow in a minor release, which names the runes it adds in its release notes. Removing a rune from it is a breaking change.
- Escaping stays idempotent: an escape is printable ASCII, which the deny-list never names.
- The package comment of
termtextsays this, and the list of interfaces beyond the Go API inAGENTS.mddoes not name the escapes.
Costs#
- Text that held a rune added to the list is shown, and stored in the event log, escaped by a later release where an earlier one wrote it as it was; a consumer’s test that compares such text changes with the release.
- An event log written before the change and one written after can hold the same text in two forms.
15. A new or stricter Check rule is a breaking change#
Status: accepted
Context#
progresstest.Check holds the events of a Bus to the promises the
progress package makes to every sink: the order of spans, the work
announced up front, the limits, the suspends and the bounds of the text.
The kit tests itself with it, and programs call it, directly or through
Watch, in their own tests, which pass or fail by its rules. Nothing said
whether a rule may be added or tightened in a minor release, and a rule
added without notice would fail a program’s tests on an upgrade although
the program had not changed.
Decision#
- Each rule
Checkenforces is a promise the progress package makes to every sink, and the rules are an interface beyond the Go API, next to the span kinds, the event log and the text the displays draw. - A new rule, or a stricter one, is a breaking change, named in the release notes.
- A rule that catches what an existing promise already forbade, such as a case the rule missed, is a fix, released as any other.
- No option selects which rules run: a test that cannot keep a rule breaks a promise a display relies on.
Costs#
- A promise the progress package adds waits for a breaking release before
Checkholds anyone’s tests to it. - Telling a fix from a stricter rule is a judgement, made in review by whether the promise was already written down.
16. A large set of targets is folded again once a second#
Status: accepted
Context#
The tree folds the targets of a step that have ended into node sets, one row for those that ended well, one for each way they failed, and one each for those interrupted and those left out. A frame folded a set again whenever it had changed since the frame before, which a step whose targets end one after another does between any two frames, ten times a second. Folding costs O(n log n) in the names, and the tree draws a frame under its lock, which the Bus waits for while it hands the tree an event, and every worker that reports one waits for the Bus. A frame over a step whose targets were ending cost 2.1 ms at 10,000 targets, 3.5 ms at 16,000 and 7.1 ms at 30,000, against 50 µs at 300. The row of the step counts the targets as they end from a tally, which costs nothing to read.
Decision#
- A set of more than 256 names is folded again at most once a second: a frame drawn within the second draws its row as it was last folded. A smaller set is folded again for every frame it changed for.
- The row of the step counts every target that has ended, and a row that says how many failed rows are left out counts the sets as they are.
- The line a step leaves when it ends, with its failures below it, names every target.
- The 256 names and the second are constants, not options, and
Tree’s doc comment states them.
Costs#
- A row of more than 256 names can leave out the targets that ended in the last second, while the count of the step’s row includes them.
- The text the tree draws changes: a consumer’s test that draws frames less than a second apart over more than 256 targets sees the fold of the earlier frame. Decision 5 makes that a breaking change, named in the release notes.
17. The cost tests run in CI, alone#
Status: accepted
Context#
TestTheCostOfALargeStep holds what the live tree costs on a large step
to the number of its targets, by timing the same work at two sizes. Two of
the regressions it catches, a pass over a step’s targets as each ends and
a search through the rows of failures, allocate nothing, so no count of
allocations could stand in for the clock. The race detector slows some
code more than other code, so the test skips under it, and CI ran every
test under the race detector: nothing in CI would have noticed the tree’s
work growing quadratic in its targets again, only make floor on a
contributor’s machine. A shared runner’s timings vary, though, and every
failed check here is root-caused, so a check that fails by chance costs
time and trust.
Decision#
- CI runs the test in a job of its own, Costs, as
make costsdoes: without the race detector, with the current Go line, alone on its runner so that no other package’s tests compete for the CPU while it measures. It runs on Linux alone: the costs are the tree’s, and macOS would say nothing more about them. - Each part logs how many times as long its larger size took, the two times and its limit, so that every run’s log shows how much room it has.
- A part over its limit is a regression in the tree until shown otherwise. If the parts run close to their limits on changes that leave the tree alone, the gap between the two sizes is widened, which sets work linear and quadratic in the targets further apart. A limit is not raised to pass, and the job is not re-run until green.
Costs#
- Every CI run takes one more runner, for about half a minute.
- A check that times code on a shared machine can still fail by chance. Its log then says which part, by how much, and how long each size took.
18. The live clocks tick in tenths of a second#
Status: accepted
Context#
The counter’s time and the tree’s command row read whole seconds, 0:41,
and so did how long each running target, step, call and wait had run, 4s,
so that a row changed once a second rather than at every frame. A display
draws up to ten frames a second, and the terminal writes nothing for a
frame that reads as the one before. A display that changes once a second
looks stalled between changes: a request that has run for a while cannot be
told at a glance from a display that has stopped drawing.
Decision#
- The Counter’s time and the Tree’s command row read minutes, seconds and
tenths,
0:41.3, or hours, minutes, seconds and tenths,1:02:03.4. - How long a running target, step, call or wait has run reads seconds and
tenths,
4.2s, or minutes, seconds and tenths,3m12.4s, and hours and minutes from an hour on,1h02m, as before; against the bound of the request it waits for,3m12.4s/10m. - The tenths are cut, not rounded, so that a clock never runs ahead.
- The tree lists running targets by the tenth of a second each started in, counted from when the tree was made, those that started in the same tenth in the order they were queued. A row’s place depends on when its target started, not on the time of the frame, so that two rows never swap places from one frame to the next, though the tenths two such rows show may differ by one. The tenths are read on the monotonic clock, as the times on the rows are, where the times have one, so that a step of the wall clock does not reorder the rows.
- What is no live clock stays as it was: the time in front of a plain
line,
[0:03], which a log keeps; how long a span that has ended took, in the lines the tree leaves, the end lines ofPlainand the summary; a pause’s countdown,12s left, rounded up to the whole second; and the bound of a request.
Costs#
- While anything runs, every frame differs from the one before, so the terminal is written up to ten times a second rather than about once: a region of twelve rows a hundred columns wide is about 12 KB a second, which a slow link carries but notices.
- The text the displays draw changes: a consumer’s test that compares
frames sees
0:41.0and4.0swhere it saw0:41and4s. Decision 5 makes that a breaking change, named in the release notes.
19. The displays draw in themes the program picks#
Status: accepted, refined by 31, 36 and 46
Context#
The displays drew in no colour, with one set of marks, or in ASCII outside
a UTF-8 locale. People who watch them all day asked for colour, and for
marks, spinners and bars that tell a failure, a run and the share done at
a glance. A terminal draws colour through sequences (SGR, ESC [ … m) that
termtext counts as columns and progresstest.Screen showed as text, so a
row drawn in colour was cut short or wrapped. Which colours read well
depends on the terminal: how many it shows, its background, and whether
its user set NO_COLOR, none of which the kit reads (it reads no
environment). Glyphs have their own traps: a character of ambiguous East
Asian Width is drawn two columns wide in a CJK locale, some are drawn as
emoji pictures, and fonts lack some, DejaVu Sans Mono all of braille.
Decision#
display.Themeis a comparable value whose zero value is no theme, which draws byte for byte what the displays drew. There are five themes,Classic,Aurora,Ember,NeonandTide;Themes,ParseThemeand the text methods serve a flag such as--theme.Theme.Indraws a theme in the 256 colours it is designed in, the zeroColours, in the terminal’s own 16, or in none, its art alone, for NO_COLOR. The program decides from its flags, NO_COLOR, TERM, COLORTERM and whether standard error is a terminal; the kit reads none of them.TreeOptions,CounterOptionsandPlainOptionstake aTheme, andSummaryaThemeandASCII.ASCIIstays the switch for the character set: with a theme it keeps the theme’s colours and draws ASCII marks, a bar of[###...], no spinner and no guide.- The tree and the counter draw a theme’s art: its marks and separators, a spinner on each running target and in front of the counter’s line, a bar on each counted step that gives way when the counts would not fit, and a guide at the innermost indent. Plain lines and the summary take its colours and one mark in front, and keep their words, punctuation and paths, so that what a log holds does not change.
- Colour goes on marks, count words, the clock, bars, titles and separators. Names, errors, requests and output lines stay in the terminal’s own colour, and colour is never the only signal: every status has a mark and a word of its own.
- Every glyph of a theme takes one column, is of East Asian Width neutral or narrow, never ambiguous, and is no emoji, nor a rune Unicode keeps for emoji to come. No theme sets a background, reverse video, italic or blink. The palettes of 256 colours keep to a middle lightness, an L* of 45 to 60, which reads on light and dark backgrounds, though some of their colours are as vivid as any; and the cells of a bar that stand for the targets that failed, which only their colour sets apart, differ from the rest of the bar by a ΔE of 20 or more as readers with deuteranopia or protanopia see them too. Those of 16 use red, green, yellow, cyan and magenta, bold and faint, never bold with a colour and never the bright colours, which some palettes make grey. Tests hold every theme to all of this.
- Every painted piece sets its colour back, so that no row and no line ends
in a colour; the Terminal cuts a row by the columns it shows, and starts a
frame drawn in colour by setting colours back.
progresstest.Screenapplies colours when itsStylesis set, andStyledshows them.
Costs#
- A theme in 256 colours on a terminal that shows 16 draws other colours,
or none; the program steps it down with
In(Colours16)from what TERM says. - Aurora’s braille is drawn from a fallback font where the terminal’s font lacks it, as DejaVu Sans Mono does; FreeMono and DejaVu Sans have it.
- A theme’s colours cost bytes: over a run of 48 targets the tree wrote 1.5 to 1.9 times as many in a theme of 256 colours as in none, and the counter’s line, which is short, up to 3 times.
- The API grows by
Theme,Colours, five variables a program could assign to and should not, asio.EOF, and two fields ofSummary. - The look of no theme keeps
·,…and–, which are of ambiguous width: changing them would change every consumer’s frames.
20. A theme’s look may change in a minor release#
Status: accepted, refined by 46
Context#
Decision 5 makes a change to the text consumers compare in their tests a breaking change. A theme is art: its glyphs and colours will be tuned as people use it, on terminals, fonts and palettes the first release did not try, and holding them to the rule for text would freeze them.
Decision#
- What the displays draw with the zero Theme stays the text consumers compare: changing it is a breaking change, as decision 5 has it.
- A theme’s colours, marks, separators, spinner, bar and guides may change in a minor release, which names the change in its release notes. Its name, and the words and numbers a display draws in it, may not: removing or renaming a theme is a breaking change, and adding one is not.
- A consumer that tests a display drawn in a theme checks the words and
numbers it draws, such as a count or a target’s name in what
progresstest.Screenshows, rather than comparing its frames byte for byte, or pins the release. The kit exports nothing that takes a theme’s art out of a frame, since the art is what may change.
Costs#
- A consumer’s test that compares the frames of a theme byte for byte can break in a minor release.
21. The command line’s glue for progress is the kit’s#
Status: accepted, refined by 31
Context#
clusterctl v0.4.0 (internal/cli/progress.go, internal/fileutil) and sind
(cmd/sind/progress.go and appendprivate.go, ported from clusterctl’s)
carry the same glue between a command line and the progress packages, about
560 lines in clusterctl and 760 in sind: the five words of --progress and
the rule that picks a display from them, with their notes and errors, word
for word; the reading of the locale, byte for byte; the making of the
Terminal, the display, the summary and the event log, and their teardown, in
one order; and the opening of the event log for its user alone. The copies
had drifted already: sind refuses another user’s symbolic link in a
directory on the way to the log, which clusterctl follows, and writes a log
on the display’s own terminal through the Terminal’s Lines, where clusterctl
writes it into the region. They differ in how they find out about the
terminal, go-isatty and golang.org/x/sys in sind, asked for each command,
golang.org/x/term in clusterctl, asked once, and in policy: which commands
run in a span, when TRACEPARENT leaves the environment, where notes go,
whether standard output goes through the Terminal under plain lines, and
whether a command that succeeded leaves a summary.
The kit imports no cobra (decision 3) and reads no environment, NO_COLOR
and TERM included (decision 19); which display to draw, and whether the
terminal can show one, was the program’s to decide (architecture.md).
Decision#
progress/cliprogressholds the words (Mode,Modes), the rule (Choose), a command’sRun(Start,Run.Finish, and the writers in between), the opening of the event log, and the probesIsPipe,TerminalSizeandInForeground(decision 22).- It finds out nothing for itself. The program passes in
Optionsthe name and value of its flag and of its variable for each setting, aSettingeach; whether standard error is a terminal, and a dumb one; whether standard output goes into a pipe; the terminal’s size and foreground; whether the locale shows UTF-8, whichUTF8Localetells from a getenv the program passes; and the Theme. The kit reads no variable, NO_COLOR, TERM and COLORTERM included, and decides no colour: decision 19 stands. - It sets nothing process-wide and ends no process.
Options.Traceis called only once a Bus is made, so that a program takes TRACEPARENT and TRACESTATE out of its environment there; the end of the command’s context is the interrupt. forbidigo refusesos.Getenv,os.LookupEnv,os.Environ,os.Setenv,os.Unsetenv,os.Clearenv,os.ExpandEnvandos.Exit, their namesakes insyscallandgolang.org/x/sys/unix,log.Fatal,log.Fatalfandlog.Fatalln, alog.Logger’s too, and the handlers ofos/signalin the kit’s code, its tests aside. - It imports no package of flags. What the flag asks for and cannot have is an error, which the program reports as its usage error; what the variable asks for fails no command, and is said in a note where it names no word or no log that can be used. The package exports no error.
- Notes go to
Options.Notes, escaped, behind “prog: " asTerminalOptions.Programwrites it. The summary goes toOptions.Stderrunescaped, since a Theme colours it and the Bus sanitised its names. Without a display or a log nothing is made, so standard error holds what it would without the kit. - Where the programs differed in what they share, the kit takes sind’s way: a symbolic link on the way to the log, a directory’s as well as the file’s, has to be this user’s or root’s, and a log on the display’s own file goes through the Terminal’s Lines.
- Beyond both, it refuses a log’s file with other names, hard links, which another user may have made to a file of this user’s where the system lets them, as macOS and the BSDs do; it follows no link put at the log’s own name after its walk of the path, whatever links lead to the directory; and it opens the file without blocking, so that a named pipe another user put there holds up no command, while one of this user’s or root’s is waited on as before.
- What stays in the program: finding out whether a stream is a terminal,
reading its environment, the theme and its colours, taking variables
out of the environment, signals and exit codes, which commands run in a
span and the span itself, which of its writers go through the Terminal
and putting them back, and whether a command that succeeded leaves the
summary, the argument of
Run.Finish. - Interfaces beyond the Go API: the five words, in their order, and the
texts of the errors
ChooseandStartreturn and of the notesStartandFinishwrite, the refusals of the log’s file among them, around the names the program gives and the system’s errors they wrap. Programs quote them in their manuals. Changing one is a breaking change; adding a word is not, though the list the texts give grows.
Costs#
- Each program keeps the lines that fill in
Options, and the library it asks whether a stream is a terminal. A program that words its notes otherwise, or picks a display by another rule, writes its own glue ondisplay, as before. Optionshas 19 fields, most of them passed on todisplayandprogress, for a program to leave out;Options.ManualandRun.Draware there for tests, as the displays’Drawis.- clusterctl’s walk of the links on the way to its log becomes stricter,
and its log on the display’s terminal moves above the display. On
cliprogress, both programs refuse a log’s file with other names, which they appended to. - A word or a text of cliprogress is frozen with the API: rewording one waits for a breaking release.
22. The kit may require golang.org/x/sys, in cliprogress alone#
Status: accepted, refined by 26 and 31
Context#
cliprogress (decision 21) asks the system about a command’s streams: how
many columns and rows the terminal has (TIOCGWINSZ); whether the process
is the job in its foreground (TIOCGPGRP against its own process group);
and, for an event log a path such as /dev/stderr or /dev/fd/3 names,
whether that descriptor of the process’s is open for writing (F_GETFL) and
a duplicate of it closed on exec (F_DUPFD_CLOEXEC). sind and clusterctl
make these calls through golang.org/x/sys/unix, which decision 3 lets the
kit require only with a record.
The standard library makes them only as raw system calls by number.
Measured with Go 1.26 and 1.27: on macOS syscall.Syscall traps into the
kernel itself rather than through libc, which Apple does not support; on
OpenBSD 7.5 and later it reaches ioctl alone, through libc, and returns
ENOSYS for every other number, fcntl’s among them, so a descriptor open
for reading alone could not be told from one open for writing; Solaris,
illumos and AIX define no SYS_IOCTL; and syscall has no Winsize, so the
kit would declare one and pass its address through unsafe. A write of
no bytes, which needs no fcntl, sends an empty datagram on a Unix socket
of SOCK_DGRAM or SOCK_SEQPACKET, and POSIX leaves what it does to anything
but a regular file unspecified. golang.org/x/sys/unix makes every one of
these calls on every Unix port, through libc where the system asks for it.
Its IoctlGetInt reads TIOCGPGRP’s four bytes into an int of eight, which
on a big-endian system of 64 bits puts them in the upper half; the kit
reads both halves.
golang.org/x/sys requires no module, and the go line of v0.48.0, and of
v0.49.0, the newest, is 1.26.0, the kit’s floor. sind and clusterctl
v0.4.0 require v0.48.0. A program that builds no package of cliprogress
links none of it, but minimal version selection reads the kit’s
requirement all the same. The calls cost 4,096 bytes on linux/amd64 and
128 on darwin/arm64 over the same calls through syscall, and sind on
cliprogress is 12,288 bytes larger than with its own glue.
Decision#
- The kit may require golang.org/x/sys. Only
progress/cliprogressimports it, in its files built for Unix,probe_unix.goandappendprivate.go; its files for other systems answer that a terminal’s size is not known and the process is in the foreground, and open the log as any file. depguard refuses golang.org/x/sys in every other package. - The kit requires v0.48.0, which sind and clusterctl required when this
was recorded, so that the first release of
cliprogressraises neither; Dependabot then moves it as it moves golang.org/x/text, and with it the golang.org/x/sys of the programs that take a release of the kit. - golang.org/x/term is not required: the program asks whether a stream is a terminal, with the library it has.
- Tests use the standard library still: a pseudo-terminal is opened
through
syscall. make vet-othercompiles the module for Windows and Plan 9 in CI, since no test runs their files.
Costs#
- Every program that requires the kit has golang.org/x/sys v0.48.0 or later
in its module graph, whether it builds
cliprogressor not, and a release of the kit after Dependabot has moved the requirement raises the program’s to it, as golang.org/x/text’s is raised. - These are the kit’s first files with build tags outside its tests. Coverage runs on Linux, so the files for other systems are compiled and never run.
- On AIX, which has no F_DUPFD_CLOEXEC, the duplicate of a descriptor is inherited by a child started while the log is open.
- On Windows and Plan 9 a display takes the terminal to be 80 columns wide, and the log’s file is not checked for its owner, its mode or its links.
23. A span reports its work, sampled, and a sink rolls it up#
Status: accepted, refined by 44
Context#
The displays said how many targets of a step were done, and nothing of how far one piece of work had got: a copy, an upload or a walk of many files looked the same at its first second and its last. A pool of targets that each copy something showed its share done only as targets ended.
Decision#
- A span reports its work: an amount, in a unit of a closed set,
Items,BytesorPercent, out of a size when one is known; the size of work inPercentis 100.Workgives the unit and size;Advance,SetAmount,CountWriterandCountReadermove the amount. - The Bus sends the amount as a
TypeAdvanceat most every 100 ms a span, by its clock, the newest never lost, and with the span’sEnd. - An event carries only what its span said.
progress.Meterrolls the work up the tree: amounts add within a unit; a span’s share done comes from its own size, else from the targets it counts once one of them is bounded, short of done until their count is complete, else from the sizes of the work below, for any span, where a span with a size of its own counts for everything below it, and it is unbounded without any of them. - The displays draw a span’s work after its counts, or after its name;
what they draw for a span without work does not change. Bytes are drawn
in IEC units, and work in
Percentas its share alone. A row of the tree or the counter’s line too wide for the terminal gives up the time left, the rate, the amount and the bar in turn. - The log gains
advance,amount,size,unitandleftOutunder version 1. It writes an advance of a span at most once a second by default,LogOptions.AdvanceEvery, and counts those it leaves out, which leave the only gaps inseq. Check’s new rules apply only to the new event and fields, which no source before this one sends, and so are no breaking change in the sense of decision 15.
Costs#
- A span with work keeps an atomic counter and a timer; an
Advancereads the clock,BusOptions.Now, outside the Bus’s lock, so the clock must be safe for concurrent use: one that was safe only under the lock, such as a test clock moved by hand without a mutex, no longer is. - A pool of copies adds up to one log line a second a target running, and
the log shows the amount a span reached at its next line, up to a second
late, or at its
end. - A reader of the log that checks
seqfor gaps must allow those of the advances left out, whichleftOutcounts. - Rule 3 lets a share fall back when work below starts that was not known.
- The time left is an estimate, and reads wrong while the rate changes.
24. The strict command tree is the kit’s, in cobratree#
Status: accepted, refined by 29 and 39
Context#
sind (cmd/sind/root.go, exitcode.go and version.go, adapted from
clusterctl’s) and clusterctl v0.4.0 (internal/cli/root.go,
internal/exitcode and internal/version) carry the same plumbing around
cobra, about 190 lines in sind. Cobra is lenient where a script needs it
strict: a command that only holds subcommands prints its help and succeeds
for a word that names none of them, so a script that misspelt one, or
called one that was renamed, goes on to its next step; help prints the
root’s help and succeeds for a topic that names no command; and its
argument checks, such as cobra.ExactArgs, return plain errors that name
neither the arguments wanted nor the command, which a program exits with
as with any failure. Both programs add help and completion to the tree
before it runs, make such commands and help refuse those words, and mark
the errors of their argument checks as usage errors. Both also report
their version from what the Go toolchain recorded in the binary, taking
the module version only from a build without VCS stamps, go install …@v1.2.3, since the version the toolchain derives in a checkout, from a
tag or as a pseudo-version, names no release.
The copies had drifted:
- clusterctl suggests every close subcommand,
; did you mean a or b?, and sind the first,(did you mean "a"?). - Only sind appends the command’s usage line to the error of an argument
check, and names the command that replaced one it dropped,
(it is now "get cluster"). - clusterctl marks a usage error with its own package of exit codes, and a
flag cobra cannot parse with a
FlagErrorFunc. sind marks its own, and takes pflag’s typed errors for usage errors wherever it finds them, since cobra passes only the flags of the command that runs through itsFlagErrorFunc, not those of the parents it traverses withTraverseChildren, nor those of a command that parses its own. - clusterctl prints
devel, twelve digits of the revision, the commit’s date, the Go version and the platform on one line; sind printsdev, seven digits and-dirty, and sind-action reads theversionfield ofsind version --json.
Decision 3 allowed cobra only in a package about cobra, cobratree, and
recorded no requirement for it.
Decision#
cobratreeholdsStrict(root, moved), which makes a built tree strict;Usage,UsagefandIsUsage; andBuildInfo, whichReadBuildInforeturns.- The kit may require
github.com/spf13/cobraandgithub.com/spf13/pflag, which onlycobratreeimports; depguard refuses both in every other package, andcliprogressis still given what its flag holds (decision 21). It requires cobra v1.10.2, which sind and clusterctl v0.4.0 require, and pflag v1.0.9, the version cobra requires, which clusterctl has, so that the first release raises neither program’s; sind requires v1.0.10. Dependabot then moves them, as it moves golang.org/x/sys (decision 22).github.com/inconshreveable/mousetrap, which cobra imports on Windows, comes in as an indirect requirement. - Where the programs differed, the kit’s one wording is sind’s: the first
suggestion,
(did you mean "get"?); the usage line after the error of an argument check,(usage: prog power on NODES), for a command that holds no subcommands; and a moved command’s new place,(it is now "get cluster"), where no subcommand is close. sind takes the package with no change to what it prints; clusterctl’s suggestions and arity errors change when it takes it. - A moved command is data:
movedmaps a command line the program no longer takes, its words after the program’s name, to the one that replaced it. - A usage error is a mark, not an exit code: the kit names no number, and
a program maps
IsUsageto its own, 2 in both. The error type is not exported. pflag’s four typed errors, an unknown flag, a missing value, a value it does not take and a syntax it does not know, count as usage errors wherevererrors.Asfinds them, so that noFlagErrorFuncis needed. - The build info is data and no words: the module version of a build from
the proxy, the full revision, the commit’s time and whether the checkout
had changes. Which word a build that is no release gets, how many digits
of the revision are printed, and the Go version and platform, which
runtimegives, are the program’s, so sind keepsdevand seven digits and clusterctldeveland twelve; so is a version injected with-ldflags, which the program prefers. It lives incobratree, beside the tree whose version command prints it, as both programs that read it have one. - Interfaces beyond the Go API: the texts of the errors that
Strict’s checks return, around the names of the commands in the tree. Programs pin them in their tests, and scripts may read them; changing one is a breaking change.
Costs#
- Every program that requires the kit has cobra and pflag in its module
graph, whether it builds
cobratreeor not, as with golang.org/x/sys (decision 22), and a release of the kit that follows cobra raises the program’s. Both programs require them already. Strictreplaces the argument check of every command that holds subcommands and runs nothing, and of help, so a check a program set there is lost, and it adds help and completion when it is called, so the completion scripts go to the output the root has then.- The texts are frozen with the API: rewording one waits for a breaking release, and clusterctl adopts sind’s.
- A program without cobra that wants the build info takes
cobratreeand cobra with it, until a later decision givesBuildInfoa package of its own, which would be a breaking change.
25. The local web server is the kit’s, in localweb#
Status: accepted, refined by 34 and 35
Note: localweb shipped in v0.8.0 (decision 34) and v0.9.0 (decision 35),
not in v0.9.0 and v0.10.0 as this decision planned, and before fsutil
and doctor, which came in v0.10.0.
Context#
sind, brig and sdash each serve a web page to the user who started the
program, on a loopback address or a Unix socket. Each wrote its own server
guard, and no one has all the defences: the Host check, the check of the
peer’s user, the launch token that becomes a session, the CSRF token, the
Origin and Sec-Fetch-* checks, the headers, the size cap and the
timeouts. brig still accepts Host: localhost:PORT and sends nothing when
an SSE stream opens, which sind fixed; sind has no Sec-Fetch-* check and
no subscriber queue that drops a slow reader, which brig has. The copies
drift further with every commit. A fix found in one program reaches the
others only if someone remembers it.
Decision#
- The kit takes the web side of these programs as
localweb, in this module, in sub-packages:localweb(the guard),localweb/session,localweb/sse,localweb/launch,localweb/assets,localweb/page,localweb/htmxandlocalweb/localwebtest. They arrive in the releases v0.9.0 and v0.10.0, each in a stack of pull requests onmain. - The packages mirror the cobra side (decision 24):
net/httpandhtml/templateare imported bylocalweband its sub-packages alone. depguard refuses them intermtext,progress,fanoutandfsutil, which library packages import too, andlocalwebimports no cobra and noprogress/cliprogress. - A seed is the strictest variant, with the fixes of the others added
before its first release. The kit reads no environment (decision 21): the
program passes the runtime directory, the program name, the storage-key
prefix and the
GOOSas parameters. - The cookie names, header names, the token format and the texts of the errors a client can read are interfaces of the kit, as the texts in decision 24 are. Each package that freezes one adds its own record before its release.
- What stays in the program: the pages and fragments, the actions, the dialogs, the templates, the CSS, fonts and brand, and the read-only rules. They name the program’s domain.
- A UI convention that the code cannot hold, such as the key table, is
written in
doc/ui-convention.md.
Costs#
- Every release of the module that touches
localwebis a release of every web program’s guard. A fix reaches them when they take it, and a mistake reaches them too. - The module’s
go.sumdoes not grow:localwebtakes the standard library andgolang.org/x/sys(decision 26) alone. The kit’s size in the graph of a program that builds nolocalwebpackage does not change. - The 100% statement coverage of every file (AGENTS.md) holds for the Unix-only peer check only on Linux; the files for macOS are compiled and not run, as the files for other systems of decision 22.
- A second module for the web side was weighed and left: it would double the release work, and the release flow and the coverage gate are here.
26. golang.org/x/sys also in fsutil and localweb#
Status: accepted, refined by 35 and 37. Refines 22.
Note: no localweb package asks for LOCAL_PEERCRED: the peer check
reads /proc on Linux and fails closed elsewhere (decision 34), so the
localweb root takes syscall alone. The flock is in localweb/launch
(decision 35), which alone of the localweb packages imports
golang.org/x/sys, and fsutil exists since decision 37.
Context#
fsutil takes a file lock for the doctor’s and the launcher’s single
instance, and localweb asks the system which user is on the other end of
a connection. On Linux the standard syscall package is enough for both.
On macOS the peer of a Unix socket is read with LOCAL_PEERCRED, which
syscall does not name, and syscall.Syscall there traps into the kernel
without libc, which Apple does not support (decision 22). Without
golang.org/x/sys the tools would not work on macOS, or would fail closed.
Decision#
fsutilandlocalwebmay importgolang.org/x/sys, in their files built for Unix, with files for other systems that say the call is not supported and fail closed.progress/cliprogresskeeps its right (decision 22).- depguard allows
golang.org/x/sysin these three packages and refuses it in every other package. The version is the one decision 22 sets; no program’s requirement rises because of this decision. gofrs/flockandgolang.org/x/termare not required.
Costs#
- Every program that requires the kit already has
golang.org/x/sysin its module graph (decision 22). This decision adds no module. - The files for macOS and for other Unix systems are compiled in CI and not run, as the files of decision 22.
27. Progress over MCP is built now, in mcpprogress#
Status: accepted, refined by 32, 33 and 44
Context#
clusterctl sends progress over MCP from a Bus: a progress.Sink on a
Tally that reports a monotonic (progress, total, message) at most every
500 ms. It is about 200 lines without the SDK and has 434 lines of tests.
Only three plain fields touch the SDK. The first proposal was to wait until
a second program needed it. sind sends no progress over MCP today, because
a tool call runs the program again with progress off, but its tools are to
run in process, and clusterctl’s model makes that possible.
Decision#
- The kit builds
progress/mcpprogressnow, from clusterctl’s notifier. It takes aSend func(progress, total float64, message string) error, so it imports no MCP SDK (decision 3). - Every program with an MCP server adopts it: clusterctl and sind. brig and sdash have none.
- The rate, 500 ms, and the rule that
progressnever goes down are interfaces of the kit.
Costs#
- The package has one user at first, and sind’s adoption depends on its tools reporting in process. Until then the move removes one copy, not two.
- A change of the MCP progress notification in the specification reaches the kit’s package, not only one program.
28. Code of the programs may move into the kit, under the kit’s licence#
Status: accepted. Refines 1.
Context#
The seeds of fsutil, localweb and the other packages of the plan come
from clusterctl, sind, brig and sdash. clusterctl and sind (LGPL-3.0-or-later)
and sdash (Apache-2.0) are GSI’s, so decision 1 covers them. brig’s files
carry SPDX-FileCopyrightText: Dennis Klein, and decision 1 says GSI holds
the kit’s copyright.
Decision#
- All four repositories hold code written by Dennis Klein together with Claude. Code from any of them may be copied, mixed and relicensed under Apache-2.0 with GSI as the holder, as decision 1 sets for the kit.
- A file that moves into the kit takes the two SPDX lines of the kit (AGENTS.md). The commit that moves it says which program it came from. The program’s own copy is deleted in the program’s adoption commit, not kept under a second licence.
- Code written by anyone else, and a dependency’s code, does not move under this decision. It needs its own record.
Costs#
- brig’s moved code leaves the copyright line
Dennis Kleinbehind in brig’s history only. The kit’s files do not name him. - The statement is a record of the maintainer’s decision. It is not legal advice, and it does not cover a contribution that someone else made to one of the repositories.
29. cobratree words flag values, carries exit statuses and shows help topics#
Status: accepted, refined by 39. Refines 24.
Context#
Once cobratree held the strict tree, the programs still carried the
plumbing next to it, each its own way:
- sind words a flag’s value that pflag cannot parse, and one it refuses
itself, in one form,
invalid --output value "yaml": want human or json, through aFlagErrorFunc; clusterctl sets aFlagErrorFuncof its own, and brig and sdash print pflag’s text, which quotes Go’s parser:invalid argument "x" for "-c, --count" flag: strconv.ParseInt: parsing "x": invalid syntax. - clusterctl’s
internal/exitcodegives an error the exit status the code nearer its cause knows, such as 3 for a host it could not reach, and keeps one that is there; sdash copies it, cut down, and sind carries the status of a child that ended. - sind wraps the help command after
Strictso thatsind help realmshows a concept of its manual; brig hides subcommands for the same end. - sind and clusterctl prefer a version and commit injected with -ldflags
to the build info, and cut the commit, sind to 7 digits with
-dirty, clusterctl to 12.
Decision#
cobratreeaddsInvalidValue(name, value, want), the usage errorinvalid --NAME value "VALUE": want WANT, andFlagError, aFlagErrorFunca program sets itself, which words pflag’s errors for a value of a bool, a duration, a count or a number of any size so, and keeps every other error as it is. It is not part ofStrict, whose texts decision 24 froze.Coded(status, err)gives an error an exit status, andStatus(err, def)returns it; an error that carries one keeps it. The kit still names no number: decision 24’s rule holds, andIsUsageis asked apart.HelpTopics(root, topic)lets help show a topic that is one word and names no command; a command of that name wins, and the rest is refused asStrictrefuses it. The kit names no topic, not even a list of them: the program answers a word of its choice with one.Resolve(version, commit)puts the values a release build set with -ldflags in place of the toolchain’s, leaving out the time and the changes when the commit set is another, andBuildInfo.Short(n)cuts the commit, with-dirty. The word for a build that is no release stays the program’s.- Interfaces beyond the Go API: the texts
InvalidValueandFlagErrorreturn, the words each of pflag’s types wants among them. Changing one is a breaking change.
Costs#
- clusterctl, brig and sdash change the text of a flag value refused when
they set
FlagError; scripts that matched pflag’s text see sind’s. - A value a number of 64 bits cannot hold reads as one that is no whole number, “want a whole number”, where pflag said it is out of range.
Codedkeeps the first status given, so a caller cannot raise it on the way up; a program that needs another rule, such as clusterctl’sWorstover the errors of many hosts, keeps it.
30. A command’s result is written by cliout, as a table or as JSON, escaped#
Status: accepted, refined by 42
Context#
clusterctl, sind, brig and sdash each write a command’s result as a table
for a person and as JSON for a program, each its own way. clusterctl’s
writer aligns the columns by the columns a terminal gives their text,
escapes every cell before it measures it, so that a reason a node gave
cannot start a row that seems to be another node’s, hides the wide
columns unless -o wide asks for them, pads no last column, and writes a
slice of JSON an element at a time. sind and brig align with
text/tabwriter, which counts bytes, three and two spaces apart, and
escape nothing in a cell; sdash has --json alone. sind writes JSON
without HTML escapes; the others write < as \u003c. No program
escapes the C1 and bidirectional controls that encoding/json writes as
they are.
Decision#
- A package
clioutholdsTable, withColumnand itsWideandRight, andWriteJSON. It importstermtextalone, and library packages may import it. - The seed is clusterctl’s writer: every cell and heading escaped with
termtext.Escapeand the caption withtermtext.EscapeLinesbefore they are measured, bytermtext.Width; two spaces between columns; no line ending in a space; the caption after an empty line; no cell cut. WriteJSONindents by two spaces, writes<,>and&as they are, as sind does, since the output goes to a terminal or a program and never into HTML, and writes every runetermtextescapes andencoding/jsondoes not as a JSON escape,\u009b, so that the strings decode to what they were and cannot act on the terminal. A slice is written an element at a time, in the bytes of the whole.- The package parses no flag. The words of
-o, which of them is the default,tableorhuman, and YAML or jq, which only clusterctl writes and the latter of which needs gojq, stay the program’s. - Interfaces beyond the Go API: the text
Table.Writewrites and the bytesWriteJSONwrites for a value. Changing either is a breaking change, save that the runes escaped followtermtext’s deny-list, which may grow in a minor release (decision 14).
Costs#
- sind and brig change the spacing of their tables, three or two spaces to two, and where a cell holds a wide character; scripts that cut their columns by position see it.
- clusterctl, brig and sdash change the JSON of a string that holds
<,>or&, and every program that of one holding a C1 or bidirectional control: a decoder sees no change, a comparison of the bytes does. - A table is built whole in memory before it is written, as clusterctl’s was, since its widths need every row.
31. cliprogress keeps a default log, picks colours and asks questions#
Status: accepted
Context#
Beside the glue decision 21 moved into cliprogress, each program kept
more of the same. sind and brig keep an event log unasked, in their state
directory, which a view of what they did reads. brig moves it aside to
its name with “.1” after it once it is larger than 8 MiB, under a lock
so that one of the commands that start at once moves it, and sind’s view
reads that copy too. Both pass the log to cliprogress as though a
variable had named it, and sind drops the notes written about it by
matching their text, since the user did not ask for the log. brig turns
NO_COLOR, TERM and COLORTERM into a theme’s colours by a rule that
clusterctl and sind each word their own way. clusterctl and brig ask
before they act, each with a reader of its own that suspends the
displays while it waits, and each asks whether standard input is a
terminal with a library of its own, which decision 22 left to the
program.
Decision#
Options.DefaultLognames the log a program keeps unasked. It is used when the flag is not given and the variable names no file, and it fails no command: one that cannot be opened is left out, and one that stops short says nothing, unlike a log the flag or the variable names.Options.RotateAtmoves the default log, and only it, to its name with “.1” after it beforeStartappends, once it is a regular file larger than that many bytes. Where the system has flock, a lock on its name with “.lock” after it lets one of the commands that start at once move it; the others append to the file they open. A log the user named is never moved. The names “.1” and “.lock” are interfaces.ThemeForturns a theme into the colours a terminal can show, from a getenv the program passes: no colour off a terminal or on TERM=dumb, the art alone for NO_COLOR set to anything, 256 colours for COLORTERM or a TERM that names 256 colours, and the terminal’s 16 otherwise. The kit still reads no environment; the program may use its own rule.IsTerminaltells whether a file is a terminal, through golang.org/x/sys/unix, whichcliprogressrequires already; it is false on systems that are not Unix. This reverses the part of decision 22 that left the question to the program.Askwrites a prompt and reads a line, a byte at a time, so that what follows is left for the next reader, with the Bus’s displays suspended until it returns; the end of the context ends the question at once. An answer longer than 4,096 bytes is refused, with the error “the answer is longer than 4096 bytes”.Yesreads “y” and “yes”, in any case. Whether to ask where standard input is no terminal, and what no answer means, stay the program’s.- Interfaces beyond the Go API: the rule of
ThemeFor, the names the rotation uses, the error of a long answer and the wordsYestakes.
Costs#
- sind’s notes about a default log that cannot be opened go away, which was the point; a user who wants to know names the log with the flag.
- Commands that start at once on a log over the limit may each write to a different file for that run; none of their lines is lost.
Askleaves a goroutine reading standard input once its context ends, since a read cannot be stopped; the next read of that input races it.
32. mcpprogress sends a line that counts targets, and stops on the first failure#
Status: accepted, refined by 44
Context#
Decision 27 moved clusterctl’s notifier into the kit and made its rate and its rule that progress only grows interfaces. It left open what the package’s API is, what the line of a notification says, and what a notification that cannot be sent does. clusterctl’s notifier took the SDK’s notification as its argument, ignored the error of a send, and wrote the stack of a panic under its own name.
Decision#
Notifieris aprogress.Sinkmade byNew(Options), which starts the goroutine it sends from;Close, called after the Bus’s, stops it and sends what is left.Options.Sendtakes the progress, the total and the line as plain values, so the package imports no MCP SDK; the server puts the progress token in what it sends.- The progress and the total count targets, as decision 27 and the package comment say. The line is clusterctl’s: the root that started last, “name: 3/16 done”, with “batch i/n” first where it runs in batches and “1 failed”, “1 canceled” and “1 skipped” after, and “; 6/12 in all” where the numbers count more than that root.
- A
Sendthat returns an error stops the Notifier, since the session that cannot take one notification takes none after it. ASendthat panics stops it too, with “prog: progress notifications stopped:” and the stack inOptions.PanicLog, standard error when nil, as a Bus does for a sink. - Interfaces beyond the Go API:
Interval, the rule that progress only grows, and the words of the line.
Costs#
- A send that fails once, for a reason that would pass, ends the call’s progress; the call and its result are not touched.
- The line counts in English words a client may show as they are; a change of them is a breaking change.
33. The kit reads its event log back, in eventlog#
Status: accepted, refined by 45
Context#
The kit writes the event log, version 1, and holds the writer to a golden log. sind and brig each read it back with a parser of their own, for the view of their runs that their local web UI draws: sind about 600 lines, which follow a log as cliprogress rotates it, bound the spans of a run and tell which runs changed, and brig 311 lines, an older fork of them. A third reader was due: sind’s MCP tools run sind again as a child, in a process group of its own, so the server has no Bus for the child’s work, and to send the child’s progress with mcpprogress (decision 27) it must read the child’s log back into events. A reader beside the writer lets the two be tested against each other.
Decision#
- A package
progress/eventlogreads the log. It importsprogressalone, and nothing of the kit imports it. Decodeturns a line into aLine: the first line of a run, with its trace context, program and version, or one of its events as aprogress.Event. It leaves out the keys and the values it does not know, as the format asks of a reader, refuses a line it cannot read, and sanitises every text as a Bus does. The texts of its errors are no interface.Tailreads a log as it grows, aPollat a time, whole lines into aFeed, and follows the rotation of decision 31: a log moved to its “.1” copy is read to its end there, without a reset, and a log cut short or made again resets the Feed and is read again. It starts no goroutine; the program polls it on its own time.Forwardis the Feed of a server that runs a command as a child with the child’s log in a file of its own: it gives each event to aprogress.Sink, such as anmcpprogress.Notifier, once, by its run’sseq.Reduceris the Feed of a view: it folds the lines intoRunvalues, each the tree of the spans of its command, with typed values, not the log’s strings.Options.Keep, 100 by default, andOptions.MaxSpans, 131072 by default, bound what it holds.- The loop that polls, its timing, the rule that takes a run that has not ended for a killed one, and the questions a page asks of a run, such as counting its targets or grouping its failures, stay in the programs: each has one user.
- Seeds: sind’s
internal/ui/activity, with brig’s handling of a log moved to its copy.
Costs#
- A reader that follows the log polls it; a view that wants to be told of a change at once still polls, as sind and brig do today.
- The defaults of
OptionsandMaxLineare API: changing them is a breaking change. - A run whose Bus ran several commands one after the other, as the golden log’s does, shows the later commands below the first.
34. localweb guards a local web server, keeps its sessions and streams its pages#
Status: accepted, refined by 35 and 40. Refines 25.
Context#
Decision 25 took the web side of sind, brig and sdash into the kit, in two
releases after fsutil and doctor. Those two are not built yet, and the
three servers still drift: brig answers to Host: localhost, which a
browser may resolve to ::1, where another user can hold the port; sind
took brig’s Sec-Fetch-Site check and its queue of pending topics only
now, as fixes of its own; sdash binds both loopback addresses and Unix
sockets with full timeouts, which neither of the others has. The guard,
the session and the stream are what keeps another user and another site
out, so they come first.
Decision#
- The release after v0.7.x brings
localweb,localweb/session,localweb/sseandlocalweb/localwebtest.localweb/launch,localweb/assets,localweb/pageandlocalweb/htmxfollow in a later release, and so dofsutilanddoctor, in stacks of their own. localwebholds the listener and the checks, each a middleware of the typefunc(http.Handler) http.Handler:Listenbinds a loopback address and nothing else: a literal, or the name localhost as 127.0.0.1 and::1on one port, or a Unix socket that nobody but the user can reach or replace (sdash’s rules: the socket is bound under a temporary name, given mode 0600 and renamed into place, in a directory of the user’s below directories only the user or the system may change).CheckAddrrefuses anything else before it is bound.ListenOptions.AnyPortbinds a free port when the one asked for is taken; without it a port in use is an error.Bound.Answersnames the Host header valuesHostslets in: each bound address with its port, and localhost only while the server holds the port on both loopback addresses; on a socket, the loopback names under any port, the near end of a forward.Hostsrefuses the rest with 421 Misdirected Request.CrossSiteandSameOriginrefuse what a page of another site caused, by the strictest rule of the three servers:Sec-Fetch-Siteis same-origin or absent, or the request is a navigation of the whole page, a GET or HEAD whoseSec-Fetch-Modeis navigate and whoseSec-Fetch-Destis document, which onlyOrigin.Navigatelets in, and which lets in “none” too.Originmust be the request’s own on every request that may change something, and on a GET or HEAD that carries it.Origin.Othersnames more origins, for a development server.PeerListenercloses, on Linux, a TCP connection that a process of another user opened, from/proc/net/tcpandtcp6, trusting only an established socket with an inode. On other systems it returns an error that matcheserrors.ErrUnsupported, and the program decides.HeaderssetsContent-Security-Policy(ContentSecurityPolicyunless the program gives its own),Referrer-Policy: same-origin,X-Content-Type-Options: nosniff,X-Frame-Options: DENYand the opener and resource policiessame-origin.NoStoresetsCache-Control: no-storeapart, so that files with a hash in their name can be cached.Serveserves with every timeout set (10 s for the headers, 30 s for the request, a minute for the answer, two idle, 64 KiB of headers), closes the connections without a request at once when it stops, and gives the rest five seconds.- A refusal is a
Refusal: a status, the message a client reads and a reason for the log. ARefuserlets the program write it in its own form, add the line that says where the address is, and log the reason; the kit has no logger.
localweb/sessionexchanges the launch token, in the query parametertokenof a GET or HEAD, for a session cookie and redirects, 303, to the address without it. The cookie holds a random session ID, never the token, is HttpOnly and SameSite=Strict, and is named by the program’s prefix and the port of the request’s Host. Each session has a CSRF token, which a request that may change something sends in the program’s header or in the form fieldcsrf; its body is capped at 64 KiB and its form parsed. Tokens are 32 random bytes, URL-safe base64.localweb/sseholdsNotifier[T]andSubscription[T]: a subscriber keeps the set of topics that changed since it last took them, so a slow one loses no topic and renders no more than changed. The names areChanged(), the channel that wakes the subscriber, andTake(), which returns the topics in the order of their first change and forgets them: brig’s names, which say that taking clears, with sind’s ordered slice.Servesubscribes, sends every event first, then gathers changes for 30 ms, renders what depends on them and sends each event whose data differs from what it last sent under that name. It sendsretry: 2000and a keep-alive comment every 25 s, no event IDs, a carriage return as , and lifts the write deadlineServeoflocalwebsets.localweb/localwebtestholdsLogin, a client that signs in with the token and sends requests as the server’s own pages do, andReader, which reads the events of a stream.- These are interfaces of the kit, as decision 25 asks: the messages of
the refusals (“This server answers to its loopback address only.”, “The
request came from another site.”, “This link holds an old or wrong
token.”, “This browser is not signed in.”, “The request lacks this
page’s CSRF token. Reload the page.”, “The form is larger than 64 KiB.”,
“The form is malformed.”) and their statuses; the headers
Headerssets and the default policy; the parametertoken, the cookie’s name, its attributes and the fieldcsrf; the framesServeofssewrites, asFormatwrites them. The texts of the errorsListenandPeerListenerreturn are no interface. - The UI convention changes:
Referrer-Policyissame-origin, notno-referrer. Underno-referrera browser sends a form’s POST, and the POST of htmx, withOrigin: null, whichSameOriginrefuses; a request to another site still carries no referrer. - What stays in the program: the names of its cookie, header and the line its refusals add; the log; which fragments a page streams, how it reads their parameters, and the timings it tunes; the token printed and the browser opened; its own development origins.
Costs#
- Each program changes on adoption, where its rule was looser: brig no
longer answers to localhost on one address; sind and brig refuse a
request with
Sec-Fetch-Site: noneunless it is a navigation of a page, and checkOriginon a GET that carries it; sdash refuses a request to its API that the user typed into the address bar, which is no page. - sdash keeps its cookie model, whose value is the token, unless it takes
session: its pages would then send the CSRF token. - The peer check reads
/proc, so it fails closed on macOS; a program there serves on a Unix socket, or on a machine it has alone. localwebis the kit’s first package to importnet/http; depguard keepsnet/httpandhtml/templateout of every other package.
35. localweb starts a server once, opens the browser on it, and serves its files and pages#
Status: accepted, refined by 41. Refines 25 and 34.
Context#
Decision 34 left localweb/launch, localweb/assets, localweb/page and
localweb/htmx for a later release. sind and brig still carry the same
guard of one server per user: a lock in $XDG_RUNTIME_DIR/PROGRAM, a
record of the server’s address and process, and a check that the user
owns the socket that listens there, which keeps the token from another
user’s server on a port the recorded one gave up. sdash opens the browser
in the safest way of the three: through a private file, in a session of
its own, and not at all under SSH. sind and brig serve their static files
under a hash of all of them, and parse their templates in two ways: sind
into one set with a function include, brig into a base set cloned for
each page. Both vendor htmx 2.0.11 and its SSE extension 2.2.4, sind
idiomorph 0.7.4 too, byte for byte the same, with no checksum and no REUSE
annotation. sdash serves a React page and has no Go templates. fsutil,
on which decision 25 meant the launcher to build, is not built.
Decision#
localweb/launchholds theOpenerand theInstance:Opener.OpenwritesDIR/open-PORT.html, readable by the user alone, under a temporary name renamed into place, which sends the browser on to the address, and hands that file, never the address, toxdg-openon Linux oropenon macOS.DIRmust be a directory of the user’s of mode 0700. The page holds no script and no style.Opener.Startnil isDetached, which starts the opener in a session of its own and waits for it in the background.Opener.Skipsays why no browser can open: no opener for the system,SSH_CONNECTIONset, or on Linux neitherDISPLAYnorWAYLAND_DISPLAY. The program passesGOOSandGetenv.Acquire(runtimeDir, program)makesruntimeDir/programfor the user alone, refuses one that is a link, another user’s or not of mode 0700, and waits for the flock(2) ofui.lockin it (decision 26).Runningreadsui.json, which must be a regular file of the user’s that no other user can read or write, of at most 4096 bytes, with the keysurlandpid; the process must live, the URL be http on a loopback IP address and a port, something must take a connection there, andlocalweb.ListenerOwnermust name the user.Publishwrites the record under a temporary name and drops the lock;Retireremoves the record, under the lock, when it is this process’s. The namesui.lockandui.jsonand the two keys are sind’s and brig’s, so that a program that adopts the kit finds a server an older version of it started.launchchecks a private directory itself untilfsutilexists. On a system that is not Unix, and on AIX, which has no flock,Acquirereturns an error that matcheserrors.ErrUnsupported.
localweb.ListenerOwnerreturns the user of the socket that listens at an address, on Linux from/proc/net/tcpandtcp6, trusting only a socket a process holds. Elsewhere it returnserrors.ErrUnsupported, soRunningfinds no server there, and a second start starts another.localweb/assetsserves the files of anfs.FSatPREFIX/VERSION/NAME.VERSIONis the first twelve hex digits of the SHA-256 of every file’s name, length and content. A file is sent withCache-Control: private, max-age=31536000, immutableand a Content-Type from a fixed list, never the host’s; an address of another version, a name that is no regular file, and an address outside the prefix get 404, and a method other than GET and HEAD 405.Options.Devserves under the versiondev, reads each file anew and sendsCache-Control: no-cache.localweb/pageparses a program’s templates into one set, in which every page and fragment is a block of its own name, and addsinclude, which renders a block whose name is a value. One set, not a clone per page, so that a fragment renders the same in its page, in an answer to htmx and in an event of the stream.Respondrenders the whole block before it writes, and on a failure writes nothing and returns the error;Options.Reloadparses anew for each render.Redirectanswers htmx with 204 andHX-Redirect, and a plain form with 303;RetargetsetsHX-RetargetandHX-Reswap;IsHTMXreadsHX-Request: true.localweb/htmxembeds htmx 2.0.11,htmx-ext-sse2.2.4 and idiomorph 0.7.4 with its htmx extension, the files their projects publish on npm, under the Zero-Clause BSD licence, whichREUSE.tomlandLICENSES/0BSD.txtrecord. A test holds each file to its SHA-256. This is the record decision 28 asks for code that is neither the kit’s nor a program’s.Configis the htmx configuration the pages of sind and brig set: noeval, no script swapped in, no style of htmx’s own, requests to the page’s own origin only, and no history cache. A new version of a script comes in a minor release that names it.- The default
Content-Security-Policykeepsmanifest-src 'self': it lets a page load a manifest from its own origin alone, so a program without a manifest takes the default as it is. - These are interfaces of the kit: the names
ui.lock,ui.jsonandopen-PORT.html, the keys of the record, the form of an address ofassets, its Cache-Control and Content-Types, the statuses and headers ofRedirectandRetarget,htmx.Config, and the version of each script. The texts of the errors and ofSkipare no interface. - What stays in the program: reading
$XDG_RUNTIME_DIRand choosing a directory without it; printing the address and choosing to open it; removing the redirect file when the server stops; the prefix and the files it serves; its templates, the names of their blocks and the pages of its errors; and its flags for development.
Costs#
- brig renames the blocks of its page templates into one set on adoption, and every program’s assets move to a new address once, so a browser loads them again.
- On macOS a second start of a program starts another server, as the owner of a listener cannot be told there.
- A program that wants a newer htmx waits for a release of the kit.
launchcarries a check of a private directory thatfsutilwill take over.
36. The shapes and names of the API are settled before v1#
Status: accepted. Refines 19.
Context#
A review of the API at v0.9.0 read every exported name of the kit, about
470 in 21 packages, and found the same concept in two or more shapes
across the packages, which are meant to be used together: enum constants
with a type prefix and without one, options named as nouns and one named
WithFlags, Options in a package with several configurable types, a
constructor that returns an interface, Check* that fails a test in one
function and returns an error in the next, a negative value that means
“no limit”, a panic or a limit of zero, blocking calls without a context,
a nil *Bus safe for three of its methods, settings that can change after
construction, and Colours beside Canceled. Each fix renames or
reshapes API, and v0 allows that in a minor release; after v1 it costs a
major version.
This decision sets one rule for each of those concepts. The findings behind them are the prefixes of enum constants (PR-05, XP-08, NM-02), options (PR-06, NM-09), options structs (EL-03, NM-10), constructors (TF-05, EL-09), test helpers (CT-09), zero and negative values (TF-02, LW-04, LW-13), contexts (LW-09, XP-02, TF-08, TF-10), nil receivers and hooks (PR-02, TF-07), state after construction (XP-10, XP-12) and spelling (NM-11). Decisions 39 to 46 apply the rules package by package, and settle those findings in code.
Decision#
These rules hold for the API of every package, and a new API keeps them:
- Every constant of an enum type takes its type as a prefix:
UnitBytes,StreamStdout,FlagHidden,BoundSize,StatusOK. - An option is a bare noun, such as
progress.Hostorprogress.Flagged.With*names only a helper that returns a context, such asprogress.WithBus. A later name that would collide takes another noun, never aWith. - A package with more than one configurable type names each options
struct after its type:
XxxOptions. A package with one may keepOptions. NewXreturns a concrete pointer,*X, never an interface, so that a method can be added after v1.- A function named
Check*takes atesting.TBand fails the test through it. A function that returns an error takes another verb, such asValidate*. - Each package states one rule for zero and negative values per family of fields, in its package doc or on the options type. Zero is the default. An option never panics because it is negative: it is refused with an error at construction, or it means off, as the package states.
- Every call that can block takes a
context.Context, and returns the context’s error, wrapped, when the context ends first. - Every method of a type that
progress.BusFromcan return as nil is safe on a nil receiver. A hook that a type cannot work without is checked when the type is made, not on first use. - Options are fixed when a value is made. The kit keeps no package state that a caller can change, such as an exported variable that other functions read.
- Identifiers use American spelling, as Go and its standard library do:
Colors, notColours. Comments and documentation stay in British English. This refines decision 19, which namedColours. - A rename keeps the old name for one minor release, as a deprecated
alias: a type alias, a constant or variable, a wrapper function or
method, or, for a field of an options struct, a deprecated field that
the code still reads while the new field is zero. Its
Deprecated:paragraph ends with(decision 36)..golangci.ymlhides staticcheck’s SA1019 for these paragraphs alone, so that the kit’s own callers in other packages keep compiling and linting during that release, while a deprecation of the standard library or of a requirement is still reported. - A change that cannot keep an alias, such as a new signature or a field of another type, is a breaking change, named in the release notes and in the decision that makes it.
Costs#
- Every program that uses the kit changes names, mostly mechanically, over v0.10.0 and v0.11.0. The aliases let it do so in its own time within that window.
- v0.11.0 moves the kit’s own callers to the new names, deletes every
alias marked
(decision 36)and the SA1019 exclusion. A program still on an old name then fails to compile. - For one release, two names for one thing are on pkg.go.dev. The deprecation notice says which one to use.
37. fsutil writes, locks and checks the files of a program#
Status: accepted, refined by 47. Refines 26.
Context#
clusterctl’s internal/fileutil, sind’s atomicfile and brig’s
WriteFileAtomic each write a file through a temporary file and a rename,
and clusterctl’s also takes a lock beside a shared file and refuses
configuration that another user could have written. They differ in the
fixes each has: clusterctl keeps the owner, mode and group of a file that
several administrators share and syncs the directory; sind removes the
temporary file on every error path; brig follows a symbolic link at the
path. Decision 25 planned fsutil before the web side, and decision 26
gave it golang.org/x/sys, but the web side came first, and the review
of v0.9.0 found decision 26 and depguard naming a package that did not
exist (XP-05). This decision makes it.
Decision#
- A package
fsutilholds, from clusterctl’sfileutilwithout its cache, with the fixes of sind and brig added:WriteAtomic(path, data, perm)writes a temporary file in the directory ofpath, syncs it, closes it, renames it overpathand syncs the directory. A file that exists keeps its mode and group, and its owner where this user can keep it; write permission for others is never kept. A new file gets exactlyperm, set withChmodafter it is made, whatever the umask. A symbolic link atpathis written through, not replaced, for at most 40 links. The temporary file is removed on every error path.Lock(ctx, path, wait)takes an exclusiveflockof the sibling lock file.NAME.lock, clusterctl’s name, and returns the function that lets it go, which may be called more than once. The lock file is made as clusterctl makes it: readable by the group that can write the locked file, so that every administrator of a shared file can take its lock.Lockpolls until it holds the lock, untilctxends, when it returns the context’s error wrapped, or untilwaitpasses, when it returns an error that matchescontext.DeadlineExceededand names the process that holds the lock, on Linux from/proc/locks. Awaitof zero or less waits untilctxends.DefaultLockWaitis 30 seconds, clusterctl’s.Update(ctx, path, perm, change)reads the file underLock, withDefaultLockWait, a missing file as nil data, and writes whatchangereturns withWriteAtomic. Whenchangereturns an error, nothing is written.EnsureDir(dir)makes a directory of mode 0700 and refuses one that is a symbolic link, is not a directory, is another user’s, or that its group or others can write. It does not repair a mode that is too wide: the error says what to run, as clusterctl’s does.CheckTrusted(path)refuses a file or directory, or any of its ancestors, that a user other than this one or root could have written: one another user owns, one its group or others can write, or an ancestor its group or others can write without the sticky bit. Every refusal matchesErrUntrusted.
- On Unix the lock is
flock(2)fromsyscallorgolang.org/x/sys(decision 26). On a system that is not Unix,LockandEnsureDirreturn an error that matcheserrors.ErrUnsupported. fsutilreads no environment and keeps no state a caller can change.- These are interfaces of the kit: the name and the mode of the lock
file, so that a program that adopts
fsutiland an older build of it that has its own copy exclude each other, andErrUntrusted. The texts of the errors are not. - The files move under decision 28; each program deletes its own copy in its adoption commit.
Costs#
localweb/launchandcliprogresskeep their own lock and their own check of a private file until a later release moves them ontofsutil.- A lock of
flock(2)does not hold across machines on every NFS server; a program with shared files on NFS keeps the rule it has.
38. doctor reports a program’s checks in fixed words and text#
Status: accepted
Context#
This decision and decision 37 bring the two packages that decision 25 planned before the web side. Neither settles a finding of the review of v0.9.0.
sind and clusterctl each have a doctor command, and brig has its
preflight: a list of checks of the machine, each ok, a warning or a
failure, written for a person and, in clusterctl, as JSON. sind writes a
check mark and a cross with the detail, and a remediation after a failed
check; clusterctl writes name, status and detail as JSON and a
caption of the counts; brig has an enum of statuses and a hint. The UI
convention gives a doctor page the go-to key g d. The checks differ by
program; the way their result is written does not have to.
Decision#
- A package
doctorholdsStatus, withStatusOK,StatusWarningandStatusFailed, whose values are the wordsok,warningandfailed;Check, withName,Status,DetailandRemediation;Worst,CountandWrite. It importstermtextalone. Worstorders failed above warning above ok, and returns ok for no checks. A status that is none of the three counts as failed inWorstandCount, so a mistake fails closed.Writewrites one line for each check,MARK NAMEorMARK NAME: DETAIL, with the mark✓for ok,!for a warning and✗for a failure. After the lines it writes each distinct remediation once, in the order it first appears, set off by blank lines. Every name, detail and remediation is escaped withtermtext. It returns the first write error.- These are interfaces of the kit, which a script or a test of a program
may read: the three words, the JSON keys
name,status,detailandremediation(the last two left out when empty), and the textWritewrites, which a test ofdoctorholds byte for byte. - What stays in the program: the checks, their names and texts, the command and its flags, and the exit status of a failed check (decision 21). The kit has no exit code.
Costs#
- sind shows
!for a warning where it showed a cross, and writes its remediations after all the lines; clusterctl gainsremediation. - A new mark or word is a breaking change.
39. cobratree marks every error of the command line, and Strict takes options#
Status: accepted. Refines 24 and 29.
Context#
The review of v0.9.0 found that IsUsage misses cobra’s errors for a
required flag and for flag groups (CT-01); a runnable root with
subcommands stays as lenient as cobra and ignores moved (CT-02);
Strict and HelpTopics work only in one order, nothing tells when they
are called in the other, and Strict(root, moved) cannot take a new
setting (CT-04); the topics cannot be listed, so completion does not offer
them (CT-10); Strict makes a group runnable, which adds a line to its
usage (CT-05); the merge of Resolve cannot be tested from outside, and
its name does not say build info (CT-07, NM-08); Coded accepts the
status 0 (CT-08); and CheckCommandLine returns an error while
CheckCommandLines fails a test (CT-09). HelpTopics on a tree without
subcommands takes the root over (CT-03), and CheckCommandLine refuses
arguments that a runnable group takes (CT-06): two bugs, fixed in the same
release, that take no decision.
Decision#
Strictmarks the errors of a required flag and of a flag group as usage errors: the check it installs calls cobra’sValidateRequiredFlagsandValidateFlagGroupsand wraps their errors withUsage, on every runnable command, one withoutArgsincluded. A program that exits with 2 for a usage error now does so for these too.- A runnable root with subcommands and no
Argsis strict as a group is: a word that names no command is refused, with the hint ofmoved. Strict(root, opts...)takes options:Moved(map[string]string), andTopics(lookup, names), which shows help topics after the strict checks and addsnames()to the completion ofhelp. The old signatureStrict(root, moved)breaks, as no alias can keep it; a callStrict(root, nil)becomesStrict(root).HelpTopicsstays for one release as a deprecated wrapper (decision 36), and panics when it andStrictare called in the order that would lose the topics.- A group stays runnable, so that it can refuse a word that names no
command, and its usage keeps the extra line. The doc of
Strictsays so. Codedwith a status of zero or less returns the error unchanged, so thatStatusgives the default.(BuildInfo).Resolve(version, commit)is the merge, callable on aBuildInfoa test builds. The functionResolvestays for one release as a deprecated wrapper.BuildInfostays incobratree; decision 29 stands, and a package of its own is not made for now.cobratreetest.CheckCommandLinebecomesValidateCommandLine; the old name stays for one release as a deprecated wrapper.- The texts of decisions 24 and 29 do not change; the errors of a required flag and of a flag group are cobra’s texts.
Costs#
- A program whose tests expect its general exit status for a missing required flag changes them.
- Every call of
Strictwith a second argument changes.
40. localweb takes one form for each hook, and stops its streams with the server#
Status: accepted. Refines 34.
Context#
The review of v0.9.0 found that the Refuser reaches the checks in three
ways, an argument of Hosts, a field of Origin and a field of
session.Options, and that CrossSite takes it and ignores it (LW-06,
XP-03); Origin is a policy and Options.Cookie a prefix (LW-07,
NM-06); Sessions nearly repeats its package (NM-05); session.New
takes a cookie prefix or a header that net/http will not send (LW-03);
a negative KeepAlive panics, a negative Retry is sent as it is, and a
negative MaxBody becomes a limit of zero (LW-04, LW-13); an open stream
holds a stopping server for its whole grace period unless the program
connects Done itself (LW-05); PeerListener closes every connection of
a listener that is not TCP (LW-10); Login promises a check it does not
make, and a test cannot read the CSRF token (LW-11); one frozen message is
written in two packages (LW-12); and Format writes a name with a line
break (LW-14).
Decision#
- A
Refuseris always a fieldRefuseof an options struct:Hosts(answers, HostsOptions),OriginPolicyandsession.Options.Hostspanics when it is made with a nilanswers.CrossSiteignoresRefuse, as its doc says. - Renames, each with a deprecated alias (decision 36):
localweb.OrigintoOriginPolicy;session.SessionstoManager; the fields ofsession.OptionsCookietoCookiePrefix,HeadertoCSRFHeaderandFieldtoCSRFField; andDefaultFieldtoDefaultCSRFField. session.Newrefuses a cookie prefix that does not make a valid cookie name with a port after it, and a CSRF header that is not a valid header name, and its error names the field.- One rule for the values of every
localweboption: zero is the default; a negative duration turns the thing off, asServeOptionsalready says:sse.Options.KeepAlivesends no keep-alive,Retrysends noretryfield, andCoalescegathers nothing. A negative size that caps what a client may send to the program,session.Options.MaxBody, is refused byNew, since no cap would let any page of the user’s fill the program’s memory. localweb.Serveputs its stop signal in the context of every request, andlocalweb.Stopping(ctx)returns it.sse.Serveends its stream on it whenOptions.Doneis nil, so a stream no longer holds a stopping server for its grace period.PeerListenerreturns an error for a listener whose address is not TCP, on every system.localweb.MisdirectedRefusal(host)holds the frozen refusal of a Host, whichHostsandsession.Middlewareboth use. Its text does not change.sse.Formatdrops a carriage return and a line feed from an event’s name, so that no name adds a field or an event to the stream.Servestill refuses such a name.localwebtest.Loginfollows the redirect of the sign-in and fails unless the page answers 2xx, and(*Client).CSRF(tb, path)reads the CSRF token from a page assessionwrites it.- The interfaces of decision 34 do not change.
Costs#
- Every program that sets a
RefuseronHostsorOriginchanges the call; the renames can wait one release. - A program that passed a negative
MaxBodynow fails to start.
41. launch waits under a context, page limits its includes, and htmx restores history as a page#
Status: accepted. Refines 35.
Context#
The review of v0.9.0 found that a template that includes itself crashes
the process, which recover cannot stop, since include starts the
depth limit of text/template again on each call (LW-01); htmx 2.0.11
sends HX-Request: true on a history restore, so with the history cache
off (htmx.Config) a handler that checks IsHTMX answers the back button
with a fragment (LW-02); assets.Assets repeats its package and the
version constants of htmx do not pair with its files (LW-08, XP-13,
NM-07); page.Options.Reload is the switch assets calls Dev (NM-04);
and launch.Acquire and Retire wait for a lock with no limit and no
context (LW-09, XP-02).
Decision#
includecounts how deep it is nested in one render and returns an error pastpage.MaxIncludeDepth, 100.IsHTMXis false for a request withHX-History-Restore-Request: true, andhtmx.Configadds"historyRestoreAsHxRequest": false, so htmx does not mark a history restore as its request at all.htmx.Configis an interface (decision 35), so this is a breaking change: a page that pins the Config in a test changes it.- Renames, each with a deprecated alias (decision 36):
assets.Assetstoassets.Set;htmx.VersiontoScriptVersionandhtmx.IdiomorphVersiontoMorphVersion, in pairs withScript,SSE/SSEVersionandMorph;page.Options.ReloadtoDev, read as before whileDevis false. launch.Acquire(ctx, runtimeDir, program)and(*Instance).Retire(ctx)poll the lock until they hold it orctxends, and then return the context’s error, wrapped. The signatures break; no alias can keep them.- The names, record, addresses, headers and script versions of decision 35 do not change.
Costs#
- Every program that calls
AcquireorRetirepasses a context. - A program with a test that pins
htmx.Configchanges it.
42. termtext keeps a CRLF line end, and its writer and limits take one shape#
Status: accepted. Refines 14 and 30.
Context#
The review of v0.9.0 found that EscapeLines and NewEscapeWriter show
a literal \r at the end of every line of output with CRLF line ends,
which ssh -t and a Windows host write, although a carriage return just
before a line feed cannot overwrite anything (TF-04); NewEscapeWriter
returns io.WriteCloser where every other constructor returns its type
(TF-05); Truncate and Wrap name the width differently and read zero in
opposite ways (TF-06); and cliout.Table.Add keeps the caller’s slice, so
a reused row buffer changes every row (PR-04).
Decision#
- The CRLF rule: in
EscapeLinesand the writer ofNewEscapeWriter, a carriage return immediately before a line feed is part of the line end and is written as it is,\r\n. A carriage return anywhere else is escaped as before, andEscape, for one line, escapes every carriage return. The writer holds a carriage return at the end of a write until the next byte says which it is. The escape policy in the package doc states the rule. - This takes a rune out of the escaped set in one place, which decision
14 makes a breaking change: output that showed
\rat its line ends shows none. NewEscapeWriterreturns*EscapeWriter; a caller that holds anio.WriteCloserstill compiles.Wrap(s, cols, ...)names its width asTruncatedoes. The package doc states both zero rules together:Truncatewith zero or fewer columns returns “”, andWrapwith zero or fewer never breaks a line.cliout.Table.Addcopies the cells it is given. Whatclioutwrites (decision 30) does not change.
Costs#
- A program whose tests expect
\rat line ends changes them.
43. fanout returns the error of Batches, names its limits, and commits to go-nodeset#
Status: accepted. Refines 3 and 12.
Context#
The review of v0.9.0 found that Batches drops the error that ended its
step, the gap decision 12 closed in Map (TF-01); BatchOptions.Limit
limits nothing, and the zero of each limit means something else (TF-02);
Failure reads as an error type but is a function, its error type is
unexported, Failed is an adjective and Item describes an item
(TF-03); MapOptions.Noun is a string where cliprogress and display
take a function (NM-03); and Keyed.Acquire returns no error after the
context ended when it is given no key (TF-10). It also found that
*nodeset.NodeSet is in three exported signatures of fanout, the only
type of a requirement in the kit’s API, while decision 3 allows
go-nodeset only to fold node names (XP-01).
Decision#
Batchesreturns([]Batch, error), the error that ended the step, asMapdoes. The signature breaks.BatchOptions.LimitbecomesReportLimit, with the old field read while the new one is zero. The package doc lists the zero rule of every limit:MapandEachtake 16 for less than 1,Keyed1, andReportLimit0 records nothing.FailedbecomesFailedItemandItembecomesItemInfo, each with a deprecated alias. The error of a step is the exported*FailureError, whicherrors.Asfinds, with itsSummary;Failurestays for one release as a deprecated constructor of it.MapOptions.Nounis afunc(n int) string, as incliprogressanddisplay; nil leaves the noun out. The type of the field changes.Keyed.Acquirechecks the context before it looks at any key.*nodeset.NodeSetinBatchOptions.Before,Batch.NodesandBatchesis a commitment of the API: go-nodeset v2 would make go-clikit v2. This refines decision 3, which allowed go-nodeset only to fold names. Taking[]string, or a small interface, and keeping go-nodeset inside was weighed and left: a batch of a power action is a node set in every program that runs one, and each would fold and unfold the names at the call.
Costs#
- Every caller that assigns the result of
Batches, or setsMapOptions.Noun, changes. - A major version of go-nodeset that changes
NodeSetwaits for a major version of the kit, or the kit moves to its own type then.
44. progress names each thing once, and a nil Bus is safe#
Status: accepted. Refines 23, 27 and 32.
Context#
The review of v0.9.0 found in progress, cliprogress and mcpprogress:
Span.Update ignores every option but the total, the message and the
work (PR-01); a nil *Bus panics in Trace, TraceContext and Close
(PR-02); a program cannot hand its current span to a child as a
traceparent (PR-03); constants without a type prefix (PR-05, XP-08,
NM-02); WithFlags among options that are nouns, and HTTP beside
HTTPStatus (PR-06, NM-09); Run naming a header, a verb, a command’s
lifecycle and a snapshot, and the run ID spelt three ways (PR-07, XP-09,
NM-01); Trace meaning a TraceID in some places and a TraceContext
in others (PR-08, XP-04); an unnamed note from Choose (PR-09); probes
that take different types (PR-10); a shared *int in an Event of plain
data (PR-11); CountWriter and CountReader, which hide the fast paths
of io.Copy and suggest the type Count (PR-12); Meter and Tally,
which read differently with no reason given (PR-13); defaults that only
some packages export (XP-11); and an mcpprogress.New that takes a nil
Send, a Send without a context, a Close that can wait for ever, and
options that repeat the Bus (TF-07, TF-08, TF-09).
Decision#
- Constants take their type’s prefix:
UnitItems,UnitBytes,UnitPercent;StreamStdout,StreamStderr;FlagHidden,FlagFold,FlagShowLines,FlagDryRun;BoundNone,BoundSize,BoundTargets,BoundChildren. Their words in the log do not change. - Options:
WithFlagsbecomesFlagged,HTTPbecomesHTTPRequest.Timeoutrecords a value and enforces nothing, andExitrecords a status; their docs say so. - One name for each thing: the header a sink is given is
RunInfo, with aVersion, and its sinkRunSink;Bus.TraceID()returns theTraceID;TraceContext.IDis the field that wasTraceContext.Trace;LogOptions.RunIDnames a run;cliprogress.RunbecomesSession. Each keeps a deprecated alias butTraceContext.ID, a field that no alias can keep. Fields.Exitis anintwithHasExit, so anEventis plain data;progress.Exit(code)sets both. The event log does not change.CountWriterandCountReaderbecomeAdvanceWriterandAdvanceReader, whose docs say that they hideio.ReaderFromandio.WriterTo.DefaultAdvanceEveryandDefaultFlushEveryare exported, and the field docs name them. The doc ofTallysays that its zero value is ready and why its accessors differ from those ofMeter.Span.Updateapplies every field option, asEnddoes; the total still never goes down, and the unit stays.- Every method of
*Busis safe on nil and returns a zero value or does nothing.(*Span).TraceContext()returns the trace context of a span, with the span as the parent, which a program hands a child it starts;NewTraceContextis for a program without a Bus. cliprogress.Choosenames its results,(mode, note, err), and says that the note has the program’s prefix and is escaped byStart. The four probes take one parameter type,any; a value that gives no file descriptor, such as a wrapper of a file, is never a terminal.mcpprogress:Newpanics whenSendis nil.Send func(ctx, progress, total, message)takes a context, which ends when the context given toClose(ctx)ends, so a stalled session holdsCloseno longer than its caller allows. TheNotifieris aRunSinkand takes the program’s name and panic log from the Bus when its options leave them empty, as decision 12 asks;mcpprogressneeds no exception from it. The rate, the rule and the line of decisions 27 and 32 do not change.- The JSONL event log, version 1, does not change: its keys stay, and the golden log stays byte for byte.
Costs#
- Most callers of the kit change some names; the aliases give them one
release.
TraceContext.Trace,Fields.Exit, theSendandCloseofmcpprogressand the probes break at once.
45. eventlog tells a newer log apart, and forwards one run#
Status: accepted. Refines 33.
Context#
The review of v0.9.0 found that a reader of version 1 shows nothing for a
log of a newer version and cannot say why (EL-01); Forward hands one
sink the events of every run in the log, with a Begin for each, against
the contracts of a sink (EL-02); Options configures only the Reducer,
and Keep does not say what it keeps (EL-03, NM-10); a view cannot look
a run up by its ID (EL-04); Feed.Reset asks to forget what Forward
does not forget (EL-05); the doc of Keep says what the code does not do
(EL-06); the set of dropped runs is cleared at once, so a dropped run can
come back (EL-07); a new field of progress.Fields could be hidden on
Span (EL-08); Forward returns an interface, and Line names a struct
and a method (EL-09); and the defaults are not exported (XP-11).
Decision#
ErrVersionand*VersionError, with the version it got, are the error of a line of a newer version;(*Reducer).Skipped()counts the lines the Reducer could not read.NewForwarder(sink, trace)returns a*Forwarderthat forwards the run of one trace, the one the program handed the child withSpan.TraceContext, and counts the lines of other runs it drops.Forwardstays for one release, deprecated, and forwards the first run that begins.- Renames, each with a deprecated alias:
OptionstoReducerOptions, itsKeeptoKeepRuns,Feed.LinetoTake, andLine.RuntoRunID. (*Reducer).Run(id)returns one run; the docs ofRunsandChangedsay why their orders differ.Feed.Resetis a notice that the log was cut short or made again, and each Feed says what it does on it.- The doc of
KeepRunssays what the code does: past it, the oldest run that has ended goes first, else the oldest. - The set of dropped runs is a ring that forgets the oldest ID first.
- The test that keeps a field of
Fieldsfrom being hidden coversSpantoo. DefaultKeepRuns, 100, andDefaultMaxSpans, 131072, are exported, each named after its field.
Costs#
- A server that forwards a child’s log passes the trace it gave the child.
46. display’s themes cannot be changed, and its names follow decision 36#
Status: accepted. Refines 19 and 20.
Context#
The review of v0.9.0 found that the five themes are exported variables
that Themes and ParseTheme read, so a library that assigns
display.Classic changes what --theme classic means in the whole
process (XP-10); Summary takes its settings as fields that can change
while it is on a Bus, where the other displays take options when they are
made (XP-12); and Colours, Colours256, Colours16 and NoColours
are spelt in British English, with the prefix last in NoColours (NM-11,
NM-02).
Decision#
ThemesandParseThemeread unexported copies of the themes. The exported variables stay, documented as copies; an assignment to one changes nothing else. Functions such asdisplay.Classic()are left for later, since callers use the variables as values.NewSummary(SummaryOptions{Theme, ASCII})makes aSummary, and the zeroSummarystays usable. The fieldsThemeandASCIIare deprecated (decision 36).ColoursbecomesColors, withColors256,Colors16andColorsNone, andTheme.Colours()becomesTheme.Colors(), each with a deprecated alias. What the themes draw does not change.
Costs#
- A program that changed a theme by assigning its variable loses that; none of the kit’s users does.
47. fsutil gives a file that exists exactly perm when asked#
Status: accepted. Refines 37.
Context#
WriteAtomic keeps the mode of a file that exists, so that a file several
administrators share stays writable by all of them (decision 37). brig
writes private files, such as its records of VMs and the keys of its
hosts, and wants each to have exactly the mode it asks for, even when
someone widened the mode since the last write. Its paths.WriteFile
therefore changes the mode of the file that exists before it calls
WriteAtomic: a second system call, outside the write, and a step every
program with private files would have to copy.
Decision#
WriteAtomicandUpdatetake options,...Option, after their arguments, ascobratree.Strictdoes (decisions 36 and 39).- The option
ExactMode()gives a file that exists exactlyperm, as a new file gets. The temporary file has that mode before the content is written, so that the new content is never readable with the wider mode. The group, and the owner when root writes, are kept as before. - Without the option, nothing changes: a file that exists keeps its mode without write permission for others.
Costs#
- A function value of the type
func(string, []byte, os.FileMode) errorno longer holdsWriteAtomic, and one of the type ofUpdateno longer holdsUpdate. A call compiles as before.
48. The kit has a documentation site, beside pkg.go.dev#
Status: accepted.
Context#
pkg.go.dev shows each package alone, its names in alphabetical order, and
the README and doc/README.md sent a reader there for everything but the
design. A reader who meets the kit learns there what each name does, but
not which of the 22 packages to import, how a span gets from a library to
a display, or what the tree looks like while it runs. doc/architecture.md
says the first two, for someone who changes the kit, in 400 lines and
without a picture. doc/README.md named a documentation site as
deliberately absent, since pkg.go.dev, the signed tags and AGENTS.md
said what it would. The maintainer asked for one that complements
pkg.go.dev, with a richer introduction, diagrams and motion where it
explains.
Decision#
site/holds a Hugo site, which.github/workflows/pages.ymlbuilds on each pull request and publishes to GitHub Pages frommain, at https://gsi-hpc.github.io/go-clikit/. Hugo is a tool, pinned inmise.tomlas golangci-lint is, and no requirement of the module (decision 3).- The site complements pkg.go.dev and copies no reference: a tour, concepts, guides, a page per package and a map of the packages, and every name links to its entry on pkg.go.dev.
- Each text has one source. The documents of
doc/are pages of the site as they are. The code a page shows is read from the package’sexample_test.gowhen the site is built, so it is code the tests run; the code of a guide that no package’s example shows, themainof a program, is an example ofinternal/docsite, which the tests compile. - The tests of
internal/docsitekeep the site true to the code: the recordings of the displays are frames the displays draw on a fake clock, golden files undersite/static/casts; the imports the map draws are thosego listreports, insite/data/imports.json; every package has a page and a place on the map; and every link to pkg.go.dev and every example a page names is a name the package has.go test ./internal/docsite -updatewrites the golden files anew. - Nothing in the tree names a version (decision 4): the workflow writes the list of releases and the version the site shows from the tags, as it builds.
- Motion is used where it explains: the recordings of the displays, which a reader can stop and play in each theme, and the steps of a diagram. With reduced motion asked for, neither moves, and each shows its last frame. Every page reads in full without its script.
Costs#
- A change of behaviour that a concept or guide page describes updates
that page in the same pull request, as it updates the documents of
doc/. - A new package needs a page and a place on the map, and a change to what a display draws needs the recordings written anew, or the tests fail.
- The site is published only once a maintainer sets GitHub Actions as the source of the repository’s Pages.
49. An update carries the newest amount, and the log writes the last advance before a pause#
Status: accepted.
Context#
sind reports the bytes of an image pull. Its parser learns a new size of
the download and a new amount from the same line of docker pull, and
sends the size with Update and the amount with SetAmount. The update
went out at once, with the amount the Bus had last sent in an advance, and
the advance came up to 100 ms later: a reader of the events saw the share
of the work go back, then forward, many times a pull (23 times in five
seconds in one pull of 113 MB). The event log then wrote the first advance
of a span in each second, or in each AdvanceEvery, and left the others out
without a trace: the last amount before a pause, such as the minutes a pull
spends unpacking after its last byte, reached the log only with the span’s
end. A live view of the log showed 98 % for the whole pause.
Decision#
Span.Updateof a running span takes the newest amount of its work, asEnddoes, and the update carries it. Work that learns an amount and a size together sets the amount first, then the size, and the update has both. An advance the Bus’s timer still had to send then finds nothing new.- The Log holds the newest advance of a span that it leaves out, and
writes it once
AdvanceEveryhas passed since the last it wrote. Any event of the span that comes first carries a newer amount, and the advance held is left out after all and counted inleftOut. Close writes the advances still held, of spans that never ended. - The line of an advance held may follow lines of other spans with a
greater
seq. A reader that needs the order of the Bus sorts byseq; the lines of one span stay in order.
Costs#
- A log has up to one more advance line per span and pause, and a timer per span while it holds one.
- A reader that took the order of the lines for the order of
seqreads one advance late; the eventlog reducer, which keeps each span’s newest fields, is not affected, since no event of the span comes between.