Go version
go version go1.26.0 windows/amd64
Output of go env in your module/workspace:
set AR=ar
set CC=gcc
set CGO_CFLAGS=-O2 -g
set CGO_CPPFLAGS=
set CGO_CXXFLAGS=-O2 -g
set CGO_ENABLED=0
set CGO_FFLAGS=-O2 -g
set CGO_LDFLAGS=-O2 -g
set CXX=g++
set GCCGO=gccgo
set GO111MODULE=
set GOAMD64=v1
set GOARCH=amd64
set GOAUTH=netrc
set GOBIN=
set GOCACHE=C:\Users\me\AppData\Local\go-build
set GOCACHEPROG=
set GODEBUG=
set GOENV=C:\Users\me\AppData\Roaming\go\env
set GOEXE=.exe
set GOEXPERIMENT=
set GOFIPS140=off
set GOFLAGS=
set GOGCCFLAGS=-m64 -fno-caret-diagnostics -Qunused-arguments -Wl,--no-gc-sections -fmessage-length=0 -ffile-prefix-map=C:\Users\me\AppData\Local\Temp\go-build=/tmp/go-build -gno-record-gcc-switches
set GOHOSTARCH=amd64
set GOHOSTOS=windows
set GOINSECURE=
set GOMOD=NUL
set GOMODCACHE=C:\Users\me\go\pkg\mod
set GONOPROXY=
set GONOSUMDB=
set GOOS=windows
set GOPATH=C:\Users\me\go
set GOPRIVATE=
set GOPROXY=https://proxy.golang.org,direct
set GOROOT=C:\Program Files\Go
set GOSUMDB=sum.golang.org
set GOTELEMETRY=local
set GOTELEMETRYDIR=C:\Users\me\AppData\Roaming\go\telemetry
set GOTMPDIR=
set GOTOOLCHAIN=auto
set GOTOOLDIR=C:\Program Files\Go\pkg\tool\windows_amd64
set GOVCS=
set GOVERSION=go1.26.0
set GOWORK=
set PKG_CONFIG=pkg-config
What did you do?
On Windows, a parent process starts two children that share an inherited, overlapped named-pipe handle as stdout. One child is a Go program; the other is a non-Go program that writes to the pipe with a synchronous WriteFile. The launcher here is OpenSSH Server, but anything that hands children an inherited overlapped pipe as stdout will do.
The Go program does nothing itself. A blank import of os is enough: os package initialization builds os.Stdout via os.NewFile before main runs. (Any import that pulls in os, e.g. fmt, triggers it too.)
test.go
package main
import (
_ "os"
)
func main() {
}
writefile.cpp, a synchronous writer to STD_OUTPUT_HANDLE. It writes the same buffer twice. The payload is larger than the pipe buffer (0x1142 bytes > 4096) so each write takes the STATUS_PENDING path rather than completing inline:
#include <windows.h>
#include <stdio.h>
char buffer[0x1142];
int main()
{
DWORD dw;
memset(buffer, 'X', sizeof(buffer)-2);
buffer[sizeof(buffer)-2] = '\n';
buffer[sizeof(buffer)-1] = 0;
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
// do this 2 times (hangs in the 2nd WriteFile())
if (!WriteFile(h, buffer, sizeof(buffer), &dw, NULL)) {
fprintf(stderr, "ERROR:WriteFile:%u\n", GetLastError());
return 1;
}
fprintf(stderr, "INFO: 1st WriteFile() completed\n");
if (!WriteFile(h, buffer, sizeof(buffer), &dw, NULL)) {
fprintf(stderr, "ERROR:WriteFile:%u\n", GetLastError());
return 1;
}
fprintf(stderr, "INFO: 2nd WriteFile() completed\n");
return 0;
}
repro.bat runs the Go program first (so os init associates the inherited stdout pipe with the runtime completion port), then the C writer on that same handle:
Save the three files to a local path (e.g. C:\repro), build both (gcc writefile.cpp -o writefile.exe; go build test.go), then run them under the SSH server so they inherit the same overlapped stdout pipe. Use an absolute path; the sshd session's logon context resolves relative paths against its own home dir and may not see mapped network drives:
ssh localhost C:\repro\repro.bat
Note, OpenSSH Server has to be running locally. On a stock Windows box it ships as an optional feature and starts out stopped:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 # if not already present
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic
What did you see happen?
The sibling (non-Go) process's synchronous WriteFile never completes. It blocks in NtWaitForSingleObject inside WriteFile, waiting on a completion event that never fires. The Go process is the cause, not the victim; it does not itself hang. writefile.exe prints its first status line and hangs before the second:
INFO: 1st WriteFile() completed
<hangs; the 2nd WriteFile never returns>
Rebuilding only test.exe with an older toolchain, keeping the C writer and run steps identical, makes it go away, so the Go version is the only variable:
test.exe built with |
1st WriteFile |
2nd WriteFile |
result |
| go1.24.6 |
completes |
completes |
no hang |
| go1.26.0 |
completes |
never returns |
sibling hangs |
(Confirmed the toolchain baked into each binary with go version -m test.exe.)
This needs no special code to trigger. os initialization runs NewFile on the inherited stdout/stderr before main, so any Go binary that imports os, directly or transitively (e.g. via fmt), sets it off. Any launcher that shares an overlapped pipe across its children, such as a CI runner, build orchestrator, or SSH-based test harness, can end up with a non-Go process's writes silently hanging.
What did you expect to see?
The sibling's synchronous WriteFile completes normally, as it does under go1.24 and earlier. os.NewFile on an inherited handle should adopt it passively and not change the completion behavior of a handle shared with another process.
Analysis
The regression bisects to the overlapped-NewFile work in go1.25 (CL 662236, commit 7e60bdd, closing #19098). Under this change os.NewFile associates an inherited overlapped stdout/stderr pipe with the runtime's I/O completion handling before main runs. As part of this it calls the WINAPI SetFileCompletionNotificationModes() on the handle. That call alters the behavior of the underlying kernel file object, which is shared by every process that inherited the handle, not just the Go program's instance. After it runs, a sibling process's synchronous WriteFile on that same handle no longer completes. Removing that association for the inherited handle stops the hang.
This looks related to #76391 (NewFile blocking on a busy handle), but that is about a Go process's own I/O; here the Go process is fine and a separate process's write on a shared inherited handle hangs.
We have a local candidate fix that extends the existing Stdin exemption in os/file_windows.go to Stdout/Stderr:
// See go.dev/issue/75949 and go.dev/issue/76391.
if kind == kindNewFile && h != syscall.Stdin && h != syscall.Stdout && h != syscall.Stderr {
nonBlocking, _ = windows.IsNonblock(h)
}
It resolves the reproducer and passes our validation testing.
Go version
go version go1.26.0 windows/amd64
Output of
go envin your module/workspace:What did you do?
On Windows, a parent process starts two children that share an inherited, overlapped named-pipe handle as stdout. One child is a Go program; the other is a non-Go program that writes to the pipe with a synchronous WriteFile. The launcher here is OpenSSH Server, but anything that hands children an inherited overlapped pipe as stdout will do.
The Go program does nothing itself. A blank import of os is enough: os package initialization builds os.Stdout via
os.NewFilebefore main runs. (Any import that pulls in os, e.g. fmt, triggers it too.)test.gowritefile.cpp, a synchronous writer to STD_OUTPUT_HANDLE. It writes the same buffer twice. The payload is larger than the pipe buffer (0x1142 bytes > 4096) so each write takes the STATUS_PENDING path rather than completing inline:repro.batruns the Go program first (so os init associates the inherited stdout pipe with the runtime completion port), then the C writer on that same handle:Save the three files to a local path (e.g. C:\repro), build both (gcc writefile.cpp -o writefile.exe; go build test.go), then run them under the SSH server so they inherit the same overlapped stdout pipe. Use an absolute path; the sshd session's logon context resolves relative paths against its own home dir and may not see mapped network drives:
ssh localhost C:\repro\repro.batNote, OpenSSH Server has to be running locally. On a stock Windows box it ships as an optional feature and starts out stopped:
What did you see happen?
The sibling (non-Go) process's synchronous WriteFile never completes. It blocks in NtWaitForSingleObject inside WriteFile, waiting on a completion event that never fires. The Go process is the cause, not the victim; it does not itself hang. writefile.exe prints its first status line and hangs before the second:
Rebuilding only test.exe with an older toolchain, keeping the C writer and run steps identical, makes it go away, so the Go version is the only variable:
test.exebuilt withWriteFileWriteFile(Confirmed the toolchain baked into each binary with
go version -m test.exe.)This needs no special code to trigger. os initialization runs NewFile on the inherited stdout/stderr before main, so any Go binary that imports
os, directly or transitively (e.g. viafmt), sets it off. Any launcher that shares an overlapped pipe across its children, such as a CI runner, build orchestrator, or SSH-based test harness, can end up with a non-Go process's writes silently hanging.What did you expect to see?
The sibling's synchronous WriteFile completes normally, as it does under go1.24 and earlier.
os.NewFileon an inherited handle should adopt it passively and not change the completion behavior of a handle shared with another process.Analysis
The regression bisects to the overlapped-NewFile work in go1.25 (CL 662236, commit 7e60bdd, closing #19098). Under this change
os.NewFileassociates an inherited overlapped stdout/stderr pipe with the runtime's I/O completion handling before main runs. As part of this it calls the WINAPISetFileCompletionNotificationModes()on the handle. That call alters the behavior of the underlying kernel file object, which is shared by every process that inherited the handle, not just the Go program's instance. After it runs, a sibling process's synchronous WriteFile on that same handle no longer completes. Removing that association for the inherited handle stops the hang.This looks related to #76391 (NewFile blocking on a busy handle), but that is about a Go process's own I/O; here the Go process is fine and a separate process's write on a shared inherited handle hangs.
We have a local candidate fix that extends the existing Stdin exemption in os/file_windows.go to Stdout/Stderr:
It resolves the reproducer and passes our validation testing.