Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/actions/setup-deps/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,9 @@ runs:
# the ARM64 runner. The x64 runner keeps CLANG64 as the LLGo host even
# when R11 activates a separate llvm-mingw 386 target afterwards.
msystem: ${{ inputs.windows-arch == 'arm64' && 'CLANGARM64' || 'CLANG64' }}
# ARM64 images initialize MSYS2 before the job starts. Reuse that
# installation instead of bootstrapping an x64 base under emulation.
release: ${{ runner.arch != 'ARM64' }}
path-type: inherit
update: true
install: pacman-contrib
Expand Down
9 changes: 8 additions & 1 deletion .github/scripts/test_ci_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
import json
import os
from pathlib import Path
import runpy
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest import mock

import yaml

Expand Down Expand Up @@ -79,7 +81,12 @@ def test_intel_macos_runs_both_native_debuggers(self):
self.assertIn("test/debug/runtime/runtest.sh -v", commands)
self.assertIn("^TestGDBIntegration$", commands)
self.assertNotIn("^TestGDBCompleteWorkerUnwind$", commands)
self.assertIn("exec sudo -n", commands)
self.assertIn("test/debug/runtime/gdb_darwin.py", commands)
self.assertNotIn("sudo", commands)
helper = runpy.run_path(str(WORKFLOWS.parents[1] / "test/debug/runtime/gdb_darwin.py"))
with mock.patch("os.execvp", side_effect=SystemExit) as execute, self.assertRaises(SystemExit):
helper["main"]("/native/gdb", ["--version"])
execute.assert_called_once_with("sudo", ["sudo", "-n", "/native/gdb", "--version"])
self.assertNotIn("continue-on-error", job)
self.assertTrue(all("continue-on-error" not in step for step in job["steps"]))

Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/llgo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -614,12 +614,12 @@ jobs:
shell: bash
run: |
set -euo pipefail
# Unsigned Homebrew GDB cannot obtain the inferior's task port as
# the runner user. Elevate only the debugger, not builds or Go tests.
# This does not change host trust settings or pretend Apple Silicon
# has the native Darwin process backend.
# GDB's Darwin fork/exec can block in wait4 at _dyld_start. Spawn
# suspended, then use native attach with the same test assertions.
# Elevate only GDB to obtain task ports on the ephemeral runner.
wrapper="$RUNNER_TEMP/llgo-gdb-native"
printf '#!/bin/bash\nexec sudo -n %q "$@"\n' "$LLGO_GDB" > "$wrapper"
printf '#!/bin/bash\nexec python3 %q %q "$@"\n' \
"$GITHUB_WORKSPACE/test/debug/runtime/gdb_darwin.py" "$LLGO_GDB" > "$wrapper"
chmod +x "$wrapper"
"$wrapper" --batch --nx -ex 'python import gdb; print(gdb.VERSION)'
echo "LLGO_GDB=$wrapper" >> "$GITHUB_ENV"
Expand Down
19 changes: 16 additions & 3 deletions .github/workflows/test_windows_runtime.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,22 @@ foreach ($artifact in @($runtime, $stdlib, $ffi, $empty, $coreFault, $network))
}

Write-Host "==> windows-runtime-smoke.exe"
& $runtime
if ($LASTEXITCODE -ne 0) {
throw "windows-runtime-smoke.exe exited with code $LASTEXITCODE"
$runtimeStdout = Join-Path $out "runtime.stdout"
$runtimeStderr = Join-Path $out "runtime.stderr"
$runtimeProcess = Start-Process -FilePath $runtime -NoNewWindow -PassThru `
-RedirectStandardOutput $runtimeStdout -RedirectStandardError $runtimeStderr
if (-not $runtimeProcess.WaitForExit(60000)) {
$runtimeProcess.Kill()
throw "windows-runtime-smoke.exe did not terminate after main returned"
}
$runtimeProcess.WaitForExit()
Get-Content $runtimeStdout
Get-Content $runtimeStderr
if ($runtimeProcess.ExitCode -ne 0) {
throw "windows-runtime-smoke.exe exited with code $($runtimeProcess.ExitCode)"
}
if (-not (Get-Content -Raw $runtimeStdout).Contains("windows C stdio smoke: ok")) {
throw "normal Windows main return lost buffered C stdio output"
}

Write-Host "==> windows-runtime-smoke.exe (unrecovered fault)"
Expand Down
16 changes: 11 additions & 5 deletions internal/build/main_module.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,13 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g
if cfg.rtInit || wasmRuntimeScheduler {
rtInit = declareNoArgFunc(mainPkg, rtPkgPath+".init")
}
var processExit llssa.Function
var processExit, stdioFlush llssa.Function
if ctx.buildConf.Goos == "windows" {
processExit = declareRuntimeExit(mainPkg, "runtime.exit")
stdioFlush = mainPkg.NewFunc("fflush", newSignature(
[]types.Type{types.Typ[types.UnsafePointer]},
[]types.Type{types.Typ[types.Int32]},
), llssa.InC)
}

var abiInit llssa.Function
Expand Down Expand Up @@ -223,6 +227,7 @@ func genMainModule(ctx *context, rtPkgPath string, pkg *packages.Package, cfg *g
pyFinalize: pyFinalize,
rtInit: rtInit,
processExit: processExit,
stdioFlush: stdioFlush,
coverageExit: coverageExit,
abiInit: abiInit,
packageInits: packageInits,
Expand Down Expand Up @@ -449,6 +454,7 @@ type entryFunctions struct {
pyFinalize llssa.Function
rtInit llssa.Function
processExit llssa.Function
stdioFlush llssa.Function
coverageExit llssa.Function
abiInit llssa.Function
packageInits []llssa.Function
Expand Down Expand Up @@ -549,10 +555,10 @@ func emitRuntimeMainBody(b llssa.Builder, fns entryFunctions) {
b.Call(fns.coverageExit.Expr, b.Prog.IntVal(0, b.Prog.Int()))
}
if fns.processExit != nil {
// Go terminates the process as soon as main returns, regardless of
// other goroutines. On Windows, call runtime.exit (ExitProcess) rather
// than returning through CRT teardown, which may wait on runtime-owned
// threads and violates that guarantee.
// Preserve normal C-main stdio flushing before immediate Go termination.
// Flush while the other threads can still release their stream locks;
// DLL_PROCESS_DETACH after killing them cannot safely do that cleanup.
b.Call(fns.stdioFlush.Expr, b.Prog.Nil(b.Prog.VoidPtr()))
b.Call(fns.processExit.Expr, b.Prog.IntVal(0, b.Prog.Int32()))
}
}
Expand Down
3 changes: 2 additions & 1 deletion internal/build/main_module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ func TestGenMainModuleWindowsExitsAfterMain(t *testing.T) {
ir := mod.LPkg.String()
assertInOrder(t, ir,
`call void @"example.com/foo.main"()`,
`call i32 @fflush(ptr null)`,
`call void @runtime.exit(i32 0)`,
)
}
Expand Down Expand Up @@ -131,7 +132,7 @@ func TestGenMainModuleCoverageExit(t *testing.T) {
`call void @runtime.runCoverageExitHook(i64 0)`,
}
if goos == "windows" {
order = append(order, `call void @runtime.exit(i32 0)`)
order = append(order, `call i32 @fflush(ptr null)`, `call void @runtime.exit(i32 0)`)
}
assertInOrder(t, ir, order...)
}
Expand Down
3 changes: 3 additions & 0 deletions runtime/_test/windowsruntime/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"time"
"unsafe"

c "github.com/xgo-dev/llgo/runtime/internal/clite"
nativesync "github.com/xgo-dev/llgo/runtime/internal/sync"
// The smoke package lives below the LLGo runtime root, whose packages are
// excluded from the ordinary need-runtime signal. Import the core runtime
Expand Down Expand Up @@ -366,4 +367,6 @@ func main() {
}()
<-lingeringStarted
println("windows runtime smoke: ok")
// The parent redirects stdout, so this remains buffered until main returns.
c.Printf(c.Str("windows C stdio smoke: ok\n"))
}
10 changes: 4 additions & 6 deletions runtime/internal/runtime/os_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ import (
"unsafe"

c "github.com/xgo-dev/llgo/runtime/internal/clite"
cliteos "github.com/xgo-dev/llgo/runtime/internal/clite/os"
psync "github.com/xgo-dev/llgo/runtime/internal/sync"
"github.com/xgo-dev/llgo/runtime/internal/sync/atomic"
"github.com/xgo-dev/llgo/runtime/internal/thread"
Expand All @@ -40,22 +39,21 @@ type mOS struct {
// processExiting is non-zero after runtime.exit or syscall.Exit starts
// terminating the process. It serves the same purpose as exiting in the Go
// runtime. The thread backend separately records the same transition before
// Windows starts running FLS process-shutdown callbacks.
// native process shutdown.
var processExiting uint32

// processExitLock is used only to freeze a thread until ExitProcess terminates
// it. Its zero value is a ready-to-use Windows SRW lock.
var processExitLock psync.Mutex

// ExitProcess marks the runtime as exiting before asking Windows to terminate
// all process threads. Keep every Go-facing process exit path behind this
// ExitProcess marks the runtime as exiting before immediate native process
// termination. Keep every Go-facing process exit path behind this
// helper so newosproc can distinguish shutdown from a real resource failure.
//
//go:nosplit
func ExitProcess(code uint32) {
atomic.Store(&processExiting, 1)
thread.BeginProcessExit()
cliteos.ExitProcess(code)
thread.ExitProcess(code)
}

//go:linkname runtime_exit runtime.exit
Expand Down
17 changes: 16 additions & 1 deletion runtime/internal/thread/_wrap/thread_windows.c
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ __declspec(dllimport) llgo_handle LLGO_WINAPI CreateThread(
void *attributes, llgo_size_t stack_size, llgo_win_thread_start start,
void *arg, llgo_dword flags, llgo_dword *thread_id);
__declspec(dllimport) void LLGO_WINAPI ExitThread(llgo_dword exit_code);
__declspec(dllimport) void LLGO_WINAPI ExitProcess(llgo_dword exit_code);
__declspec(dllimport) llgo_handle LLGO_WINAPI GetCurrentProcess(void);
__declspec(dllimport) llgo_bool LLGO_WINAPI
TerminateProcess(llgo_handle process, llgo_uint exit_code);
__declspec(dllimport) llgo_bool LLGO_WINAPI CloseHandle(llgo_handle handle);
__declspec(dllimport) llgo_dword LLGO_WINAPI GetLastError(void);
__declspec(dllimport) llgo_handle LLGO_WINAPI GetProcessHeap(void);
Expand Down Expand Up @@ -59,9 +63,20 @@ enum {
* deliberately retained sidecar allocation. */
static llgo_dword llgo_process_exiting;

void llgo_win_thread_begin_process_exit(void)
void llgo_win_thread_exit_process(llgo_dword exit_code)
{
__atomic_store_n(&llgo_process_exiting, 1, __ATOMIC_RELEASE);
/* Go process exit must not wait for other goroutines or DLL callbacks.
* ExitProcess kills the other threads before DLL_PROCESS_DETACH; a DLL
* cleanup callback can then wait on a lock held by a killed thread. Native
* gcgort stress gets stuck inside LdrShutdownProcess. Self-termination
* skips that unsafe teardown and does not return.
* See Microsoft's ExitProcess documentation on shutdown with unknown
* thread state. Go exit hooks run before this call. */
TerminateProcess(GetCurrentProcess(), (llgo_uint)exit_code);
/* The current-process pseudo-handle has termination rights. Retain the
* normal exit path only if self-termination unexpectedly fails. */
ExitProcess(exit_code);
}

typedef void *(*llgo_thread_routine)(void *arg);
Expand Down
12 changes: 6 additions & 6 deletions runtime/internal/thread/thread_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ func createDetached(stackSize uintptr, routine RoutineFunc, arg c.Pointer) c.Int
//go:linkname exit C.llgo_win_thread_exit
func exit()

//go:linkname beginProcessExit C.llgo_win_thread_begin_process_exit
func beginProcessExit()
//go:linkname exitProcess C.llgo_win_thread_exit_process
func exitProcess(code c.Uint)

//go:linkname keyCreate C.llgo_win_fls_create
func keyCreate(index *c.Uint) c.Int
Expand All @@ -71,10 +71,10 @@ func Exit() {
exit()
}

// BeginProcessExit prevents Windows process shutdown from invoking Go or
// BDWGC through the FLS lifecycle callback.
func BeginProcessExit() {
beginProcessExit()
// ExitProcess terminates the current process without DLL or FLS teardown.
// Such callbacks can deadlock after Windows has killed their lock owners.
func ExitProcess(code uint32) {
exitProcess(c.Uint(code))
}

func (key *Key) Create(destructor KeyDestructor) c.Int {
Expand Down
6 changes: 4 additions & 2 deletions test/debug/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ modules exclude these programs
from root-module `go test ./test/...` and `llgo test ./test/...` enumeration.
The LLGo workflow invokes the LLDB suite in its native platform jobs and the
GDB suite in its Linux and all six Windows ABI/architecture jobs. A separate
Intel macOS job exercises both debuggers; its GDB wrapper uses the ephemeral
runner's passwordless sudo permission to obtain native task ports.
Intel macOS job exercises both debuggers; its GDB wrapper starts the executable
suspended with `posix_spawn`, then attaches natively using the ephemeral runner's
passwordless sudo permission to obtain task ports. This avoids GDB's Darwin
fork/exec startup hang while preserving the complete values/registry gate.
The Windows debugger is installed separately from the pinned compiler and
LLDB dependencies. Apple Silicon has no native GDB process target: use LLDB
there; GDB remote debugging remains available. Unit tests and package-specific `testdata` remain
Expand Down
10 changes: 7 additions & 3 deletions test/debug/runtime/gdb.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ Darwin GDB uses debugger-local Mach ports in its native thread identifiers.
The adapter asks the host kernel for each port's system thread ID so that it
can match the runtime registry. Remote target identifiers are not passed to
host Mach APIs. Intel macOS native launch additionally needs a debugger with
permission to obtain task ports. The dedicated Intel CI job runs only GDB
through an explicit `sudo -n` wrapper on its ephemeral runner; compilation and
Go test orchestration remain unprivileged. It also runs the full native LLDB
permission to obtain task ports. The dedicated Intel CI job launches the executable
suspended with Darwin's `posix_spawn`, then uses GDB's native attach path. This
avoids GDB 17's fork/exec hang in `decode_message`/`wait4` before `_dyld_start`
executes. The helper preserves GDB's normal disabled-ASLR layout and all
acceptance assertions, and reaps the inferior after GDB finishes. Only GDB runs
through `sudo -n` on the ephemeral runner; compilation and Go test orchestration
remain unprivileged. It also runs the full native LLDB
suite using Apple's signed `/usr/bin/lldb`. Apple Silicon GDB native process
debugging is not supported; the native acceptance command reports that limitation directly.
`llgo gdb` still supports cross-target/remote sessions on Apple Silicon.
80 changes: 80 additions & 0 deletions test/debug/runtime/gdb_darwin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright (c) 2026 The XGo Authors (xgo.dev). All rights reserved.
# Licensed under the Apache License, Version 2.0.

"""Launch native Darwin GDB acceptance through a suspended posix_spawn.

GDB 17's own fork/exec path can block in decode_message's wait4 while the
inferior is stopped at _dyld_start. Its attach path avoids that handshake.
The inferior, native GDB transport, breakpoints and assertions stay the same.
"""

import ctypes
import os
import signal
import subprocess
import sys


def spawn_suspended(executable):
system = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
attr_pointer = ctypes.POINTER(ctypes.c_void_p)
strings = ctypes.POINTER(ctypes.c_char_p)
for name in ("posix_spawnattr_init", "posix_spawnattr_destroy"):
getattr(system, name).argtypes = [attr_pointer]
system.posix_spawnattr_setflags.argtypes = [attr_pointer, ctypes.c_short]
system.posix_spawn.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_char_p,
ctypes.c_void_p, attr_pointer, strings, strings]

def check(error):
if error:
raise OSError(error, os.strerror(error))

attributes = ctypes.c_void_p()
check(system.posix_spawnattr_init(ctypes.byref(attributes)))
try:
# START_SUSPENDED (sys/spawn.h) plus DISABLE_ASLR, matching GDB's
# normal Darwin launch. GDB 17 cannot relocate dyld 17 images.
check(system.posix_spawnattr_setflags(ctypes.byref(attributes), 0x0180))
argv = (ctypes.c_char_p * 2)(os.fsencode(executable), None)
values = [os.fsencode(key + "=" + value)
for key, value in os.environ.items()]
environ = (ctypes.c_char_p * (len(values) + 1))(*values, None)
pid = ctypes.c_int()
check(system.posix_spawn(ctypes.byref(pid), argv[0], None,
ctypes.byref(attributes), argv, environ))
return pid.value
finally:
check(system.posix_spawnattr_destroy(ctypes.byref(attributes)))


def main(gdb, args):
launches = [i for i in range(1, len(args))
if args[i - 1] == "-ex" and args[i] == "run"]
command = ["sudo", "-n", gdb]
if not launches:
# Version/Python probes and the non-running C fallback fixtures.
os.execvp(command[0], command + args)
if len(launches) != 1 or "--batch" not in args:
raise ValueError("expected one batch acceptance launch")
index = args.index("--batch") + 1
executable = args[index]
pid = spawn_suspended(executable)
try:
args[launches[0]] = "continue"
# Clear the BSD stop after GDB has installed its Mach suspension.
# The program remains stopped until the original first run command.
args[index + 1:index + 1] = [
"-ex", "attach " + str(pid),
"-ex", "python import os, signal; os.kill(" + str(pid) + ", signal.SIGCONT)",
]
return subprocess.call(command + args)
finally:
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
os.waitpid(pid, 0)


if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2:]))
Loading
Loading