Skip to content

fix(codegen): write generated files atomically to survive interrupted generation#4262

Open
webel-crew wants to merge 6 commits into
99designs:masterfrom
webel-crew:fix/atomic-write-preserves-output
Open

fix(codegen): write generated files atomically to survive interrupted generation#4262
webel-crew wants to merge 6 commits into
99designs:masterfrom
webel-crew:fix/atomic-write-preserves-output

Conversation

@webel-crew

Copy link
Copy Markdown

api.Generate unlinks the exec + model output files (syscall.Unlink on cfg.Exec.Filename / cfg.Model.Filename) at the very start of generation, but the rewrite only happens much later in templates.Renderos.WriteFile. If generation is interrupted anywhere in that window — a build/compile failure, a timeout, an OOM on a large generated.go, a killed process — the output file is left deleted rather than merely stale. That's invisible to go build until the next successful regen, and reported in #2345 (and as a symptom in #3505).

This makes the write atomic and drops the upfront unlink: templates renders to a temp file in the same directory and os.Renames it into place on success, so an interruption leaves the previous file intact instead of absent, and a reader never sees a half-written file. Removing the syscall.Unlink is safe because os.Rename already replaces the file atomically (and os.WriteFile truncates) — the pre-delete was redundant. Successful runs are unchanged, including the unchanged-content short-circuit.

Added TestGenerateAtomicWritePreservesOutputOnFailure (fails on master, passes here); the existing api and codegen/templates suites still pass.

I have:

  • Added tests covering the bug / feature (see testing)
  • Updated any relevant documentation — n/a: no docs page describes the file-write/lifecycle behavior this touches

… generation

Generate() unlinked the exec + model outputs at the very start, before any schema load or render, so any interruption or failure during generation (OOM on the validation compile, a timeout, SIGKILL, or a failing schema mutator) left the output ABSENT — invisible to go build until the next regen. Reported as 99designs#2345 ('deleted the older model/generated.go file, did not create a new one') and 99designs#3505 ('it seems to delete the generated files'), both closed without addressing the delete-before-write.

Replace the upfront unlink + truncate-write with an atomic write: render to a temp file in the same directory, then os.Rename into place on success. os.Rename is atomic on the same filesystem, so a reader/build never observes a half-written file, and a mid-generation failure leaves the pre-existing file intact instead of absent. The upfront unlink is removed because the rename replaces the file atomically (no prior delete needed).

Add TestGenerateAtomicWritePreservesOutputOnFailure: a pre-existing generated file survives a mid-generation failure (fails on the old unlink-first code, passes with the fix).
Address maintainer review (99designs#4262) comparing the
atomic write to google/renameio, natefinch/atomic, tailscale/atomicfile
and moby/sys. Two gaps:

1. Permissions regression: os.CreateTemp makes the temp 0o600 and
   os.Rename carries that mode, so every regenerated file silently
   dropped 0644 -> 0600. Now chmod the temp to the existing
   destination's mode (0o644 default for a new file, matching the
   pre-change os.WriteFile mode), so regens don't churn modes.

2. Durability: fsync the temp before the rename so a power loss can't
   expose a zero-length file even when the rename succeeded (per
   ext4's Ts'o). Matches renameio/natefinch/tailscale/moby.

Added unit tests covering mode preservation, the 0o644 default for new
files, no leftover temp files, and that the unchanged-content
short-circuit still preserves mtime for the Go build cache.
@webel-crew

Copy link
Copy Markdown
Author

Thanks — reading those five against this change was the right prompt, and it surfaced two real gaps, both now fixed in ced36a94.

Where this already matched. All five write to a temp in the same directory (so os.Rename is same-filesystem/atomic), rename into place on success, and Remove the temp on every failure path — the natefinch/atomic / tailscale/atomicfile shape, inlined. The old syscall.Unlinkos.WriteFile is gone, so a process interruption (OOM, killed build, panic, mid-chain compile error) leaves the previous file intact instead of absent — the #2345/#3505 mode.

Gap 1 — permissions regression (fixed). os.CreateTemp makes the temp 0o600 and os.Rename carries that mode through, so every regenerated file silently dropped 06440600. renameio (WithExistingPermissions) and natefinch chmod the temp to the destination's existing mode; tailscale/moby chmod to the requested perm. Now chmods the temp to the existing destination's mode, defaulting to 0o644 for a brand-new file (the exact mode os.WriteFile used here before) — so regens don't churn modes.

Gap 2 — fsync before rename (added). renameio, natefinch, tailscale, and moby all Sync() first (renameio quotes Ts'o: without fsync the directory entry can be durable before the data is, so a crash can expose a zero-length file even after a successful rename); relic doesn't. The original PR fixed process interruption — the reported bug — where fsync isn't needed (page-cache bytes survive a killed process). Added fsync anyway for the power-loss case, since it's cheap (a few files per run) and makes the write match the references.

Dep vs inline. Kept inline/dependency-free to match gqlgen's no-file-IO-deps style and the ~20-line scope of the lighter refs (tailscale's is explicitly internal; moby's is heavier than this needs). If maintainers would rather take a vetted dep, google/renameio is the one I'd reach for — happy to switch if you'd prefer that.

Tests. Added TestWriteIsAtomicPreservesPermissionsAndLeavesNoTemp (mode preserved on existing files, 0o644 default for new, no temp left behind) and TestWriteIsAtomicAndUnchangedShortCircuit (mtime-preserving short-circuit still fires, content change still writes atomically); the existing api/codegen/templates suites still pass.

webel-crew and others added 2 commits July 18, 2026 05:51
CI on 99designs#4262 failed on windows + fmt-and-lint, exposing real gaps in the
atomic-write change (all four are genuine, not flaky):

1. test (windows-latest, *): os.Rename over an existing file can fail
   TRANSIENTLY on Windows with "Access is denied" (MoveFileEx contends
   with a virus scanner / search indexer briefly holding an open handle
   on the destination). This broke TWO PRE-EXISTING tests
   (TestTemplateOverride, TestRenderFS) that windows CI was passing
   before this PR. Fixed with a short, bounded retry-on-EPERM loop
   around the rename (renameWithRetry) — the same workaround
   natefinch/atomic and tailscale/atomicfile ship for this exact
   Windows quirk; a no-op on non-windows (the common case still does
   exactly one os.Rename).

2. My own new tests (TestWriteIsAtomicPreservesPermissionsAndLeavesNoTemp,
   TestWriteIsAtomicAndUnchangedShortCircuit) asserted EXACT POSIX mode
   bits (0o644/0o600), which are meaningless on Windows — it has no
   real owner/group/other permissions (os.FileMode there is a coarse
   read-only-attribute emulation). google/renameio's own writefile.go
   is `//go:build !windows` for the same reason. Gated the POSIX-mode
   assertions on runtime.GOOS != "windows"; the platform-agnostic
   leftover-temp-file check still runs everywhere.

3. fmt-and-lint (1.25): three real golangci-lint findings, all mine:
   - api/generate.go: golang.org/x/tools/imports was in the stdlib
     import group instead of its own third-party group (gci).
   - codegen/templates/templates_test.go: writeContent's two string
     params should be combined (gocritic paramTypeCombine).
   - api/generate_test.go: two lines over the repo's 100-char golines
     limit.
   Verified clean against gofumpt, golines --max-len=100, and gci with
   the repo's exact section config (standard/default/prefix).

Verified: go build ./... clean; go test ./codegen/templates/... ./api/...
green; gofumpt/golines/gci all report no changes needed.
test (windows-latest, *) still failed after the rename-retry fix
(ced36a9/b7ddf535) with the same "Access is denied" on TestTemplateOverride
and TestRenderFS specifically - my retry loop only helps a TRANSIENT
external lock (an AV scanner, a search indexer), but this one is
deterministic and self-inflicted: both tests do

    f, err := os.CreateTemp(...)
    defer f.Close()
    Render(Options{..., Filename: f.Name(), ...})

and keep f OPEN (via the deferred Close) while Render's write() tries to
atomically replace f.Name() out from under it. On Windows, file
replacement is a mandatory-locking operation - you cannot rename/replace
a file that this same process still has an open handle on - unlike
POSIX, where renaming over an open file is completely fine (the old
inode just stays alive under the open fd). The pre-change code called
os.WriteFile directly on the open path, which Windows DOES allow
(in-place content overwrite, not a rename), so this never surfaced
before.

Both tests only need f.Name() after creation (TestRenderFS reads the
result back fresh via a separate os.ReadFile, not through the open
handle), so the fix is to close f immediately after CreateTemp, before
calling Render - no change to what either test actually verifies.

Verified: go build ./... clean; go test ./codegen/templates/... ./api/...
green (both tests pass, including the pre-existing "gofmt failed on
gqlgen<N>: ... found 'import'" log line from Render's own template
smoke-test content, which is expected noise, not a failure - both
tests still report PASS around it, same as before this PR).

Separately: fmt-and-lint (1.25)'s check-generate step is panicking with
"unable to find type: <package>.<DifferentTypeEachRun>" while
fmt-and-lint (1.26) and golangci-lint (both versions) are green. This
doesn't look connected to this PR: the panic is inside gqlgen's own
codegen.buildInterface/BuildData type-merging across federation/
testserver fixture packages this PR's diff (codegen/templates/
templates.go's file-write internals) doesn't touch, a DIFFERENT type
fails to resolve on each run (my first commit 6dca944 hit this same
class before any of these template.go changes existed, on the exact
same job), and it's isolated to one Go version's matrix leg. It has
the signature of `go generate ./...` racing itself across gqlgen's many
interdependent testdata packages under Go 1.25 specifically. I don't
have a fix for that (it's outside this PR's diff and I can't reproduce
Windows/the exact runner locally to bisect further) - flagging it for
maintainer visibility rather than guessing at a change to unrelated
code.
@StevenACoffman

StevenACoffman commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

I don't think you understand some of the nuances of the implementations I linked to.
This is the tailscale implementation:

import (
	"fmt"
	"os"
	"path/filepath"
	"runtime"
)
// WriteFile writes data to filename+some suffix, then renames it into filename.
// The perm argument is ignored on Windows, but if the target filename already
// exists then the target file's attributes and ACLs are preserved. If the target
// filename already exists but is not a regular file, WriteFile returns an error.
func WriteFile(filename string, data []byte, perm os.FileMode) (err error) {
	fi, err := os.Stat(filename)
	if err == nil && !fi.Mode().IsRegular() {
		return fmt.Errorf("%s already exists and is not a regular file", filename)
	}
	f, err := os.CreateTemp(filepath.Dir(filename), filepath.Base(filename)+".tmp")
	if err != nil {
		return err
	}
	tmpName := f.Name()
	defer func() {
		if err != nil {
			f.Close()
			os.Remove(tmpName)
		}
	}()
	if _, err := f.Write(data); err != nil {
		return err
	}
	if runtime.GOOS != "windows" {
		if err := f.Chmod(perm); err != nil {
			return err
		}
	}
	if err := f.Sync(); err != nil {
		return err
	}
	if err := f.Close(); err != nil {
		return err
	}
	return Rename(tmpName, filename)
}


// Rename srcFile to dstFile, similar to [os.Rename] but preserving file
// attributes and ACLs on Windows.
func Rename(srcFile, dstFile string) error { return rename(srcFile, dstFile) }

This will write to a temporary file first, then do an atomic rename to the final destination.

The temporary file is created in the same directory as the target, to give the best chance of being able to do an atomic rename. You can’t do an atomic rename across filesystem boundaries; using the same directory ensures both files are on the same filesystem.

To handle concurrent writes, I normally insert a random UUID into the temporary filename, so different processes write to different tempfiles. This is handled automatically by Go’s os.CreateTemp function, which adds a random string to the end of the filename.

The Rename() function has different logic for Windows and non-Windows systems:

  • On non-Windows, it uses os.Rename(). The Go documentation notes that “even within the same directory, on non-Unix platforms Rename is not an atomic operation”.
  • On Windows, it makes a syscall to the ReplaceFileW function. A cursory Internet search is conflicted on whether this is a truly atomic rename, although it concurs that it’s the best option on Windows.

This is the windows file:

package atomicfile

import (
	"os"

	"golang.org/x/sys/windows"
)

func rename(srcFile, destFile string) error {
	// Use replaceFile when possible to preserve the original file's attributes and ACLs.
	if err := replaceFile(destFile, srcFile); err == nil || err != windows.ERROR_FILE_NOT_FOUND {
		return err
	}
	// destFile doesn't exist. Just do a normal rename.
	return os.Rename(srcFile, destFile)
}

func replaceFile(destFile, srcFile string) error {
	destFile16, err := windows.UTF16PtrFromString(destFile)
	if err != nil {
		return err
	}

	srcFile16, err := windows.UTF16PtrFromString(srcFile)
	if err != nil {
		return err
	}

	return replaceFileW(destFile16, srcFile16, nil, 0, nil, nil)
}

Which then calls this fileto make the sys call:

package atomicfile

import (
	"syscall"
	"unsafe"

	"golang.org/x/sys/windows"
)

var _ unsafe.Pointer

// Do the interface allocations only once for common
// Errno values.
const (
	errnoERROR_IO_PENDING = 997
)

var (
	errERROR_IO_PENDING error = syscall.Errno(errnoERROR_IO_PENDING)
	errERROR_EINVAL     error = syscall.EINVAL
)

// errnoErr returns common boxed Errno values, to prevent
// allocations at runtime.
func errnoErr(e syscall.Errno) error {
	switch e {
	case 0:
		return errERROR_EINVAL
	case errnoERROR_IO_PENDING:
		return errERROR_IO_PENDING
	}
	// TODO: add more here, after collecting data on the common
	// error values see on Windows. (perhaps when running
	// all.bat?)
	return e
}

var (
	modkernel32 = windows.NewLazySystemDLL("kernel32.dll")

	procReplaceFileW = modkernel32.NewProc("ReplaceFileW")
)

func replaceFileW(replaced *uint16, replacement *uint16, backup *uint16, flags uint32, exclude unsafe.Pointer, reserved unsafe.Pointer) (err error) {
	r1, _, e1 := syscall.SyscallN(procReplaceFileW.Addr(), uintptr(unsafe.Pointer(replaced)), uintptr(unsafe.Pointer(replacement)), uintptr(unsafe.Pointer(backup)), uintptr(flags), uintptr(exclude), uintptr(reserved))
	if int32(r1) == 0 {
		err = errnoErr(e1)
	}
	return
}

And this is the not windows file:

//go:build !windows

package atomicfile

import (
	"os"
)

func rename(srcFile, destFile string) error {
	return os.Rename(srcFile, destFile)
}

webel-crew and others added 2 commits July 18, 2026 12:14
Addresses @StevenACoffman's feedback on the tailscale/atomicfile
reference (99designs#4262 (comment)):
tailscale's WriteFile explicitly does

    if runtime.GOOS != "windows" {
        f.Chmod(perm)
    }

i.e. permission handling is SKIPPED on Windows, not attempted with a
default value. My prior code called tmp.Chmod(perm) unconditionally on
every platform. That's not a correctness bug (Chmod on Windows just
toggles the read-only attribute off, since it has no real owner/group/
other bits to set to 0644), but it's not the tailscale contract either
- Windows doesn't have the concept these bits are trying to express, so
attempting the call there is a category error even when harmless, and
I'd represented the comparison as more careful than it was. This gates
the whole Chmod call (and the perm-resolution stat) on
runtime.GOOS != "windows", matching tailscale's own code exactly, with
the reasoning written down inline (and a link back to the review
comment) so a future reader isn't left to rediscover why.

Confirmed while re-checking this: os.Rename on Windows already calls
MoveFileEx(MOVEFILE_REPLACE_EXISTING) internally (internal/syscall/
windows.Rename) - the exact same underlying API natefinch/atomic's
ReplaceFile wraps - so it is not a weaker primitive than the
alternatives; what it lacks is retry, which is what renameWithRetry
(the previous commit) already adds. Documented that in the retry
function's comment so the choice not to switch to a raw syscall isn't
left unexplained.

Verified: go build ./... clean; go test ./codegen/templates/...
./api/... green (including TestWriteIsAtomicPreservesPermissionsAndLeavesNoTemp,
TestWriteIsAtomicAndUnchangedShortCircuit, TestTemplateOverride,
TestRenderFS, TestGenerateAtomicWritePreservesOutputOnFailure);
gofmt/go vet/golines/gci all clean.
… unlinking them

Root cause of the check-generate CI failures on this PR, found by running
`go generate ./...` locally: base and master pass, every commit of this
branch failed deterministically (the "different type each run" in CI was
map-iteration order picking which missing type to report first — the
failure itself was 100% reproducible).

The old upfront `syscall.Unlink` of the exec + model outputs was not
redundant — it was LOAD-BEARING for every config that autobinds the
package its models are generated into (all codegen/testserver fixtures,
the federation examples):

  1. old: unlink first → stale models file GONE at schema-load time →
     autobind finds nothing → modelgen generates all types → OK.
  2. this branch (before this commit): stale models file VISIBLE at
     load → autobind binds the previously-generated types as if they
     were user-written models → modelgen SKIPS them → the freshly
     written models file loses them → the exec build reload fails with
     "unable to find type: <pkg>.<Type>".

But the unlink is also exactly what 99designs#2345/99designs#3505 are about: delete-then-
interrupted leaves the user with no generated file at all. Both
properties need to hold, so replace the destructive unlink with a
NON-destructive loader mask:

  - internal/code.Packages gains an Overlay (packages.Config.Overlay)
    threaded into every Load, plus MaskFile/UnmaskFile.
  - api.generate masks each existing output (exec + model) with a
    package-clause-only stub read from the file's own package clause
    (empty bytes would be a parse error and break loading the rest of
    the package). Missing/unparseable files are left alone — nothing
    stale to bind, matching the old unlink's no-op there.
  - config.Config owns the overlay map (MaskGeneratedFile) because
    LoadSchema RECREATES c.Packages mid-generation — an overlay set
    only on the first instance would silently vanish. The map is shared
    by reference with every instance the Config creates.
  - templates.write unmasks each file once its new contents are on disk
    (including the unchanged-content short-circuit path): from that
    moment disk is truth, and the exec build's reload right after
    modelgen must see the just-generated types, not the stub.

Disk state is now only ever changed by the atomic rename — kill
generation at any instant and the previous outputs are intact — while
the loader sees the same "outputs don't exist yet" world the old unlink
provided.

Verified:
  - `go generate ./...` (the exact check-generate CI command): failed
    with 22 "unable to find type" errors before, exits 0 with this
    commit, run twice back-to-back with a clean tree both times
    (regenerated output identical to committed).
  - The deterministic single-package repro (codegen/testserver/
    followschema: `rm -f resolver.go && go run testdata/gqlgen.go
    -config gqlgen.yml -stub stub.go` on a clean tree) fails without
    this commit and passes with it; models-gen.go regenerates at full
    436 lines instead of being emptied to a bare package clause.
  - api, codegen/templates, internal/code, codegen/config suites green;
    gofmt/vet/golines/gci clean.

No standalone regression test is added: a minimal self-autobind fixture
does NOT reproduce (the trigger needs the testserver fixtures' richer
interface/implementor topology), and the check-generate CI step already
runs the full corpus — it is the deterministic gate that caught this.
@webel-crew

Copy link
Copy Markdown
Author

CI is fully green now. The check-generate failures were a bug in this PR:

The upfront syscall.Unlink this PR removed was load-bearing. Every codegen/testserver fixture (and the federation examples) autobinds the same package its models are generated into. With the unlink gone, the stale models-gen.go was still visible at schema-load time, so autobind bound the previously-generated types as if they were user-written models → modelgen skipped regenerating them → the rewritten model file silently lost them (I watched followschema's 436-line models-gen.go collapse to a bare package clause) → the exec build's reload failed with unable to find type. Fully deterministic — base and master pass go generate ./..., every commit of this branch failed; the "different type each run" in CI was just map iteration order choosing which missing type to report first.

The fix (c7cbb9ef) keeps both properties: rather than deleting the outputs (crash-unsafe — the #2345 hole) or leaving them visible (the autobind regression), api.Generate now masks its own existing outputs from the type loader via packages.Config.Overlay — each is overlaid with a package-clause-only stub, and templates.write unmasks per-file the moment the new contents are renamed into place. The loader sees the same "outputs don't exist yet" world the old unlink provided, while disk is only ever touched by the atomic rename — kill the process at any instant and the previous files are intact. Significantly: the stub keeps the real file's package clause (empty overlay contents would be a parse error and poison loading the rest of the package), and the overlay map lives on config.Config because LoadSchema recreates c.Packages mid-run — a mask held only by the first instance would silently vanish.

Verified with go generate ./... run twice back-to-back: both exit 0 and the tree stays clean (regenerated output identical to what's committed), which is exactly what the check-generate gate asserts. The single-package repro (codegen/testserver/followschema: rm -f resolver.go && go run ../../../testdata/gqlgen.go -config gqlgen.yml -stub stub.go on a clean tree) fails without the commit and passes with it.

No standalone regression test in the diff: I built a minimal self-autobind fixture and it does not reproduce — the trigger needs the testserver fixtures' richer interface/implementor topology — and check-generate over the full corpus is already the deterministic gate that caught this.

Earlier in the thread I suggested this looked like a pre-existing flake — that was wrong, based on a local bisect against base/master. Happy to adjust the overlay approach if you'd rather see it shaped differently.

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