Repository navigation
os: Root.OpenFile returns ENOENT when goroutines concurrently create the same path on macOS #81246
Description
Go version
go1.26.6 darwin/arm64 for the local runs quoted below. Also reproduced on go1.26.7 and go1.27.0, and on darwin/amd64. The CI matrix linked below runs go1.27.0. Not fixed in any of them.
Output of go env in your module/workspace:
AR='ar'
CC='clang'
CGO_CFLAGS='-O2 -g'
CGO_CPPFLAGS=''
CGO_CXXFLAGS='-O2 -g'
CGO_ENABLED='1'
CGO_FFLAGS='-O2 -g'
CGO_LDFLAGS='-O2 -g'
CXX='clang++'
GCCGO='gccgo'
GO111MODULE=''
GOARCH='arm64'
GOARM64='v8.0'
GOAUTH='netrc'
GOBIN='/Users/soph/.local/share/mise/installs/go/1.26.6/bin'
GOCACHE='/Users/soph/Library/Caches/go-build'
GOCACHEPROG=''
GODEBUG=''
GOENV='/Users/soph/Library/Application Support/go/env'
GOEXE=''
GOEXPERIMENT=''
GOFIPS140='off'
GOFLAGS=''
GOGCCFLAGS='-fPIC -arch arm64 -pthread -fno-caret-diagnostics -Qunused-arguments -fmessage-length=0 -ffile-prefix-map=/var/folders/gz/h7sjhvz13cb0gcrzzcncqtyw0000gn/T/go-build2816466184=/tmp/go-build -gno-record-gcc-switches -fno-common'
GOHOSTARCH='arm64'
GOHOSTOS='darwin'
GOINSECURE=''
GOMOD='/tmp/verify6/go.mod'
GOMODCACHE='/Users/soph/go/pkg/mod'
GONOPROXY=''
GONOSUMDB=''
GOOS='darwin'
GOPATH='/Users/soph/go'
GOPRIVATE=''
GOPROXY='https://proxy.golang.org,direct'
GOROOT='/Users/soph/.local/share/mise/installs/go/1.26.6'
GOSUMDB='sum.golang.org'
GOTELEMETRY='local'
GOTELEMETRYDIR='/Users/soph/Library/Application Support/go/telemetry'
GOTMPDIR=''
GOTOOLCHAIN='auto'
GOTOOLDIR='/Users/soph/.local/share/mise/installs/go/1.26.6/pkg/tool/darwin_arm64'
GOVCS=''
GOVERSION='go1.26.6'
GOWORK=''
PKG_CONFIG='pkg-config'What did you do?
Several goroutines call Root.OpenFile with O_CREATE on the same path through one shared *os.Root. The os.Root documentation says:
Methods on Root are safe to be used from multiple goroutines simultaneously.
And O_CREATE without O_EXCL has two correct outcomes: the file was created, or the existing one was opened. So every caller should get a descriptor.
Reproducer, stdlib only:
package main
import (
"fmt"
"os"
"sync"
)
// trial releases n goroutines simultaneously into O_CREATE on one path through
// one shared *os.Root, and reports how many of them failed.
func trial(n int) (failed int, sample error) {
dir, err := os.MkdirTemp("", "rootrace")
if err != nil {
panic(err)
}
defer os.RemoveAll(dir)
root, err := os.OpenRoot(dir)
if err != nil {
panic(err)
}
defer root.Close()
var parked, done sync.WaitGroup
var mu sync.Mutex
start := make(chan struct{})
parked.Add(n)
done.Add(n)
for range n {
go func() {
defer done.Done()
parked.Done()
<-start
f, err := root.OpenFile("f", os.O_RDWR|os.O_CREATE, 0o600)
if err != nil {
mu.Lock()
failed++
if sample == nil {
sample = err
}
mu.Unlock()
return
}
f.Close()
}()
}
parked.Wait() // every goroutine is at the channel before any of them proceeds
close(start)
done.Wait()
return
}
func main() {
const trials, n = 50, 20
failed := 0
var sample error
for range trials {
f, s := trial(n)
failed += f
if sample == nil {
sample = s
}
}
fmt.Printf("%d/%d OpenFile calls failed; sample: %v\n", failed, trials*n, sample)
}What did you see happen?
Most of the calls fail. Three consecutive runs on macOS 26.6 (25G72, M4 Max), go1.26.6:
592/1000 OpenFile calls failed; sample: openat f: no such file or directory
666/1000 OpenFile calls failed; sample: openat f: no such file or directory
615/1000 OpenFile calls failed; sample: openat f: no such file or directory
The error wraps fs.ErrNotExist, which is the error a caller reads as "not there yet".
Variants, 50 trials of 20 goroutines each on the same machine:
| variant | failures |
|---|---|
Root.OpenFile("f", O_CREATE), same path |
692 / 1000 |
Root.OpenFile("d/f", O_CREATE), same path, nested |
574 / 1000 |
Root.OpenFile(...), a distinct path per goroutine |
0 / 1000 |
Root.OpenFile("f", O_CREATE|O_EXCL) |
0 / 1000, correct EEXIST |
Root.Mkdir("d"), same name |
0 / 1000, correct EEXIST |
os.OpenFile(filepath.Join(dir,"f"), O_CREATE) |
0 / 1000 |
Nesting does not matter. A flat single-component name fails as hard as d/f. Creating the parent concurrently does not matter either. The trigger is concurrent O_CREATE on one path through one shared *os.Root.
What did you expect to see?
0/1000. Every caller should get a descriptor, because O_CREATE without O_EXCL means create the file, or open it if someone else created it first. That is what the plain os.OpenFile row does on the same machine, on the same filesystem, in an identically created directory.
ENOENT is not a correct answer for any of these callers. The file exists by the time the error comes back, the caller is the one that asked for it to be created, and the directory holding it is pinned by an open descriptor for the whole call. Apple's own open(2), which covers openat(), lists exactly two ENOENT conditions, and neither of them applies:
[ENOENT] O_CREAT is not set and the named file does not exist.
[ENOENT] A component of the path name that must exist does not exist.
O_CREAT is set, so the first does not apply. In openat(dirfd, "f", O_RDWR|O_CREAT, 0600) the only component is f itself, the file being created, which is precisely the component that is not required to exist, so the second does not either. There is no third ENOENT clause. POSIX is narrower still: with O_CREAT set it permits ENOENT only when a component of the path prefix does not name an existing file.
So the problem is the errno rather than the timing. A caller that reads "no such file or directory" as a benign or absent-file answer gets a wrong answer with no way to tell it apart from a true one.
The rate depends on how much real parallelism the machine has. A 3-core CI runner scores a few percent where a 16-core laptop scores 60% to 79%. A low number is the same bug with fewer chances to land, not a milder one.
Why this reaches os.Root and not plain os
The defect is below Go. It reproduces in plain C with no Go involved. On macOS, openat(dirfd, name, O_CREAT) without O_EXCL returns a spurious ENOENT to most callers racing the first creation of one name.
os.Root resolves path components with openat, which is the point of the type, so it inherits this. Plain os.OpenFile hands an assembled path to open(2) once, so it does not. CGO_ENABLED makes no difference, because darwin routes syscalls through libSystem either way. There is no build-flag workaround.
The isolation is narrow, which is probably the most useful part for diagnosis. On the same machine, racing 20 threads on one name: O_CREAT|O_EXCL, openat with O_CREAT on an existing file, plain reads through openat, and mkdirat, symlinkat, linkat and renameat are all correct. Only the one operation with a create-or-open fallback is wrong, where a create that fails with EEXIST is retried as an open. That fallback is visible in Apple's published source (vn_open_auth in bsd/vfs/vfs_vnops.c), and the same function already retries this exact condition, bounded, in a sibling path.
Scope of the measurements:
- macOS 14.8.7, 15.7.7, 15.7.9, 26.5.2, 26.6, 26.6.1 and 26.6.2.
- Both
arm64andx86_64, on real Intel hardware, not virtualised. - 6% to 9% on 3-core and 4-core CI runners, 66% to 79% on a 16-core laptop.
- Forked processes racing one name fail at the same rate as threads, so this is not a scheduler artifact.
- Linux is unaffected: 0 out of 40,000 calls on both architectures, same source.
Probes, controls and a 7-runner CI matrix: https://github.com/Soph/macos-openat-enoent
Possible fixes
Two options, both of which would sit in the darwin openat path.
-
Retry
ENOENTwhenO_CREATEwas requested, bounded. On the question of how large a bound needs to be, which came up on os:Root.MkdirAllcan return "file exists" when called concurrently on the same path #75114, two data points that may help: xnu's own retry for this condition is bounded at 10 with progressive yielding, and a downstream project that hit this measured retry depth 1 always sufficing, with 0 failures in 14,400 attempts (spiceai/spiceai#13232). -
Split create-or-open into
O_CREATE|O_EXCLfollowed by a plain open, so the broken path is never taken. This is deterministic rather than probabilistic and needs no retry budget. It costs one extra syscall, and only on first creation. In our own codebase this took a lock-file open from 4806 failures in 6000 racing opens to 0, and a flaky test from 12 failures in 20 fresh processes to 0 in 20.
The second is what we used in our own code, for what that is worth. Either looks like it would work, and you will have a better sense than I do of which fits the package.
If a CL would be useful I am happy to write one, for whichever approach you prefer. I have a machine that reproduces this reliably and the CI matrix above, so I can verify a fix rather than only propose one.
One question I would want your view on first: a regression test for this can only be probabilistic, and only on darwin, since it depends on the scheduler leaving the callers overlapping. It might fit better as a stress test than a normal one, but I do not know what you would want there.
Related
- os:
Root.MkdirAllcan return "file exists" when called concurrently on the same path #75114,Root.MkdirAllreturning "file exists" when called concurrently on the same path. Same class, different operation. Accepted and fixed in CL 698215, backported to 1.24 and 1.25. Mentioning it in case it is a useful precedent, since that one was also an OS-level race handled on the Go side. - os: os.Root: use openat2 with RESOLVE_BENEATH on Linux #73077 and os: os.Root: use O_NOFOLLOW_ANY on macOS #73079,
RESOLVE_BENEATHandO_NOFOLLOW_ANY. Current macOS does documentO_RESOLVE_BENEATH, but it would not avoid this, because the flat single-component case still goes throughopenatwithO_CREAT.
A note on the documentation
os.Root already documents a list of platform-specific caveats, and one of them is a race condition:
Root's behavior differs on some platforms:
[...]
- On Unix, Root.Chmod, Root.Chown, and Root.Chtimes are vulnerable to a race condition. If the target of the operation is changed from a regular file to a symlink while the operation is in progress, the operation may be performed on the link rather than the link target.
I mention it because that list looks like the natural home for this if the behaviour cannot be changed. Root.OpenFile with O_CREATE on darwin is not in it today, and the concurrency sentence sits just above it, so a caller on macOS currently has no way to find out.
A fix would help callers more than a doc note would, but which of those makes sense is your call. I will report this to Apple too, since the defect is theirs. The only thing I would add for prioritisation is that an OS fix would reach future macOS versions, while a change in Go would reach people on the versions they are running today.
Disclosure
I used an AI assistant to research and write this up. Every number in it comes from a run on a machine I control or from the CI matrix linked above, the reproducer was compiled and run as it appears here, and the man page and kernel source quotes are from the versions named.
Activity
Filed as golang/go#81246
gabyhelp commentedon Aug 31, 2026
Related Issues
(Emoji vote if this was helpful or unhelpful; more detailed feedback welcome in this discussion.)
Soph commentedon Aug 31, 2026
Filed with Apple as FB24592276, with the C reproduction attached, in case that is useful for tracking. I will update here if I hear anything back.
ianlancetaylor commentedon Aug 31, 2026
CC @neild
Avoid concurrent first creation of shared fuzz files (#1)
Add a redesigned calendar and native Google integration (#300)
fix(sshdoor): survive darwin openat(O_CREAT) race on shared locks; pr…
acoseac commentedon Oct 15, 2026
Still reproduces on macOS 27.0.1 (26A434, xnu-13432.1.9, Apple M3 Pro, 12 cores) with go1.27.1 and go1.26.6.
Setup: 3 goroutines × 300 rounds, a fresh directory per round, one shared
*os.Rootand one shared dirfd. Results:Root.OpenFile("x", O_RDWR|O_CREATE)failed with ENOENT 596–600 of 900 times.unix.Openat(dirfd, "x", O_RDWR|O_CREAT|O_CLOEXEC)failed 597–600 of 900 times.os.OpenFileon the full path failed 0 of 900 times.One immediate retry after the ENOENT succeeded every time, with 3, 8 and 16 goroutines (over 16,000 retries in the logged runs, none failed).
On the mechanism, which the issue leaves open. I ran C variants on the same machine (3 threads × 300 rounds, 900 calls each). They point at the directory descriptor, not at the name, the component count or the filesystem:
openat(dirfd, "x", O_RDWR|O_CREAT|O_CLOEXEC)O_CLOEXEC/ withO_NOFOLLOW/O_RDONLY,O_WRONLYopenat(parentfd, "r/x", ...)openat(dirfd, "/abs/r/x", ...), so the dirfd is ignoredopenat(AT_FDCWD, "/abs/r/x", ...)chdir("r"); open("x", O_RDWR|O_CREAT), the same one-component create in the same directory, started from the cwdopenat(dirfd, "x", ...|O_EXCL)APFS, case-sensitive APFS, HFS+ and exFAT all fail at similar rates.
That matches one specific path in the published xnu-12377.121.6 source:
open1at()setsNAMEI_ATFD(vfs_syscalls.c:5222-5224).namei(),namei_setup_at_dir()puts the dirfd's vnode inni_dvpand setsUSEDVP(vfs_lookup.c:199).namei()drops that reference on return but never clearsUSEDVP.vn_open_auth()doesgoto again(vfs_vnops.c:524-529). By thenni_dvpisNULLVP(asserted at :517), whileUSEDVPsurvives :455.namei()therefore skips the at-dir setup (vfs_lookup.c:549) and starts fromdp = ni_dvp, which isNULLVP(:612). It returns ENOENT from the start-directory check (:625-629) before looking up any name.An absolute path,
AT_FDCWDor the cwd never setUSEDVP, which is why those rows are clean. macOS 14 and 15 (xnu-10063, xnu-11215) reach the same state through the olderopen1at(), which setsni_dvpandUSEDVPitself. That is consistent with your matrix. libSystem'sopenatis a thin wrapper around theSYS_openattrap here, so this is the kernel, not the wrapper. This is a reading of the source, not a kernel trace (the 27 source isn't published), but it predicts every row above.What this means for os.Root. This ENOENT is never information about the name. It is raised only after the kernel's own create of that name has just returned EEXIST, so the file exists. A bounded retry in
rootOpenFileNolog, on darwin, whenflaghasO_CREATEwithoutO_EXCLandopenatreturns ENOENT, is therefore safe: a genuine ENOENT, such as the root directory having been removed, simply recurs. One retry was always enough here. TheO_EXCL-then-open split proposed above avoids the path entirely.Root.CreateandRoot.WriteFilego through the same call.A data point for fixing rather than documenting. We hit this with a daemon's data-dir lock. Each process opens the lock file through an
os.RootwithO_RDWR|O_CREATEand then flocks it. Any error other than "already held" is read as "this filesystem keeps no locks", and the process warns and carries on. Two instances started together on a fresh data directory: the loser got ENOENT and ran unlocked beside the lock holder. A doc note would not have caught that, because ENOENT is exactly the error such code is written to tolerate.Would you take a darwin-only retry (or the O_EXCL split) in
os, or is a caveat in theRootdocs the intended outcome? I'm happy to test a CL on this machine.Disclosure: the analysis and the reproductions were prepared with AI assistance (Anthropic's Claude). Every number above was measured on the machine described.
neild commentedon Oct 15, 2026
If you dig through the mountain of LLM-generated prose above, the summary is: There's a macOS bug which can result in a racing
openatwithO_CREATreturning ENOTENT rather than successfully opening an existing file.acoseac commentedon Oct 15, 2026
Thanks, that's the right summary, and sorry for the length. It's filed with Apple as FB25197270. Until it's fixed, we retry an
O_CREATopen that returns ENOENT; the retry succeeds in our runs.fix(sshdoor): survive darwin openat(O_CREAT) race on shared locks; pr…