Skip to content

Finish removing the ETXTBSY write-then-exec race in testkit fixtures - #729

Merged
omkhar merged 2 commits into
mainfrom
fixup/etxtbsy-complete
Sep 10, 2026
Merged

Finish removing the ETXTBSY write-then-exec race in testkit fixtures#729
omkhar merged 2 commits into
mainfrom
fixup/etxtbsy-complete

Conversation

@omkhar

@omkhar omkhar commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Why #727 was incomplete

PR #727 fixed the shared writeExecutable helper (local_docker_parity_test.go)
and four inline sites in release_outputs_verify_test.go, but the hostile-env
CI axes still fail reliably:

release_outputs_verify_test.go:330: running release-output verifier: fork/exec .../verify-release-outputs-test-driver.sh: text file busy
release_outputs_verify_test.go:617: ...

verify-release-outputs-test-driver.sh is already written through
writeExecutable, i.e. through the correct write(0o644)+close+chmod(0o755)
pattern. The failure isn't in how that file is created — it's that Go test
subtests in this package run concurrently (t.Parallel()), all inside one
process, and every other place in internal/testkit that still wrote an
executable fixture directly at 0o755/0o700 was enough to trip the
kernel's fork/exec ETXTBSY race for whichever exec happened to be in flight
at that moment — including an already-safely-written file elsewhere in the
same test binary. Fixing only the two files #727 touched left the rest of
the package racy, so the CI gate stayed red.

What's fixed

Every executable-fixture write in internal/testkit now goes through
write(0o644) fully closed, then a separate os.Chmod to add the exec bit,
via a new shared writeExecFile primitive (in local_docker_parity_test.go,
next to writeExecutable, which now calls it):

  • local_docker_parity_test.go: writeExecutable, and copyValidatorFixtureFile
    — a second, previously-missed racy site in the very file Harden two transient CI build and test flakes #727 already touched.
  • canonical_build_env_test.go: writeCanonicalFixture (also used by
    hosted_controls_token_isolation_test.go and writeCanonicalMutation).
  • update_upstream_pins_check_test.go: writeUpdaterFixtureFile.
  • ci_plan_git_test.go: ciPlanFixture.writeFile, .writeExecutable, and
    replaceScript — the site the Harden two transient CI build and test flakes #727 agent flagged as the same racy shape
    and explicitly left out of scope.
  • exec_fixture_dir.go (not a _test.go file, fixed inline): the probe
    script ExecFixtureDir writes to prove a candidate directory is
    exec-capable.
  • Direct call sites: workcell_version_test.go, container_smoke_harness_test.go,
    public_repo_hygiene_test.go, security_boundary_test.go,
    tracked_shebangs_test.go, git_hooks_test.go, release_verify_test.go,
    install_release_e2e_test.go, and 11 spots in validation_entrypoints_test.go.

Audit: grep -rn "0o755\|0o700\|0o775\|0o777" internal/testkit/ now shows no
os.WriteFile call creating a file with the exec bit already set — every
remaining 0o75x/0o70x literal is either a directory MkdirAll/Mkdir
mode, the final mode argument passed into writeExecFile/writeExecutable/
writeCanonicalFixture/writeUpdaterFixtureFile/ciPlanFixture.writeFile
(all of which write 0o644 then chmod), or an unrelated os.Chmod restoring a
directory's mode in test cleanup.

Verification

  • gofmt -l ./internal — empty.
  • go build ./..., go vet ./... — clean.
  • go test ./internal/testkit/... -count=1 and -run TestVerifyReleaseOutputs -count=30 -v — green on darwin (ETXTBSY doesn't reproduce there; the proof is the audit above).
  • Linux stress (Colima/Docker, golang:latest, since ETXTBSY is Linux-specific):
    • 10x default go test ./internal/testkit/...: 0/10 fail on this branch vs 2/10 hitting text file busy on origin/main.
    • -run TestVerifyReleaseOutputs -count=30 with default t.Parallel(): this branch still shows occasional text file busy under that artificial stress, but so does origin/main (worse: 6 vs 12 failures) — confirming it isn't a remaining unsafe write site.
    • Serializing subtests (-parallel=1) at -count=30 is 30/30 clean on both branches, showing the residual stress-only flakiness is a concurrent-fork kernel race independent of file-creation order, not something a write-then-chmod ordering fix (in this PR's scope) can further reduce.

Not merging — opening for review per house rules.

…es (Linux repro: 0/10 vs 2/10 on main; internal test-infra fix)

PR #727 fixed the shared writeExecutable helper plus four inline sites in
release_outputs_verify_test.go, but every other place in internal/testkit
that wrote an executable test fixture still created it at 0o755/0o700
directly. Any one of these, running concurrently with an already-fixed
exec (e.g. verify-release-outputs-test-driver.sh) in the same test binary,
is enough to trip the kernel's fork/exec ETXTBSY race -- which is exactly
what the hostile-env CI axes were hitting.

Route every executable-fixture write through write(0o644)+close+chmod:

- local_docker_parity_test.go: new shared writeExecFile primitive;
  writeExecutable and copyValidatorFixtureFile (a second missed site in
  the very file #727 touched) now use it.
- canonical_build_env_test.go: writeCanonicalFixture (used by
  canonical_build_env_test.go and hosted_controls_token_isolation_test.go).
- update_upstream_pins_check_test.go: writeUpdaterFixtureFile.
- ci_plan_git_test.go: ciPlanFixture.writeFile, .writeExecutable, and
  replaceScript -- the site #727 explicitly flagged and left out of scope.
- exec_fixture_dir.go: the probe script ExecFixtureDir writes to prove a
  candidate directory is exec-capable (not a _test.go file, fixed inline).
- Direct sites: workcell_version_test.go, container_smoke_harness_test.go,
  public_repo_hygiene_test.go, security_boundary_test.go,
  tracked_shebangs_test.go, git_hooks_test.go, release_verify_test.go,
  install_release_e2e_test.go, validation_entrypoints_test.go (11 spots).

Verified: gofmt/go build/go vet clean; go test ./internal/testkit/...
green on darwin (count=1 and count=30). On Linux (Colima/Docker), 10/10
default `go test ./internal/testkit/...` runs are clean on this branch
versus 2/10 hitting ETXTBSY on origin/main; serializing t.Parallel()
(-parallel=1) at count=30 is 30/30 clean on both, confirming the residual
flakiness under heavy artificial stress is concurrent-exec-driven, not a
remaining unsafe write site.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-10T10:01:43.943631Z 436bcbe Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@omkhar

omkhar commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9f270f3e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/testkit/local_docker_parity_test.go Outdated
… (count=20/count=1 pass; test-only fixture-safety fix)

writeExecFile wrote content at 0o644 then chmod'd to the target mode, but
when path already exists (ciPlanFixture.replaceScript overwriting the
already-checked-out, already-executable scripts/ci-plan.sh), os.WriteFile
truncates that live inode in place and never touches its mode: for the
whole write the inode stays 0o755 with an open writable fd, which is the
exact ETXTBSY window this helper exists to close, just on the overwrite
path instead of the create path.

Fix it once in the shared primitive: write to a temp file in the same
directory, close it, chmod it, then os.Rename it over path. Rename is
atomic within one directory, so the path a concurrent exec sees is never
the inode being written, whether path is new or already exists and is
already executable.

Verified: gofmt/go build/go vet clean; go test ./internal/testkit/...
-count=1 green; go test ./internal/testkit/ -run
'TestVerifyReleaseOutputs|CiPlan|CIPlan' -count=20 green (covers the
replaceScript overwrite path).
@omkhar

omkhar commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 436bcbe022

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@omkhar
omkhar merged commit f27b448 into main Sep 10, 2026
19 checks passed
@omkhar
omkhar deleted the fixup/etxtbsy-complete branch September 10, 2026 10:15
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.

1 participant