Skip to content

bb: defer a named library's init to the commands that import it - #178

Open
rminnich wants to merge 3 commits into
u-root:mainfrom
rminnich:deferlib
Open

rminnich wants to merge 3 commits into
u-root:mainfrom
rminnich:deferlib

Conversation

@rminnich

@rminnich rminnich commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Rebased onto main now that #173 has merged, so this is two commits and
nothing else:

22aeb67 Step 4. -lazy-init, the library rewrite, the hook codegen.
9ffd29e The gomodbb arm of the check, run offline. It sat behind -external, so it guarded nothing on a pull request.

Step 4 of #165. Step 3 (#173, merged) decides which packages could be
deferred; this one defers the ones you name and generates the code that runs
them.

Summary

-lazy-init <import-path>, off by default, repeatable. A named library is
rewritten the way a command already is, except that it keeps its package name
and import path and gains an exported hook:

 vendor/example.com/m/pkg/table/
   table.go
-    var V = build()          # runs at program start, for every command
-    func init() { ... }      # likewise
+    var V string             # hoisted
+    func busyboxInit1() { V = build() }
+    func busyboxInit0() { busyboxInit1() }
+    func busyboxInit2() { ... }   # the original init body
+  bbinit.go                  # generated
+    var busyboxInitDone bool
+    func BusyboxInit() {
+      if busyboxInitDone { return }
+      busyboxInitDone = true
+      bbdep0.BusyboxInit()   # this package's direct deferred imports
+      busyboxInit0()         # then its variables
+      busyboxInit2()         # then its init bodies
+    }

 vendor/example.com/m/cmd/sl/
+  bbinit.go                  # generated
+    func busyboxInitDeferred() { bbdep0.BusyboxInit() }
   main.go
     func registeredInit() {
+      busyboxInitDeferred()  # prepended: Go finished imports first too
       busyboxInit0()
       ...

Who decides what:

makebb -lazy-init P
  bb.BuildBusybox
    planInit                      -> lazyinit.New(cmds, DeferOnly([P]))
      refuses a path that does not move
    rewriteLibraries              -> names every hook, then writes them
      bbinternal.RewriteLibrary   -> hoists, then writes bbinit.go
    cmd.Rewrite(..., hookCalls)   -> writes the command's bbinit.go
    writeBBMain(..., lazy=true)   -> arms bbmain's one-command guard

Three rules, all enforced rather than documented:

  • Entirely or not at all. Go finishes an imported package before the
    importer's variables run, so a variable left eager would read a table that
    had not been built.
  • Naming a package defers everything that imports it. An eager package
    initializes before any deferred one, so it may not import one. That closure
    can never meet a package that has to stay eager, because lazyinit: decide which packages initialize at program start #173's eager set is
    already closed downward.
  • A name that does not take fails the build. A -lazy-init that quietly
    deferred nothing would pass every differential check here, since a candidate
    identical to its baseline agrees with it on everything.

Evidence

The libinit fixture, deferring pkg/lonely — the package second never
imports and pays for anyway:

second runs lonely's init first prints
before yes, at program start lonely.init(saw lonely.var) shared.init(...)
after no lonely.init(saw lonely.var) shared.init(...) — unchanged

Both halves are needed. A deferred init is absent from
GODEBUG=inittrace=1 whether it was called or silently skipped, so the first
row alone is also satisfied by a rewrite that dropped the initialization
entirely. TestADeferredInitLeavesTheStartupTrace asserts both.

Real u-root code, through gomodbb, deferring pkg/ls:

$ GODEBUG=inittrace=1 ./bb-base echo hello   # 45 packages, pkg/ls among them
$ GODEBUG=inittrace=1 ./bb-lazy echo hello   # 44 packages, pkg/ls absent
$ ./bb-base ls lsdir && ./bb-lazy ls lsdir   # alpha beta  /  alpha beta

The once-only guard is load-bearing. Deferring shared, which second
reaches twice over, then deleting the guard from the generated tree by hand:

guard present: ... dependent.init(saw ...) shared.init(saw shared.var)
guard removed: ... dependent.init(saw ...) shared.init(saw shared.var)shared.init(saw shared.var)

The fixture appends rather than assigns for exactly this, per #167.

Red-checked by sabotage, 16 in all, each firing alone:

sabotage fires
markImporters does not recurse TestDeferOnlyCarriesTheImportersUp + the closure invariant
named path skips the Deferred guard TestDeferOnlyARefusedPackageMovesNothing
isCommand always false TestNarrowingNeverCallsACommandEager
empty pattern list defers everything TestDeferOnlyNothingDefersNoLibrary
Deferred drops the known check TestDeferredSeparatesAbsentFromEager
hook / guard / alias name skips the collision check the three AvoidsACollision tests
once-only guard removed TestALibraryHookRunsOnce
imports called after this package's own init TestALibraryHookCallsItsImportsFirst
a library gets its package renamed TestALibraryKeepsItsPackageName
RewriteLibrary accepts a command TestRewriteLibraryRefusesACommand
bbmain's guard never armed TestDeferringArmsTheOneCommandGuard
commands never call their hooks TestADeferredInitLeavesTheStartupTrace
no library ever rewritten both of the above
an unmatched -lazy-init ignored TestLazyInitRefusesAPackageItCannotDefer

One sabotage failed to fire and that is the useful one: removing the
!p.library check still errored, with bad parse while processing imports .../bbinit.go: expected type, found newline, because a command has no hook
name so the generated file declares func (). The test asserted only that
an error came back, so it passed without the check being there at all. It
now pins the message.

Suites: src unit tests, test/ default (113s) and test/ -external
(262s) all pass. Lint is at exact parity with #173 — identical 37-issue list,
not merely the same count.

The second commit: gomodbb, on every PR

The differential check had two arms and only one of them ran on a pull
request. The reason recorded for that was gomodbb pinning GOPROXY, which
stopped being true when #168 landed — goCmd overrides GOPRIVATE,
GONOPROXY and GOVCS and deliberately not GOPROXY, so that a caller may
point it at "a corporate mirror, or a cache standing in for the network so
that a test can run offline"
. The comment outlived the pin by six days.

gomodbb's whole input is a module path and a version, and resolving one needs
no internet: the go command serves file:// proxies, so the libinit fixture
is laid out as a module proxy and fetched from it.

TestLazyInitDifferentialGomodbbOffline   5.2s, default suite, no network
TestLazyInitDifferentialGomodbb          -external, real third-party source

Same fixture TestMakeBB and TestLazyInitDifferential build directly, so an
edit to it is seen by both and neither can drift into testing a tree the other
does not.

Red-checked three ways:

check result
HTTPS_PROXY pointed at a closed port still passes — offline, not merely fast
proxy serves no .zip fails — the source really comes from there
candidate built without -lazy-init fails — not comparing a build against itself

golang.org/x/mod was already in this module's graph and becomes a direct
requirement; go.sum does not change. Default suite goes from ~108s to
~113s, within run-to-run variance.

Merge Danger

Door: two-way. Off by default: with no -lazy-init, planInit returns a
nil plan, no library is rewritten, bbmain/lazyinit.go is not written, and the
generated tree is byte-for-byte what it was.

Blast radius: every command importing a named library.

The fair arguments against:

  • The trace is a weak guard and the output is the real one. That only
    works where a library's initialization reaches a command's output, which is
    true of libinit and of no other fixture. The other four fixtures now defer
    a library, but what they prove is that the rewrite compiles and does not
    change behaviour — not that the initialization ran.
  • Five fixtures defer one package each. Nothing here exercises deferring
    many at once, or a deep hook chain. The diamonddep fixture is the deepest,
    at one diamond.
  • It has not met generated code. ADR 0004 names this as the real risk:
    the variable hoisting is where Fix global naming collisions #9's collisions came from, and step 5 points
    it at generated protobuf sources. Nothing in this change does that, so
    nothing here is evidence it will hold.
  • golang.org/x/sys/unix is refused, so any command graph that reaches it
    through the package you name will refuse too. That is the rule working, but
    it means -lazy-init will say no more often than the ADR's numbers suggest.

Out of scope and deliberate: -lazy-init takes whole import paths, not
subtrees, and there is no "defer everything". Both are step 5.

The offline gomodbb test is a second commit rather than a second PR because it
exercises -lazy-init, so it cannot land before the first commit. It is
additive — one test, one import, one go.mod line — and passes on its own,
which matters because squash is disabled here and every commit is a git bisect stop.

One drive-by: the README's ### Command Transformation heading is renamed to
### Command Rewrite. GLOSSARY.md lists transform under Rewrite's
Avoid, and this change adds a sibling section; naming only the new one
correctly would have left two words for one concept. Nothing links to the
anchor.

Authored by Claude.

@codecov

codecov Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.83673% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.71%. Comparing base (217c367) to head (57ee997).

Files with missing lines Patch % Lines
src/pkg/bb/lazyinit/nameable.go 79.62% 11 Missing ⚠️
src/pkg/bb/bbinternal/bb.go 93.33% 8 Missing ⚠️
src/pkg/bb/lazy.go 95.23% 3 Missing ⚠️
src/pkg/bb/bb.go 83.33% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #178      +/-   ##
==========================================
+ Coverage   80.62%   82.71%   +2.09%     
==========================================
  Files          20       23       +3     
  Lines        1409     1672     +263     
==========================================
+ Hits         1136     1383     +247     
- Misses        273      289      +16     
Flag Coverage Δ
1.27.x 82.71% <91.83%> (+2.09%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

A busybox runs every library's init at program start, for every command
in it, whether or not the command being dispatched imports that library.
Of the 228 library packages in the 120-command u-root busybox, none is
reached by every command and 125 are reached by exactly one; all 120 pay
for all 228.

-lazy-init names a library whose initialization moves to the commands
that import it. It is off by default and takes whole import paths, so
the first landing moves one package of our choosing and the blast radius
is that package. Reaching subtrees, and the default, come later.

A named package is rewritten the way a command already is -- its
package-level variables and its init bodies become numbered busyboxInitN
functions -- except that it keeps its own package name and import path,
and gains an exported hook in a generated bbinit.go. The hook calls the
hooks of its direct imports, then runs its own initialization, and a
generated boolean keeps it to one run. Each command gets a bbinit.go of
its own naming its direct imports, and the recursion reaches the rest.
Direct imports only: a package may always import what it already
imports, and reaching further breaks on another module's internal/
packages, which vendoring does not exempt.

Three rules make it safe, and all three are enforced rather than
documented. A package is deferred entirely or not at all, because Go
finishes an imported package before the importer's variables run.
Naming a package defers everything that imports it, because an eager
package initializes before any deferred one and so may not import one;
lazyinit.DeferOnly is that closure, and it can never meet a package that
has to stay eager, since the eager set is already closed downward.
Naming a package that cannot be deferred fails the build rather than
being ignored -- a -lazy-init that quietly did nothing would satisfy
every differential check here, since a candidate identical to its
baseline agrees with it on everything.

Measured on the libinit fixture, deferring pkg/lonely:

	second, which never imports lonely, stops running its init
	first, which does, still prints lonely.init(saw lonely.var)

and on real u-root code through gomodbb, deferring pkg/ls: echo goes
from 45 packages initializing at program start to 44, and ls prints what
it printed before.

Five fixtures now defer a library of their own rather than comparing a
build against itself, chosen for what each makes awkward: an implicit
import, a library in another module, an import name collision, the
bottom of a diamond, and libinit. The init-trace comparison is relaxed
from equality to subsequence, which still catches a package the baseline
never ran -- initialization added rather than deferred, which no output
check would notice.

The trace cannot do more than that, and this is the part worth not
mistaking: GODEBUG=inittrace=1 reports what the runtime ran before main,
so a deferred init is absent from it whether it was called or silently
skipped. Only the output tells those apart, and only where a library's
initialization reaches it. That is what libinit is for and why both
halves of TestADeferredInitLeavesTheStartupTrace are needed.

GLOSSARY.md's Rewrite entry said only commands are rewritten, which this
makes false; it is amended here, with new entries for the hook, the
eager set and a deferred package. The README's two rewrite sections are
named for the glossary's word rather than the one it tells us to avoid.

Authored by Claude.

Signed-off-by: Ron Minnich <rminnich@gmail.com>
gomodbb's arm of the lazy-init differential check sat behind -external,
so it guarded nothing on a pull request. The reason recorded for it was
gomodbb pinning GOPROXY to the public proxy, and that stopped being true
when u-root#168 landed: goCmd overrides GOPRIVATE, GONOPROXY and GOVCS, and
deliberately not GOPROXY, so that a caller may point it at "a corporate
mirror, or a cache standing in for the network so that a test can run
offline". The comment outlived the pin by six days.

That is exactly what this does. gomodbb's whole input is a module path
and a version, and nothing about resolving one has to involve the
internet: the go command serves file:// proxies, so a module that was
never published can be fetched by path and version like any other. The
libinit fixture is laid out as a module proxy and fetched from it, which
takes about five seconds and no network at all.

It is the same fixture TestMakeBB and TestLazyInitDifferential build
directly. One fixture reached two ways means an edit to it is seen by
both, and neither can drift into testing a tree the other does not.

What this covers is the input gomodbb hands the engine, which no makebb
fixture can produce: packages resolved inside a synthetic module gomodbb
wrote itself, with Env.Dir set and GOWORK=off, over source in a
read-only module cache rather than a checkout. Since which packages are
deferred is decided from the import graph, the context that graph is
resolved in is worth a check of its own.

TestLazyInitDifferentialGomodbb stays behind -external and keeps its own
job, which is source nobody wrote to be rewritten: pflag, go-humanize,
u-root's own pkg/ls, and a golang.org/x/sys/unix the rules refuse for
carrying both a //go:linkname and an assembly file.

Red-checked three ways. With HTTPS_PROXY pointed at a closed port it
still passes, so it is offline rather than merely fast. Serving no zip
fails it, so the proxy is really where the source comes from. Building
the candidate without -lazy-init fails it, so it is not comparing a
build against itself.

golang.org/x/mod was already in this module's graph and becomes a direct
requirement; go.sum does not change. The default suite goes from about
108 to about 113 seconds, which is within the variance between runs.

Authored by Claude.

Signed-off-by: Ron Minnich <rminnich@gmail.com>
@rminnich
rminnich marked this pull request as ready for review October 28, 2026 15:02
@brho

brho commented Oct 28, 2026

Copy link
Copy Markdown

this was from PR 173, possibly, but gemini thinks you can fix them here:

  • [High] Command packages (cmds) are conflated with libraries in p.pkgs (eager.go:44-91, order.go:69-90):
      1. A command (package main) is always rewritten by bbinternal.Package.Rewrite to run registeredInit() at dispatch time after busyboxInitDeferred().
      Yet if a command has a //go:linkname or .s file, scan(cmd) marks cmd as an eager root and markEager poisons every library transitively imported by cmd
      into the eager set for the whole busybox.
      2. Conversely, normal commands are not in p.eager, so in PR #178 plan.Deferred(cmdPath) returns true for every command (allowing -lazy-init <cmd-path>
      to pass validation and then silently defer zero libraries).
  • [Medium] linknameTarget prefix over-matching (eager.go:153-163): If a package linknames gopkg.in/yaml.v3.Marshal when gopkg.in/yaml is in p.pkgs and
  gopkg.in/yaml.v3 is not, scanning right-to-left across dots falls back to "gopkg.in/yaml" and falsely marks it eager.
  • [Medium] OtherFiles check (lazyinit.go:55-60): path.Ext(f) == ".s" misses uppercase .S assembly files and .syso precompiled objects.

@brho

brho commented Oct 28, 2026

Copy link
Copy Markdown

i think i asked elsewhere but forget the exact answer. i'm a little concerned about multiple inits happening in parallel. if we had the restriction of "at most one BB command ever runs in this binary", then we'd be OK. the concern would be if we had two goroutines, each trying to run a BB command at the same time, and then getting multiple inits run.

@brho

brho commented Oct 28, 2026

Copy link
Copy Markdown

for this PR178, gemini had more comments:

  • [High] rewriteFile on deferred libraries breaks on inferred unexported types from other packages and blank var _ = expr (bb.go:428-482):
      • When a deferred library has var x = otherpkg.New() (s.Type == nil) and otherpkg.New() returns an unexported type otherpkg.client, rewriteFile synthesizes var x *otherpkg.client, which fails to compile (cannot refer to unexported name otherpkg.client).
      • For blank var _ = expr (s.Type == nil), rewriteFile still emits a pointless var _ <TypeString> declaration at package scope after hoisting _ = expr into busyboxInitN(), which also fails if expr has an unexported type from another package.
  • [Medium] newImportName and newFunctionName don't record allocated names (bbinternal/bb.go:287-341, 413): pkgImportNameTaken and funcNameTaken only consult the static go/types scopes from packages.Load, so if two inferred var types in the same file come from different transitive packages with the same package name foo (a/foo and b/foo), newImportName hands out "foo" twice (foo redeclared as imported package name).
  • [Medium] writeHook / writeDeferredInit unconditionally write bbinit.go (bbinternal/bb.go:626, 807): Overwrites any existing bbinit.go in the package.

Eight findings from @brho's review of u-root#178, each reproduced before it
was fixed and each red-checked by a sabotage that fires alone. Two were
in this change, four in the step before it, and two are older than both
and reached by it.

Mine. Naming a command in -lazy-init passed validation and deferred
nothing, which is the silent no-op the refusal exists to prevent: every
check here compares a candidate against a baseline, and a candidate that
deferred nothing agrees with its baseline on everything. Plan.Deferred
now holds only for libraries and CannotDefer says which of the three
refusals applies, because a typo, a misunderstanding of the flag and a
rule doing its job send the reader somewhere different.

Mine. The generated bbinit.go overwrote any file of that name the
package already had, and the hoisted variable initializers are appended
to whichever file sorts first, which "bbinit.go" very often does. The
build then failed with `undefined: busyboxInit0`, naming neither the
file nor the collision. The generated name now avoids the package's own.

A refused command dragged every library it reached eager, which for a
busybox of any size is all of them. A refusal makes a package an eager
root and an eager root is sound only for a package that initializes at
program start; a command does not, being rewritten whatever this decides
and its initialization already waiting for its own dispatch. Its
//go:linkname targets still join the roots -- those name a package the
command may never import.

A .syso is a precompiled object the linker takes whole, which is the
same hazard as assembly with none of the source to read, and it was not
refused. .S and .sx stay off the list deliberately: go/packages reports
them only for a package using cgo, which this never sees, and measured
on Go 1.27 with CGO_ENABLED=1 a package carrying them reports no other
files at all. A check that cannot fire is not a guard.

Older than this work, and reached by it. Hoisting splits `var V = expr`
into a declaration and an assignment, and the declaration has to state
the inferred type. When that type is unexported and belongs to another
package there is no way to state it -- Go has no declaration that names
a type without naming it -- so the generated source does not build:

	name client not exported by package other

Such a package is refused, which turns an opaque compile failure into a
refusal that names the rule. Rewrite has hoisted a command's variables
the same way since the beginning and fails identically on the same
input; what changes is the chance of meeting it, because commands are
hand-written and libraries are whatever the module graph drags in.

Also older: two inferred variable types naming different packages that
call themselves the same thing were both offered the same import name,
because go/types' scopes describe the source as loaded and know nothing
of an import this rewrite has already added. The names are recorded now,
which fixes commands as well. And `var _ = f()` declares nothing, so the
rewrite leaves no declaration for it rather than a pointless `var _ T`
that is unbuildable when T is a type this file cannot name.

@brho also asked what happens if two goroutines dispatch at once. ADR
0003 says one command runs per process and the guard refusing a second
was a plain read-then-write, so both could see nothing dispatched and
both run the deferred initialization. It is a compare-and-swap now, over
one variable rather than a flag beside a name, because the loser reads
the winner's name to report it and two variables cannot be claimed
together. The first attempt still raced on that name and -race caught
it. Deferred initialization is deliberately not made safe to run
concurrently: the decision is to refuse a second command, not to
serialise it.

The test needed a spin barrier to discriminate. Released from a
WaitGroup the goroutines wake one after another, and the sabotage passed
ten runs; spinning on an atomic leaves them hot and it fails with "2 of
64 goroutines reached a command's main". Counting panics would never
have worked, since every goroutine panics either way.

linknameTarget takes the longest prefix that is a package here, so when
the package a symbol really names is absent and a shorter prefix is
present, the shorter one is made eager. Wrong answer, safe direction,
and nothing to resolve it with, since the package that would settle it
is the one that is missing. Pinned by a test rather than fixed.

ADR 0004, GLOSSARY.md and the README are amended for the two refusals
added and the one removed. The glossary gained a second Eager set entry
in this branch, beside the one step 3 added; the duplicate is gone.

Authored by Claude.

Signed-off-by: Ron Minnich <rminnich@gmail.com>
@rminnich

Copy link
Copy Markdown
Member Author

All three reproduced; two were real and one was half right. Fixed in 57ee997,
each red-checked by a sabotage that fires on its own.

1. Commands conflated with libraries — right, and the first half is the
expensive one.

Both halves confirmed.

A command carrying assembly became an eager root and dragged everything it
imported with it:

eager set: [example.com/m/cmd/c example.com/m/pkg/mid example.com/m/pkg/deep]
Eager(pkg/mid)  = true  (reason "is imported by an eager package")
Eager(pkg/deep) = true  (reason "is imported by an eager package")

For a busybox of any size that is every library, since every library is
reached by some command. And the premise is false besides: a refusal makes a
package an eager root, which is sound only for a package that initializes at
program start, and a command does not — it is rewritten whatever this decides
and its initialization already waits for its own dispatch. A command is no
longer refused. Its //go:linkname targets still join the roots, because
those name a package the command may never import.

The second half was mine, and worse than theoretical — it is the silent no-op
the refusal was supposed to make impossible:

$ makebb -lazy-init .../test/libinit/cmd/first ./cmd/first ./cmd/second
Successfully built "bb"          # no "Deferred ..." line: nothing moved

Every check in this PR compares a candidate against a baseline, and a
candidate that deferred nothing agrees with its baseline on everything.
Plan.Deferred now holds only for libraries, and CannotDefer says which of
three refusals applies, because a typo, a misunderstanding of the flag and a
rule doing its job send the reader somewhere different:

$ makebb -lazy-init .../cmd/first ...
cannot defer the initialization of:
	.../test/libinit/cmd/first is a command, whose initialization already waits for its own dispatch

2. linknameTarget prefix over-matching — real, and left alone on purpose.

Confirmed: with gopkg.in/yaml present and gopkg.in/yaml.v3 absent, a
directive naming gopkg.in/yaml.v3.Marshal resolves to gopkg.in/yaml and
makes it eager.

The answer is wrong and the direction is safe — a package is made eager that
need not be — and there is nothing to resolve it with, because the package
that would settle it is the one that is missing. Both halves of the symbol may
contain dots (example.com/victim.(*T).Ready is a method), so there is no
textual rule either. Pinned by
TestLinknameTargetPrefersAShorterPackageWhenTheRealOneIsAbsent and written
into ADR 0004, so the next reader finds the answer rather than the question.

3. OtherFiles — half right, and the half that is right matters.

.syso is a real gap and is now refused. It is a precompiled object the
linker takes whole: the same hazard as assembly with none of the source to
read.

.S and .sx are not, and adding them would be a check that cannot fire.
go/packages reports them only for a package that uses cgo, which this never
sees — makebb disables cgo and gomodbb inherits that. Measured on Go 1.27,
with CGO_ENABLED=1, against packages carrying empty.S and empty.sx:

package OtherFiles refused
empty.s [empty.s] yes
empty.S [] —
empty.sx [] —
blob.syso [blob.syso] now yes

The reasoning is in ADR 0004 so the omission reads as a decision.

@rminnich

Copy link
Copy Markdown
Member Author

You asked the right question and the answer was no, not quite. Fixed in
57ee997.

We do have the restriction — ADR 0003, one command per process — and
nothing in a generated busybox breaks it: main calls bbmain.Run from one
goroutine, and the second call it makes on ErrNotRegistered dispatched
nothing. But the guard enforcing it was a plain read-then-write:

if dispatched {
	panic(...)
}
dispatched, dispatchedName = true, name

Two goroutines both see false, both proceed, and both run the deferred
initialization — exactly your scenario. A command can reach bbmain.Run,
since every rewritten command imports bbmain, so this is not unreachable by
construction.

It is a compare-and-swap now, over one variable rather than a flag beside a
name, because the loser reads the winner's name to report it and two variables
cannot be claimed together. My first attempt still raced on that name and
-race caught it
, which is the second time this guard has been wrong in a
way that only a racing test shows.

The test needed a spin barrier to discriminate, and that is the part worth
recording.
Released from a WaitGroup, N goroutines wake one after another
— the opposite of what this needs — and the sabotaged guard passed ten runs.
Spinning on an atomic leaves them hot, and then:

--- FAIL: TestRunRefusesASecondDispatchFromAnotherGoroutine
    register_test.go:215: 2 of 64 goroutines reached a command's main, want exactly 1

Counting panics would never have discriminated, since every goroutine panics
either way: one out of its own main, the rest off the refusal. The count that
separates them is how many reached a main.

Deferred initialization is deliberately not made concurrency-safe. Making
the hooks serialise would make two concurrent commands work, which is a
different decision from the one ADR 0003 took and leaves the second command
looking at state the first had already changed. The decision is to refuse a
second command, so the guard's job is to catch the violation, not to support
it. With dispatch single-claimed, the hooks are single-threaded by
construction: they are only reached from registeredInit, which is only
reached from Run.

@rminnich

Copy link
Copy Markdown
Member Author

All three reproduced. Two were real, one was not, and the two real ones are
older than this PR — which does not make them less worth fixing, because this
is the change that makes them likely. Fixed in 57ee997.

1. Inferred unexported type from another package — real, and it is a build
failure.

$ makebb -lazy-init example.com/unexp/pkg/lib ./cmd/c
vendor/example.com/unexp/pkg/lib/lib.go:5:14: name client not exported by package other

Hoisting splits var V = other.New() into a declaration and an assignment,
and the declaration has to state the type that was inferred: var V *other.client. There is no spelling that works. Go has no declaration
that names a type without naming it, so the only choices are to leave the
package's initialization where it is or to emit source that does not build.
It is refused now, which turns that error into one that names the rule.

It is pre-existing. The same shape in a command fails identically on main
today, with no -lazy-init anywhere:

$ makebb -o bb ./cmd/c          # on origin/main
vendor/.../cmd/c/main.go:11:14: name client not exported by package other

So this is ADR 0004's second Consequence arriving on schedule — "the type
inference that hoists package-level variables is where issue #9's collisions
came from, and it now runs over far stranger input". The refusal covers the
libraries this PR newly rewrites. The command case is older and still open
and I have not touched it here.

2. Blank var _ = expr — not a bug as described, but the remedy was right.

It does not fail: it emitted var _ string, which is legal, merely pointless.
It would fail if the inferred type were an unnameable one, so it is the same
defect wearing a different hat. var _ = f() declares nothing, so the rewrite
now leaves no declaration at all and keeps only the assignment — which removes
the noise and the hazard together.

3. newImportName not recording allocated names — real.

$ makebb -lazy-init example.com/alias/pkg/lib ./cmd/c
vendor/.../pkg/lib/lib.go:4:2: other declaration of foo
vendor/.../pkg/lib/lib.go:5:2: "example.com/alias/pkg/b/foo" imported and not used
vendor/.../pkg/lib/lib.go:12:12: undefined: foo.U

a/foo and b/foo both call themselves foo, and go/types' scopes describe
the source as it was loaded and know nothing of an import the rewrite just
added. The names are recorded now. This one fixes commands too — the same
fixture built as a command failed on main and builds here.

4. writeHook/writeDeferredInit overwriting an existing bbinit.go —
real, and worse than overwriting.

The hoisted variable initializers are appended to whichever file sorts first,
and bbinit.go very often does, so the generated file lands on top of the one
that just received them:

vendor/example.com/clobber/pkg/lib/bbinit.go:18:2: undefined: busyboxInit0

— an error naming neither the file nor the collision. The generated name now
avoids every name the package already uses, and the fixture that reproduced it
builds and prints v precious.

@rminnich

Copy link
Copy Markdown
Member Author

Green on 57ee997: all 10 checks, codecov/patch 91.83%, codecov/project
+2.09%, lint at exact parity with main (identical 37-issue list).

test-external-workspaces failed twice before that and neither failure was
this change. Both were HTTP/2 stream errors from the public proxy, in
different pre-existing tests and different modules each time:

run test module error
1 TestGomodbb/list rekby/gpt stream ID 87; INTERNAL_ERROR
2 TestExternalWorkspaces/vendor google/goterm stream ID 27; INTERNAL_ERROR

Neither test touches -lazy-init. Confirmed by re-running the same job on
#174 as a control at the same time: it had passed at 15:04, and both it
and this one came back green on the retry.

The offline gomodbb test added in 9ffd29e passed in 6.78s in both of the
failing runs, which is the argument for it in one line: the half of the
differential check that needs the network is the half that cannot be trusted
to report on the change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants