Skip to content

test(remote-worker): fix a data race in the egress tests, and honour TMPDIR for socket dirs - #645

Merged
pdettori merged 5 commits into
rossoctl:mainfrom
aslom:fix/remote-worker-test-hygiene
Oct 28, 2026
Merged

pdettori merged 5 commits into
rossoctl:mainfrom
aslom:fix/remote-worker-test-hygiene

Conversation

@aslom

@aslom aslom commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Two pre-existing test bugs in remote-worker, both found while chasing a red proto job on #620. Neither is related to that PR.

1. A data race in internal/guestagent (f040f7a3)

proto fails go test -race ./... with:

Read at ... by goroutine 433:
  testing.(*common).log()
  guestagent.(*EgressForwarder).logf()    egress.go:52
  guestagent.(*EgressForwarder).handle()  egress.go:61
Previous write at ... by goroutine 110:
  testing.tRunner.func1()                testing.go:2023

tRunner.func1 is the test finishing, so a forwarder goroutine is calling t.Logf on a *testing.T that has already returned.

startForwarder passes t.Logf straight to the forwarder. Its t.Cleanup closes the listener, which ends Serve — but Serve spawns each connection with a bare go f.handle(c) (egress.go:46) and nothing joins those goroutines. A handle still mid-request calls f.logf, which on a failed Dial it always does.

The fix gates the logger on the test lifetime: a mutex-guarded flag flipped off by a t.Cleanup registered before the listener's, so cleanup's LIFO order closes the listener first and only then stops logging. The production logf is left alone — it is correct, and falls back to log.Printf when no Logf is set. This was the test handing it a logger with a shorter lifetime than the goroutines using it.

Not reproduced locally, and worth saying plainly: my sandbox forbids Unix socket bind, so the five affected tests cannot run there at all, and the one TCP-only test on the same path passes 20 consecutive -race runs both with and without the change. The fix follows from the stack trace and from go f.handle(c) having no join. CI is the test.

2. Hardcoded /tmp for the socket test directories (ac7bedad)

Fourteen call sites across eleven test files, plus the conformance harness, used os.MkdirTemp("/tmp", ...). The reason was real — a Unix socket path is capped at 104 bytes by sockaddr_un.sun_path on macOS, and Go's default TMPDIR there is long enough to blow it — but hardcoding trades one failure for another: wherever /tmp is not writable, all of them fail at mkdir before testing anything. It also meant the Go tests ignored TMPDIR, the one knob the rest of the repo honours.

Adds internal/shortdir, which tries TMPDIR, then /tmp, then Go's default, and takes the first that is both short enough and writable. A directory that leaves under 40 bytes for a socket name is skipped rather than used, so an over-long TMPDIR costs a fallback instead of an EINVAL from bind that explains nothing. When no candidate works, the failure names every one it tried and why.

New(t, prefix) registers its own cleanup for tests; Make(prefix) is the same logic without the testing dependency, for egress/conformance, which is not a test.

No behaviour change where /tmp is writable: there TMPDIR is usually /tmp or shorter, so the first candidate wins. Verified by the failure mode moving from mkdir /tmp/ectl...: operation not permitted to bind .../c.sock: operation not permitted — the directory is now created, and only the sandbox's socket ban remains, which no path change can address.

Checks

  • gofmt -l . clean; go vet ./... clean
  • internal/shortdir has its own tests (4; two skip where there is no writable fallback, which is itself the condition the package exists for)
  • Every remaining local test failure is this sandbox's socket-bind ban, not a code failure

🤖 Assisted-By Claude Code

@ingpaolodettori-dev ingpaolodettori-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The race fix in startForwarder is correct. The gate's t.Cleanup is registered before the listener's, so LIFO order closes the listener first and only then stops logging. Holding mu across t.Logf means no line can slip in after the flag flips. The shortdir refactor is a good idea, but its length check is applied to the wrong thing, and on macOS it brings back the exact failure the hardcoded /tmp was there to avoid.

must-fix: Make checks len(base)+40 <= 104 on the parent directory. The real socket path is base + "/" + prefix + <up to 10 random digits> + <caller's suffix>. On darwin the default TMPDIR is about 48 bytes (/var/folders/xx/<28 chars>/T). That passes the check and is now preferred over /tmp. collisionLauncher then binds <dir>/firecracker/vm-6/root/run/firecracker.socket: 48+1+7+10+45 ≈ 111 bytes, which is over 104. So occupyJail fails with EINVAL on a default macOS setup, where the old /tmp base worked. That contradicts the PR's "no behaviour change where /tmp is writable". TestNewLeavesRoomForASocketName also applies budget to the returned dir rather than the base, so the package and its own test disagree. With a TMPDIR of about 60 bytes, Make accepts it and the test fails. Suggested fix: check the created dir (len(dir)+need) and let callers pass the suffix length they need (e.g. New(t, prefix, len("/firecracker/vm-6/root/run/firecracker.socket"))). Alternatively, prefer /tmp ahead of a TMPDIR that leaves less room than the caller needs.

CI is all green (linux runners with TMPDIR unset take /tmp, so the issue doesn't show there). Both commits are signed off.

Author: aslom (MEMBER — maintainer)
Areas reviewed: Go (tests + new internal/shortdir package, egress conformance harness)
Agent/IDE config (.claude/.vscode): none
Commits: 2 commits, all signed-off: yes
CI status: passing

func Make(prefix string) (string, error) {
var tried []string
for _, base := range candidates() {
if len(base)+budget > MaxSocketPath {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix: this checks the parent against a fixed 40-byte allowance, but the socket lives at base/<prefix><≤10 digits>/<suffix>, and suffixes in this suite go up to 45 bytes (/firecracker/vm-6/root/run/firecracker.socket, collision test). On darwin's default TMPDIR (about 48 bytes, which passes 48+40≤104) the collision socket comes to about 111 bytes, so bind fails with EINVAL where /tmp used to work. Check the created dir instead (len(dir)+need > MaxSocketPath → remove it and try the next candidate), with need supplied by the caller or at least sized for the longest suffix in the suite.


func TestNewLeavesRoomForASocketName(t *testing.T) {
got := New(t, "x")
if len(got)+budget > MaxSocketPath {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: this asserts budget against the returned dir, while Make only enforces it against the base, so a TMPDIR of 60–64 bytes passes Make and fails this test. Once the check in Make moves to the created dir (see shortdir.go:53), these two agree. A case with a TMPDIR just under the old threshold would pin it.

t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(base) })
base := shortdir.New(t, "fcguard")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: this is the caller that breaks on macOS. The comment above ("a short /tmp path rather than t.TempDir()") is now inaccurate, and the 45-byte /firecracker/<id>/root/run/firecracker.socket suffix doesn't fit in shortdir's 40-byte budget once TMPDIR is long. Pass the needed length, or update the comment along with the shortdir fix.

@@ -34,11 +35,7 @@ func TestListenUnixRoundTrip(t *testing.T) {
// on darwin (and on some Linux libc/kernel combos), so this uses a short-named
// dir directly under /tmp instead — long enough to be unique, short enough that

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the comment still says "a short-named dir directly under /tmp". It's now TMPDIR-or-/tmp via shortdir. The same stale wording is in socket_wait_unix_test.go ("A short /tmp base") and the shortUnixSocketDir comment in fcapi_test.go.

t.Fatal(err)
}
base := shortdir.New(t, "fceg")
t.Cleanup(func() { _ = os.RemoveAll(base) })

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: shortdir.New already registers os.RemoveAll as a cleanup, so this one (and its twins at :197, :243, plus proxy_test.go:209, control_test.go:69, egress_test.go:57, sdnotify_test.go:24, and the defers in main_test.go:51 / gates_egress_unix_test.go:91) is now redundant. Harmless, but dropping them makes it clear who owns the cleanup.

aslom added 3 commits October 28, 2026 09:48
Fourteen call sites across eleven test files, plus the conformance
harness, created their temporary directories with
`os.MkdirTemp("/tmp", ...)`. The reason was real -- a Unix socket path
is bounded by sockaddr_un.sun_path, 104 bytes on macOS, and Go's default
TMPDIR on a Mac is long enough to blow it -- but hardcoding the path
trades one failure for another: wherever /tmp is not writable, every one
of those tests fails at `mkdir` before it tests anything. It also meant
the Go tests ignored TMPDIR, the one knob the rest of the repo honours.

Adds internal/shortdir, which tries TMPDIR, then /tmp, then Go's
default, and takes the first that is both short enough to hold a socket
and writable. A directory that leaves under 40 bytes for a socket name
is skipped rather than used, so an over-long TMPDIR costs a fallback
instead of an EINVAL from `bind` that says nothing about why. When no
candidate works, the failure names every one it tried and the reason --
which is the information needed to fix the environment.

`New(t, prefix)` registers its own cleanup for tests; `Make(prefix)`
is the same logic without the testing dependency, for
egress/conformance, which is not a test.

No behaviour change where /tmp is writable: on such a machine TMPDIR is
usually /tmp or shorter, and the first candidate wins. Verified by the
failure mode moving from `mkdir /tmp/ectl...: operation not permitted`
to `bind .../c.sock: operation not permitted` in a sandbox that forbids
both -- the directory is now created, and only the socket ban remains,
which no path change can address.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Aleksander Slominski <aslom@us.ibm.com>
The `proto` job's `go test -race ./...` fails on internal/guestagent
with a data race between `EgressForwarder.logf` and the test runner's
teardown:

    Read at ... by goroutine 433:
      testing.(*common).log()
      guestagent.(*EgressForwarder).logf()   egress.go:52
      guestagent.(*EgressForwarder).handle() egress.go:61
      guestagent.(*EgressForwarder).Serve.gowrap1()
    Previous write at ... by goroutine 110:
      testing.tRunner.func1()                testing.go:2023

`startForwarder` passes `t.Logf` straight to the forwarder. Its
`t.Cleanup` closes the listener, which ends `Serve`, but `Serve` spawns
each connection with a bare `go f.handle(c)` and nothing joins those
goroutines. A `handle` still mid-request calls `f.logf` -- which on a
failed `Dial` it always does -- and if the test has already returned,
that is a `t.Logf` on a finished `*testing.T`: the race the detector
reports, and something the testing package panics on outright.

Gates the logger instead. A mutex-guarded flag is flipped off by a
`t.Cleanup` registered BEFORE the listener's, so cleanup's LIFO order
closes the listener first and only then stops logging; a line that
arrives after that is dropped rather than written to a dead test.

The production `logf` is left alone -- it is correct, and falls back to
`log.Printf` when no Logf is set. This is the test handing it a logger
with a shorter lifetime than the goroutines that use it.

Not reproduced locally: this sandbox forbids Unix socket bind, so the
five affected tests cannot run here at all, and the one TCP-only test
that exercises the same path passes 20 consecutive -race runs both with
and without this change. The fix follows from the stack trace and from
`go f.handle(c)` at egress.go:46 having no join; CI is the test.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Aleksander Slominski <aslom@us.ibm.com>
Review of rossoctl#645 caught a real bug in the new internal/shortdir: Make applied a
fixed 40-byte allowance to the PARENT directory, but the socket lives at
<dir>/<suffix> where <dir> is base + prefix + up to 10 random digits. Neither
term of what actually matters was measured.

On a default macOS TMPDIR (/var/folders/xx/<28>/T, ~48 bytes) that check passed,
so TMPDIR was preferred over /tmp -- and then the collision test's 45-byte
/firecracker/vm-6/root/run/firecracker.socket put the real path at ~111 bytes,
over sun_path's 104. bind fails with EINVAL exactly where the hardcoded /tmp it
replaced had worked, which contradicts the previous commit's claim of no
behaviour change where /tmp is writable. CI never saw it: Linux runners leave
TMPDIR unset and take /tmp.

The allowance could not have been right as a constant anyway -- callers here
need between 7 bytes ("/c.sock") and 45 -- so New and Make now take `need` and
check the directory they actually created: len(dir)+need > MaxSocketPath removes
it and tries the next candidate. Every call site passes len() of the literal
suffix it appends, so the number cannot drift from the path the test binds.

TestNewLeavesRoomForASocketName measured `budget` against the RETURNED dir while
Make measured the base, so the package and its own test disagreed; a 60-byte
TMPDIR passed Make and failed the test. Both now assert the same thing.

Also from the review: the stale "/tmp" comments in listen_test.go,
socket_wait_unix_test.go, fcapi_test.go, conformance.go and collisionLauncher
now describe what the code does, and the nine RemoveAll cleanups that duplicate
shortdir.New's own t.Cleanup are gone, leaving one owner for the cleanup.

Verified: the two new tests were run against the old base-only check and both
fail (one accepts a TMPDIR with no room for the caller, the other accepts an
impossible 105-byte need), so neither passes vacuously. Full remote-worker
go test A/B against main, back to back: main fails 110, this branch 103 -- a
strict subset, the 7 being tests main cannot run at all because they hardcode
/tmp. Every failure in both runs is "bind: operation not permitted", this
sandbox's documented ban on bind(2), not an assertion; golangci-lint reports
nothing in internal/shortdir.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Aleksander Slominski <aslom@us.ibm.com>
@aslom
aslom force-pushed the fix/remote-worker-test-hygiene branch from f040f7a to 8cbf053 Compare October 28, 2026 14:05

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you — the must-fix was real, and the arithmetic in it is exactly right. Addressed in 8cbf053b, rebased onto 25154730 (so x/net 0.62.0 from #648 is in). Three commits now.

must-fix — shortdir.go:53, the length check was on the wrong thing

Reproduced before changing anything. The socket path is base + "/" + prefix + <≤10 digits> + <caller's suffix>, and Make measured only base against a flat 40. On a default darwin TMPDIR:

base   /var/folders/xx/<28 chars>/T              46   46+40 = 86  <= 104, so ACCEPTED
dir    <base>/vmpool1234567890                   63
+      /firecracker/vm-6/root/run/firecracker.socket  45
                                                ----
total                                            108  > 104       EINVAL

So occupyJail fails where the hardcoded /tmp worked, and my "no behaviour change where /tmp is writable" claim in ac7bedad was wrong. Your diagnosis of why CI stayed green — Linux runners leave TMPDIR unset and take /tmp — is also right, and is the reason 15 green checks were not evidence of anything here.

Took your suggested fix rather than the alternative. New and Make now take need, and the check moved to the directory that actually exists:

dir, err := os.MkdirTemp(base, prefix)
if err != nil { /* tried = ...; continue */ }
if len(dir)+need > MaxSocketPath {
    _ = os.RemoveAll(dir)
    tried = append(tried, /* dir, its length, the total, and need */)
    continue
}
return dir, nil

Creating first and measuring the real path is what makes this correct: MkdirTemp's random suffix is up to 10 digits and the prefix is the caller's, so no arithmetic on base alone can predict the result.

I did not take the alternative (prefer /tmp ahead of a too-short TMPDIR), because it only moves the threshold — it still has to guess how much room the caller wants in order to decide what "too short" means, and the needs here span 7 bytes (/c.sock) to 45. Ordering cannot fix a missing input. With need supplied, the existing TMPDIR → /tmp → os.TempDir() order gives your alternative's behaviour as a consequence: a TMPDIR without room for this caller is skipped and /tmp is used.

All 15 call sites pass len() of the literal suffix they append, so the number cannot drift from the path the test binds:

base := shortdir.New(t, "fcguard", len("/firecracker/vm-6/root/run/firecracker.socket"))
dir  := shortdir.New(t, "ectl", len("/vm1.sock"))

The error message when nothing fits now names the created directory, its length, the total with the caller's suffix, and need, so the next person gets the sum rather than having to redo it.

suggestion — shortdir_test.go:58, the test disagreed with the package

Correct, and it was the sharper half of the finding: TestNewLeavesRoomForASocketName asserted budget against the returned dir while Make enforced it against the base, so the two could not both be right. Now that Make checks the created dir, they assert the same thing, and the test loops over both a 7-byte and a 45-byte need.

Added the case you asked for — a TMPDIR sized to sit just under the old threshold. TestNewFallsBackForABigNeedWhereASmallOneFits builds one base and asks for two needs: the small one must come from TMPDIR (proving the directory is usable, so the test is not just measuring an unusable path) and the large one must fall back. The base-only check could not pass that, because it never saw need at all.

Both new tests were run against the old implementation and both fail — one accepts a TMPDIR with no room for its caller, the other accepts an impossible 105-byte need — so neither passes vacuously.

suggestion — launcher_firecracker_collision_unix_test.go:30, inaccurate comment

Fixed, and it now records the 45-byte figure and the fact that the suffix length is passed to shortdir.New, so the next reader does not have to re-derive why this call site is the tight one.

nit — stale "/tmp" wording

All four fixed: listen_test.go, socket_wait_unix_test.go, fcapi_test.go's shortUnixSocketDir, and the newHarness comment in egress/conformance/conformance.go, which had the same problem.

nit — redundant os.RemoveAll cleanups

Dropped all nine you listed, so shortdir.New's own t.Cleanup is the single owner. That orphaned the os import in sdnotify_test.go and guestagent/egress_test.go; both removed.

I left the defers that are not on a shortdir directory alone — e.g. main_test.go's cmd.Process.Kill and the listener closes — since those still own something.

Verification, and what I could not verify here

Stated plainly, since the last round's "CI is the test" turned out to matter:

  • go vet ./... and gofmt clean; golangci-lint run ./... reports nothing in internal/shortdir (the 68 issues it finds are pre-existing and it is not wired into CI or pre-commit).
  • Full remote-worker go test ./... A/B against main, back to back: main fails 110, this branch 103 — a strict subset, the 7 being tests main cannot run at all because they hardcode /tmp. No new failures.
  • Every failure in both runs is bind: operation not permitted — my sandbox forbids bind(2), so those tests die before asserting. That is also why I verified the fix in a scratch copy of the package with the /tmp candidate pointed at a writable short path: it is the only way to exercise the fallback branch here, and it is how I confirmed the two new tests fail without the fix (6 pass with it, 2 fail without).
  • I cannot run the darwin bind that the must-fix is ultimately about, so the EINVAL itself is reasoned from the byte counts above, not observed. The path arithmetic is checkable by hand and the shortdir unit tests now pin the decision that precedes the bind.

Worth noting for the record: this is the second finding on this PR that CI could not have caught, both for the same structural reason — the Linux runners take /tmp, so the darwin path length is never exercised. Happy to add a job that runs these with a long TMPDIR if you think it is worth the minutes; it would have caught this one.

Two `need` values from the previous commit were measured against the wrong
literal. Both call sites bind more than one socket under the directory, and I
took the first name rather than the longest:

  internal/egress/control  shortDir  len("/vm1.sock")=9  but vmListener also
                                     binds "vm-doomed.sock" -> 15
  cmd/moca-egress          main_test len("/c.sock")=7     but the test also
                                     binds "vm.sock" -> 8

Both are real `net.ListenUnix` calls, so both were genuinely undersized -- by 6
and 1 bytes. Neither fails on a short base (on CI's /tmp they come to 34 and 28
against the 104 limit), so this is not a live failure anywhere today; it is the
margin being smaller than the call site claims, which is exactly the class of
error the `need` argument exists to make visible.

Derived by re-reading every path joined onto a shortdir directory in each
package rather than by eye: the remaining 13 call sites are correctly sized,
including fceg, whose declared 45-byte firecracker.socket also covers the
38-byte v.sock_1025 bound beside it.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Aleksander Slominski <aslom@us.ibm.com>

@ingpaolodettori-dev ingpaolodettori-dev left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review @ 8cbf053. The rebase is clean (range-diff = for the two earlier commits), so only 8cbf053 was reviewed.

All items from the f040f7a review are addressed:

  • must-fix (shortdir.go:53): Make now measures the created directory against the caller's need and falls back if it doesn't fit. All 15 call sites pass len() of their suffix, so darwin's default TMPDIR now falls back to /tmp for the 45-byte collision socket.
  • test/package disagreement: fixed. The new regression test and the error-path test both look good.
  • stale comments / redundant os.RemoveAll cleanups: fixed everywhere, including conformance.go.

One small suggestion inline: an off-by-one at exactly 104 bytes on darwin (the NUL terminator).

On the red proto job: I couldn't read the log, but gofmt and vet are clean, and an isolated go test -race ./... at this head passed every package. The only failure I could reproduce is a pre-existing flake in internal/egress TestCapRefusalsAreBoundedPerVM ("no refusal was ever in flight"). It fails about 1 run in 5 under -count=50 -race both here and at the parent commit. The test samples capRefusalsInFlight right after each write without waiting for the handler. A re-run should clear it; it's probably worth its own issue.

tried = append(tried, fmt.Sprintf("%s (%v)", base, err))
continue
}
if len(dir)+need > MaxSocketPath {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: off by one on darwin. sun_path there is 104 bytes including the NUL, and Go's syscall (SockaddrUnix.sockaddr in syscall_bsd.go) rejects len(name) >= 104 with EINVAL, so the longest bindable path is 103. > MaxSocketPath accepts exactly 104. Use >= (or make the constant the usable length, 103, and say so in its comment). TestNewFallsBackForABigNeedWhereASmallOneFits sizes its base so the small-need dir can come to exactly 104 and expects it accepted, so its arithmetic needs the same -1.

Two more undersized `need` values, and a test so the next one is found by CI
rather than by hand:

  internal/egress      len("/vm-1.sock")=10, but rossoctl#641 added "vm-quiet" -> 14
  egress/conformance   len("/c.sock")=7, but cases.go registers "vm-a" -> 10

The egress one is the instructive case. That need was CORRECT when written and
became wrong when an unrelated PR added a longer VM id, which is a failure mode
the hand-audit cannot be relied on to catch: `need` sits at the call site while
the names bound under it are hundreds of lines away, in another file, and grow
when someone adds a test. Four of fifteen call sites were undersized this way --
I got this wrong three times in a row, including once while explicitly auditing
for it.

So `callers_test.go` reads the module's own source, finds every shortdir.New /
Make need, and compares it against the longest socket name that scope binds. It
checks what the reviewer's original must-fix was really about: that the declared
margin matches the path the code actually binds.

Scoping is per-file with a package fallback, because both shapes occur: a
directory used by one test is bounded by that test (cmd/moca-egress's notify
dir only ever holds "/notify", so charging it the package maximum was a false
positive), while a helper in one file with names in another must use the whole
package -- and a file joining a VARIABLE id, as conformance.go does with
id+".sock", likewise cannot be bounded by its own literals.

Socket detection is a deliberately narrow name heuristic (.sock, .socket,
v.sock_<port>, and the one plain "notify"), not dataflow: these names reach bind
through locals, helper parameters and shell scripts, so following them properly
needs a type-checked SSA pass. The two errors are asymmetric -- a missed socket
costs one call site's coverage, while a regular file mistaken for a socket fails
correct code, which is how the first draft flagged vmpool's jailer-was-spawned
marker.

Verified against all four real bugs: each one reverted individually makes the
test fail, and the clean tree passes. An undersized need does not fail on a
short base like /tmp, which is why none of these showed up in CI and why the
test is source-level rather than a runtime assertion -- it reports every call
site at once instead of only the paths one run happens to take. It also floors
at 14 sites, so a refactor that breaks the scan fails loudly instead of passing
vacuously; the first draft silently scanned only internal/ because it walked
".." from internal/shortdir, and missed cmd/moca-egress entirely.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Aleksander Slominski <aslom@us.ibm.com>

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you — the must-fix was real, and the arithmetic in it is exactly right. Addressed in 8cbf053b, with two follow-ups (31ccf033, 91cc8db4) that came out of chasing it properly. Rebased onto 25154730, so x/net 0.62.0 from #648 is in. Five commits.

must-fix — shortdir.go:53, the length check was on the wrong thing

Reproduced before changing anything. The socket path is base + "/" + prefix + <≤10 digits> + <caller's suffix>, and Make measured only base against a flat 40. On a default darwin TMPDIR:

base   /var/folders/xx/<28 chars>/T              46   46+40 = 86  <= 104, so ACCEPTED
dir    <base>/vmpool1234567890                   63
+      /firecracker/vm-6/root/run/firecracker.socket  45
                                                ----
total                                            108  > 104       EINVAL

So occupyJail fails where the hardcoded /tmp worked, and my "no behaviour change where /tmp is writable" claim in ac7bedad was wrong. Your diagnosis of why CI stayed green — Linux runners leave TMPDIR unset and take /tmp — is also right, and is the reason 15 green checks were not evidence of anything here.

Took your suggested fix rather than the alternative. New and Make now take need, and the check moved to the directory that actually exists:

dir, err := os.MkdirTemp(base, prefix)
if err != nil { /* tried = ...; continue */ }
if len(dir)+need > MaxSocketPath {
    _ = os.RemoveAll(dir)
    tried = append(tried, /* dir, its length, the total, and need */)
    continue
}
return dir, nil

Creating first and measuring the real path is what makes this correct: MkdirTemp's random suffix is up to 10 digits (measured, not assumed — 3000 calls, max 10) and the prefix is the caller's, so no arithmetic on base alone can predict the result.

I did not take the alternative (prefer /tmp ahead of a too-short TMPDIR), because it only moves the threshold — it still has to guess how much room the caller wants in order to decide what "too short" means, and the needs here span 7 bytes (/c.sock) to 45. Ordering cannot fix a missing input. With need supplied, the existing TMPDIR → /tmp → os.TempDir() order gives your alternative's behaviour as a consequence: a TMPDIR without room for this caller is skipped and /tmp is used.

The error message when nothing fits now names the created directory, its length, the total with the caller's suffix, and need, so the next person gets the sum rather than having to redo it.

The part of this I got wrong four times — 91cc8db4

Worth reporting in full, because it says something about the shape of the fix rather than just about me.

Having added need, I then had to supply it at 15 call sites. Four of them were undersized, and I found them one at a time, each after claiming the audit was complete:

call site declared longest socket actually bound short by
internal/egress/control len("/vm1.sock") = 9 vm-doomed.sock → 15 6
cmd/moca-egress len("/c.sock") = 7 vm.sock → 8 1
internal/egress len("/vm-1.sock") = 10 vm-quiet.sock → 14 4
egress/conformance len("/c.sock") = 7 vm-a.sock (cases.go) → 10 3

All four bind real listeners. None of them fails on a short base — on CI's /tmp they come to 28–34 bytes against the 104 limit — so nothing catches them until someone runs with a long TMPDIR, which is the exact case this package exists to handle.

The internal/egress one is the instructive case: that need was correct when written. It became wrong when #641 added vm-quiet/vm-noisy, long after. need lives at the call site; the names bound under it live hundreds of lines away, often in another file, and grow when someone adds a test. That is not a thing to re-audit by hand, so 91cc8db4 adds internal/shortdir/callers_test.go, which reads the module's own source, finds every shortdir.New/Make, and checks its need against the longest socket name that scope binds.

Two design notes, since the test has judgement in it:

  • Scoping is per-file with a package fallback. Both shapes occur: a directory used by one test is bounded by that test (cmd/moca-egress's notify dir only ever holds /notify, and charging it the package maximum was a false positive), while a helper in one file with names in another needs the whole package — as does a file joining a variable id, which is how conformance.go reaches vm-a via id+".sock".
  • Socket detection is a deliberately narrow name heuristic (.sock, .socket, v.sock_<port>, plus the one plain notify), not dataflow. These names reach bind through locals, helper parameters and shell scripts, so following them properly wants a type-checked SSA pass rather than a regexp. The two errors are asymmetric: a missed socket costs one call site's coverage, while a regular file mistaken for a socket fails correct code — which is how the first draft flagged vmpool's jailer-was-spawned marker file.

Verified against all four: each reverted individually makes the test fail; the clean tree passes. It also floors at 14 call sites, so a refactor that breaks the scan fails loudly instead of passing vacuously — the first draft silently walked ".." from internal/shortdir, which is internal/, and missed cmd/moca-egress entirely while reporting a confident "12 sites checked".

suggestion — shortdir_test.go:58, the test disagreed with the package

Correct, and it was the sharper half of the finding: TestNewLeavesRoomForASocketName asserted budget against the returned dir while Make enforced it against the base, so the two could not both be right. Now that Make checks the created dir, they assert the same thing, and the test loops over both a 7-byte and a 45-byte need.

Added the case you asked for — a TMPDIR sized to sit just under the old threshold. TestNewFallsBackForABigNeedWhereASmallOneFits builds one base and asks for two needs: the small one must come from TMPDIR (proving the directory is usable, so the test is not just measuring an unusable path) and the large one must fall back. The base-only check could not pass that, because it never saw need at all.

Both new tests were run against the old implementation and both fail — one accepts a TMPDIR with no room for its caller, the other accepts an impossible 105-byte need — so neither passes vacuously.

suggestion — launcher_firecracker_collision_unix_test.go:30, inaccurate comment

Fixed, and it now records the 45-byte figure and the fact that the suffix length is passed to shortdir.New, so the next reader does not have to re-derive why this call site is the tight one.

nit — stale "/tmp" wording

All four fixed: listen_test.go, socket_wait_unix_test.go, fcapi_test.go's shortUnixSocketDir, and the newHarness comment in egress/conformance/conformance.go, which had the same problem.

nit — redundant os.RemoveAll cleanups

Dropped all nine you listed, so shortdir.New's own t.Cleanup is the single owner. That orphaned the os import in sdnotify_test.go and guestagent/egress_test.go; both removed.

I checked the teardown ordering this changes, since it is not obviously neutral: the defers I removed ran before any t.Cleanup, whereas shortdir.New's runs after. The new order removes the directory strictly after the listeners close, which is the safer of the two. I left alone the defers that own something other than the directory (main_test.go's cmd.Process.Kill, the srv.Closes).

The red proto on 8cbf053b is a pre-existing flake, not this PR

Flagging this since it will be sitting red on the PR. The failure is:

--- FAIL: TestCapRefusalsAreBoundedPerVM (0.00s)
    proxy_test.go:527: no refusal was ever in flight: the test did not exercise the refusal path
FAIL	github.com/rossoctl/moca/remote-worker/internal/egress	48.915s

Every package this PR touches passed in that same run, internal/shortdir included.

The same test is failing on main. Run 40708539112 at c916d731 (the #646 merge, which is not in this branch's history) fails identically, at proxy_test.go:530 — same assertion, different line because main has moved:

--- FAIL: TestCapRefusalsAreBoundedPerVM (0.00s)
    proxy_test.go:530: no refusal was ever in flight: the test did not exercise the refusal path

The test arrived in 87cc3fe9 (#641) and this branch does not touch it — this branch's entire diff to proxy_test.go is the two lines of newHarness that call shortdir.New (the need argument, and the redundant cleanup removed). The mechanism looks timing-dependent by construction: it writes CONNECT and then immediately polls capRefusalsInFlight, so peak == 0 means the refusal completed between the write and the first poll on a slow runner, rather than the bound being violated. The (0.00s) is consistent with that — it never got as far as measuring anything.

Happy to open a separate issue, or to take a fix in this PR if you would rather not have it bouncing: polling with a deadline, or holding the refusals open so they cannot complete early, would both close the window. I have left it alone for now since it is #641's test and not this PR's subject.

Verification, and what I could not verify here

Stated plainly, since the last round's "CI is the test" turned out to matter:

  • gofmt and go vet ./... clean, checked on linux/amd64 in a golang:1.26-alpine container (Go 1.26.4, the version go.work pins) as well as on darwin — the host here is arm64 darwin, so a darwin-only check was not evidence for CI.
  • internal/shortdir (all 6 tests), egress/conformance and internal/egress pass under -race on that Linux container at this head.
  • A/B under -race on Linux, this commit's parent (0ebc871b, same rebased base) against the branch: internal/vmpool ok/ok, internal/egress ok/ok, internal/egress/control ok/ok. No regression from these commits.
  • The shortdir tests were also run under CI's own conditions (TMPDIR unset, /tmp writable): all 6 pass, none skipped.
  • I cannot run bind(2) in my sandbox, so the darwin EINVAL the must-fix is ultimately about is reasoned from the byte counts above, not observed. The path arithmetic is checkable by hand, and the shortdir unit tests now pin the decision that precedes the bind. The fallback branch was exercised in a scratch copy of the package with the /tmp candidate pointed at a writable short path — the only way to reach it here.

One structural note for the record: both findings on this PR were things CI could not catch, for the same reason — the Linux runners take /tmp, so neither the darwin path length nor an undersized need is ever exercised. callers_test.go closes the second of those on every platform. For the first, a job running remote-worker's tests with a long TMPDIR would have caught this must-fix; happy to add one if you think it earns the minutes.

@pdettori
pdettori merged commit d193804 into rossoctl:main Oct 28, 2026
15 checks passed

@aslom aslom left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you — the must-fix was real, and the arithmetic in it is exactly right. Addressed in 8cbf053b, with two follow-ups (31ccf033, 91cc8db4) that came out of chasing it properly. Rebased onto 25154730, so x/net 0.62.0 from #648 is in. Five commits.

must-fix — shortdir.go:53, the length check was on the wrong thing

Reproduced before changing anything. The socket path is base + "/" + prefix + <≤10 digits> + <caller's suffix>, and Make measured only base against a flat 40. On a default darwin TMPDIR:

base   /var/folders/xx/<28 chars>/T              46   46+40 = 86  <= 104, so ACCEPTED
dir    <base>/vmpool1234567890                   63
+      /firecracker/vm-6/root/run/firecracker.socket  45
                                                ----
total                                            108  > 104       EINVAL

So occupyJail fails where the hardcoded /tmp worked, and my "no behaviour change where /tmp is writable" claim in ac7bedad was wrong. Your diagnosis of why CI stayed green — Linux runners leave TMPDIR unset and take /tmp — is also right, and is the reason 15 green checks were not evidence of anything here.

Took your suggested fix rather than the alternative. New and Make now take need, and the check moved to the directory that actually exists:

dir, err := os.MkdirTemp(base, prefix)
if err != nil { /* tried = ...; continue */ }
if len(dir)+need > MaxSocketPath {
    _ = os.RemoveAll(dir)
    tried = append(tried, /* dir, its length, the total, and need */)
    continue
}
return dir, nil

Creating first and measuring the real path is what makes this correct: MkdirTemp's random suffix is up to 10 digits (measured, not assumed — 3000 calls, max 10) and the prefix is the caller's, so no arithmetic on base alone can predict the result.

I did not take the alternative (prefer /tmp ahead of a too-short TMPDIR), because it only moves the threshold — it still has to guess how much room the caller wants in order to decide what "too short" means, and the needs here span 7 bytes (/c.sock) to 45. Ordering cannot fix a missing input. With need supplied, the existing TMPDIR → /tmp → os.TempDir() order gives your alternative's behaviour as a consequence: a TMPDIR without room for this caller is skipped and /tmp is used.

The error message when nothing fits now names the created directory, its length, the total with the caller's suffix, and need, so the next person gets the sum rather than having to redo it.

The part of this I got wrong four times — 91cc8db4

Worth reporting in full, because it says something about the shape of the fix rather than just about me.

Having added need, I then had to supply it at 15 call sites. Four of them were undersized, and I found them one at a time, each after claiming the audit was complete:

call site declared longest socket actually bound short by
internal/egress/control len("/vm1.sock") = 9 vm-doomed.sock → 15 6
cmd/moca-egress len("/c.sock") = 7 vm.sock → 8 1
internal/egress len("/vm-1.sock") = 10 vm-quiet.sock → 14 4
egress/conformance len("/c.sock") = 7 vm-a.sock (cases.go) → 10 3

All four bind real listeners. None of them fails on a short base — on CI's /tmp they come to 28–34 bytes against the 104 limit — so nothing catches them until someone runs with a long TMPDIR, which is the exact case this package exists to handle.

The internal/egress one is the instructive case: that need was correct when written. It became wrong when #641 added vm-quiet/vm-noisy, long after. need lives at the call site; the names bound under it live hundreds of lines away, often in another file, and grow when someone adds a test. That is not a thing to re-audit by hand, so 91cc8db4 adds internal/shortdir/callers_test.go, which reads the module's own source, finds every shortdir.New/Make, and checks its need against the longest socket name that scope binds.

Two design notes, since the test has judgement in it:

  • Scoping is per-file with a package fallback. Both shapes occur: a directory used by one test is bounded by that test (cmd/moca-egress's notify dir only ever holds /notify, and charging it the package maximum was a false positive), while a helper in one file with names in another needs the whole package — as does a file joining a variable id, which is how conformance.go reaches vm-a via id+".sock".
  • Socket detection is a deliberately narrow name heuristic (.sock, .socket, v.sock_<port>, plus the one plain notify), not dataflow. These names reach bind through locals, helper parameters and shell scripts, so following them properly wants a type-checked SSA pass rather than a regexp. The two errors are asymmetric: a missed socket costs one call site's coverage, while a regular file mistaken for a socket fails correct code — which is how the first draft flagged vmpool's jailer-was-spawned marker file.

Verified against all four: each reverted individually makes the test fail; the clean tree passes. It also floors at 14 call sites, so a refactor that breaks the scan fails loudly instead of passing vacuously — the first draft silently walked ".." from internal/shortdir, which is internal/, and missed cmd/moca-egress entirely while reporting a confident "12 sites checked".

suggestion — shortdir_test.go:58, the test disagreed with the package

Correct, and it was the sharper half of the finding: TestNewLeavesRoomForASocketName asserted budget against the returned dir while Make enforced it against the base, so the two could not both be right. Now that Make checks the created dir, they assert the same thing, and the test loops over both a 7-byte and a 45-byte need.

Added the case you asked for — a TMPDIR sized to sit just under the old threshold. TestNewFallsBackForABigNeedWhereASmallOneFits builds one base and asks for two needs: the small one must come from TMPDIR (proving the directory is usable, so the test is not just measuring an unusable path) and the large one must fall back. The base-only check could not pass that, because it never saw need at all.

Both new tests were run against the old implementation and both fail — one accepts a TMPDIR with no room for its caller, the other accepts an impossible 105-byte need — so neither passes vacuously.

suggestion — launcher_firecracker_collision_unix_test.go:30, inaccurate comment

Fixed, and it now records the 45-byte figure and the fact that the suffix length is passed to shortdir.New, so the next reader does not have to re-derive why this call site is the tight one.

nit — stale "/tmp" wording

All four fixed: listen_test.go, socket_wait_unix_test.go, fcapi_test.go's shortUnixSocketDir, and the newHarness comment in egress/conformance/conformance.go, which had the same problem.

nit — redundant os.RemoveAll cleanups

Dropped all nine you listed, so shortdir.New's own t.Cleanup is the single owner. That orphaned the os import in sdnotify_test.go and guestagent/egress_test.go; both removed.

I checked the teardown ordering this changes, since it is not obviously neutral: the defers I removed ran before any t.Cleanup, whereas shortdir.New's runs after. The new order removes the directory strictly after the listeners close, which is the safer of the two. I left alone the defers that own something other than the directory (main_test.go's cmd.Process.Kill, the srv.Closes).

The red proto on 8cbf053b was a pre-existing flake — green on re-run

Flagging it because it is in the PR's history. The failure was:

--- FAIL: TestCapRefusalsAreBoundedPerVM (0.00s)
    proxy_test.go:527: no refusal was ever in flight: the test did not exercise the refusal path
FAIL	github.com/rossoctl/moca/remote-worker/internal/egress	48.915s

Every package this PR touches passed in that same run, internal/shortdir included.

The same test failed on main. Run 40708539112 at c916d731 (the #646 merge, which is not in this branch's history) failed identically, at proxy_test.go:530 — same assertion, different line because main has moved:

--- FAIL: TestCapRefusalsAreBoundedPerVM (0.00s)
    proxy_test.go:530: no refusal was ever in flight: the test did not exercise the refusal path

The test arrived in 87cc3fe9 (#641) and this branch does not touch it — this branch's entire diff to proxy_test.go is the two lines of newHarness that call shortdir.New (the need argument, and the redundant cleanup removed). The mechanism looks timing-dependent by construction: it writes CONNECT and then immediately polls capRefusalsInFlight, so peak == 0 means the refusal completed between the write and the first poll on a slow runner, rather than the bound being violated. The (0.00s) is consistent with that — it never got as far as measuring anything.

proto then passed on 91cc8db4 (run 40712180471) with internal/egress green at 47.7s and internal/shortdir green at 1.1s, which is the same test code — only the three commits above differ, none of which touch proxy_test.go's cap-refusal path. A test that fails and then passes on code that did not change is a flake by definition; combined with the identical failure on main, that is as close to settled as this gets without a fix.

Happy to open a separate issue, or to take a fix in this PR if you would rather not have it bouncing: polling with a deadline, or holding the refusals open so they cannot complete early, would both close the window. I have left it alone for now since it is #641's test and not this PR's subject.

Verification, and what I could not verify here

Stated plainly, since the last round's "CI is the test" turned out to matter:

  • gofmt and go vet ./... clean, checked on linux/amd64 in a golang:1.26-alpine container (Go 1.26.4, the version go.work pins) as well as on darwin — the host here is arm64 darwin, so a darwin-only check was not evidence for CI.
  • internal/shortdir, egress/conformance and internal/egress pass under -race on that Linux container at this head — and, more to the point, all 15 checks are green on 91cc8db4, proto included, with every remote-worker package passing.
  • A/B under -race on Linux, this commit's parent (0ebc871b, same rebased base) against the branch: internal/vmpool ok/ok, internal/egress ok/ok, internal/egress/control ok/ok. No regression from these commits.
  • The shortdir tests were also run under CI's own conditions (TMPDIR unset, /tmp writable): all 6 pass, none skipped.
  • I cannot run bind(2) in my sandbox, so the darwin EINVAL the must-fix is ultimately about is reasoned from the byte counts above, not observed. The path arithmetic is checkable by hand, and the shortdir unit tests now pin the decision that precedes the bind. The fallback branch was exercised in a scratch copy of the package with the /tmp candidate pointed at a writable short path — the only way to reach it here.

One structural note for the record: both findings on this PR were things CI could not catch, for the same reason — the Linux runners take /tmp, so neither the darwin path length nor an undersized need is ever exercised. callers_test.go closes the second of those on every platform. For the first, a job running remote-worker's tests with a long TMPDIR would have caught this must-fix; happy to add one if you think it earns the minutes.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants