From 5943346be2f437a86d621aa2c2b87c7389ead948 Mon Sep 17 00:00:00 2001 From: Elio Neto Date: Wed, 9 Sep 2026 10:56:09 -0300 Subject: [PATCH] fix(go-core): stop the server when the TUI exits or its parent dies go-core-server kept running on Windows after the TUI closed and had to be killed from Task Manager. stopGoCore existed but nothing called it, and Windows never signals a child when its parent exits. Each new TUI then found the orphan on 43001 and started another server on the next port. Two layers: - The spawner registers a once("exit") hook on the host process that calls stopGoCore, and passes GO_CORE_PARENT_PID to the child. - The Go server watches that PID every 2s (OpenProcess/GetExitCodeProcess on Windows, kill(pid, 0) elsewhere) and runs the existing graceful shutdown when the parent is gone, covering crashes and forced kills. Also clears the errcheck and unused findings in cmd/server/main.go. Co-Authored-By: Claude Fable 5.1 --- go-core/cmd/server/main.go | 29 ++++++-- go-core/cmd/server/parent_pid.go | 22 ++++++ go-core/internal/parentwatch/alive_unix.go | 13 ++++ go-core/internal/parentwatch/alive_windows.go | 21 ++++++ go-core/internal/parentwatch/watch.go | 22 ++++++ go-core/internal/parentwatch/watch_test.go | 68 +++++++++++++++++++ packages/core/src/router/go-core.ts | 20 +++++- packages/core/test/router/go-core.test.ts | 30 ++++++++ 8 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 go-core/cmd/server/parent_pid.go create mode 100644 go-core/internal/parentwatch/alive_unix.go create mode 100644 go-core/internal/parentwatch/alive_windows.go create mode 100644 go-core/internal/parentwatch/watch.go create mode 100644 go-core/internal/parentwatch/watch_test.go create mode 100644 packages/core/test/router/go-core.test.ts diff --git a/go-core/cmd/server/main.go b/go-core/cmd/server/main.go index 9bea89b9..dc00ee98 100644 --- a/go-core/cmd/server/main.go +++ b/go-core/cmd/server/main.go @@ -24,6 +24,7 @@ import ( "syscall" "time" + "github.com/ElioNeto/teamcode/go-core/internal/parentwatch" "github.com/ElioNeto/teamcode/go-core/internal/pool" "github.com/ElioNeto/teamcode/go-core/internal/transport" ) @@ -140,13 +141,23 @@ func main() { // Graceful shutdown ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + ctx, parentGone := context.WithCancel(ctx) + defer parentGone() + if parentPid, ok := parentPidFromEnv(); ok { + go parentwatch.Watch(ctx, parentPid, parentPollInterval, func() { + log.Printf("go-core: parent process %d exited, shutting down", parentPid) + parentGone() + }) + } go func() { <-ctx.Done() log.Println("go-core: shutting down...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - server.Shutdown(shutdownCtx) + if err := server.Shutdown(shutdownCtx); err != nil { + log.Printf("go-core: shutdown error: %v", err) + } }() isUnix := transport.IsUnixSocket(resolvedAddr) @@ -162,7 +173,9 @@ func main() { // Cleanup unix socket on exit if isUnix { - os.Remove(resolvedAddr) + if err := os.Remove(resolvedAddr); err != nil && !os.IsNotExist(err) { + log.Printf("go-core: socket cleanup error: %v", err) + } } } @@ -246,12 +259,12 @@ type ErrorResponse struct { Error string `json:"error"` } -var errorPool = pool.Buffer64K - func writeError(w http.ResponseWriter, msg string, code int) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) - fmt.Fprintf(w, `{"error":"%s"}`, msg) + if _, err := fmt.Fprintf(w, `{"error":"%s"}`, msg); err != nil { + log.Printf("go-core: write error response: %v", err) + } } func writeErrorWithCode(w http.ResponseWriter, err error) { @@ -268,6 +281,8 @@ func recordMetrics(_ string, d time.Duration, isError bool) { func handleInfo(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `{"version":"%s","commit":"%s","buildTime":"%s","goos":"%s","goarch":"%s"}`, - Version, Commit, BuildTime, runtime.GOOS, runtime.GOARCH) + if _, err := fmt.Fprintf(w, `{"version":"%s","commit":"%s","buildTime":"%s","goos":"%s","goarch":"%s"}`, + Version, Commit, BuildTime, runtime.GOOS, runtime.GOARCH); err != nil { + log.Printf("go-core: write info response: %v", err) + } } diff --git a/go-core/cmd/server/parent_pid.go b/go-core/cmd/server/parent_pid.go new file mode 100644 index 00000000..9bd03e41 --- /dev/null +++ b/go-core/cmd/server/parent_pid.go @@ -0,0 +1,22 @@ +package main + +import ( + "os" + "strconv" + "time" +) + +const parentPidEnv = "GO_CORE_PARENT_PID" +const parentPollInterval = 2 * time.Second + +func parentPidFromEnv() (int, bool) { + raw := os.Getenv(parentPidEnv) + if raw == "" { + return 0, false + } + pid, err := strconv.Atoi(raw) + if err != nil || pid <= 0 { + return 0, false + } + return pid, true +} diff --git a/go-core/internal/parentwatch/alive_unix.go b/go-core/internal/parentwatch/alive_unix.go new file mode 100644 index 00000000..a1658b2b --- /dev/null +++ b/go-core/internal/parentwatch/alive_unix.go @@ -0,0 +1,13 @@ +//go:build !windows + +package parentwatch + +import ( + "errors" + "syscall" +) + +func Alive(pid int) bool { + err := syscall.Kill(pid, 0) + return err == nil || errors.Is(err, syscall.EPERM) +} diff --git a/go-core/internal/parentwatch/alive_windows.go b/go-core/internal/parentwatch/alive_windows.go new file mode 100644 index 00000000..ecbcd556 --- /dev/null +++ b/go-core/internal/parentwatch/alive_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package parentwatch + +import "syscall" + +const processQueryLimitedInformation = 0x1000 + +func Alive(pid int) bool { + handle, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid)) + if err != nil { + return false + } + defer func() { _ = syscall.CloseHandle(handle) }() + var exitCode uint32 + if err := syscall.GetExitCodeProcess(handle, &exitCode); err != nil { + return false + } + const stillActive = 259 + return exitCode == stillActive +} diff --git a/go-core/internal/parentwatch/watch.go b/go-core/internal/parentwatch/watch.go new file mode 100644 index 00000000..406602f4 --- /dev/null +++ b/go-core/internal/parentwatch/watch.go @@ -0,0 +1,22 @@ +package parentwatch + +import ( + "context" + "time" +) + +func Watch(ctx context.Context, pid int, interval time.Duration, onGone func()) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if !Alive(pid) { + onGone() + return + } + } + } +} diff --git a/go-core/internal/parentwatch/watch_test.go b/go-core/internal/parentwatch/watch_test.go new file mode 100644 index 00000000..bf042e27 --- /dev/null +++ b/go-core/internal/parentwatch/watch_test.go @@ -0,0 +1,68 @@ +package parentwatch + +import ( + "context" + "os" + "os/exec" + "runtime" + "testing" + "time" +) + +func TestAliveReportsOwnProcess(t *testing.T) { + if !Alive(os.Getpid()) { + t.Fatal("own process reported as not alive") + } +} + +func TestAliveReportsExitedChild(t *testing.T) { + pid := exitedChildPid(t) + if Alive(pid) { + t.Fatalf("exited child %d reported as alive", pid) + } +} + +func TestWatchFiresWhenParentGone(t *testing.T) { + pid := exitedChildPid(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + gone := make(chan struct{}) + go Watch(ctx, pid, 10*time.Millisecond, func() { close(gone) }) + + select { + case <-gone: + case <-ctx.Done(): + t.Fatal("watch did not fire for exited parent") + } +} + +func TestWatchStopsOnContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + Watch(ctx, os.Getpid(), 10*time.Millisecond, func() { t.Error("onGone fired for live parent") }) + close(done) + }() + cancel() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("watch did not return after cancel") + } +} + +func exitedChildPid(t *testing.T) int { + t.Helper() + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", "exit 0") + } else { + cmd = exec.Command("true") + } + if err := cmd.Run(); err != nil { + t.Fatalf("spawn helper child: %v", err) + } + return cmd.Process.Pid +} diff --git a/packages/core/src/router/go-core.ts b/packages/core/src/router/go-core.ts index fd4a159c..aa1a8110 100644 --- a/packages/core/src/router/go-core.ts +++ b/packages/core/src/router/go-core.ts @@ -35,6 +35,19 @@ let goCoreProcess: ChildProcess | null = null let goCoreReady = false let goCorePort: string = GO_CORE_PORT +type ExitHost = Pick +const hostsWithExitHook = new WeakSet() + +export function childEnv(base: NodeJS.ProcessEnv, port: string, parentPid: number): NodeJS.ProcessEnv { + return { ...base, GO_CORE_PORT: port, GO_CORE_PARENT_PID: String(parentPid) } +} + +export function installExitHook(host: ExitHost, stop: () => void): void { + if (hostsWithExitHook.has(host)) return + hostsWithExitHook.add(host) + host.once("exit", stop) +} + /** * Find an available port starting from the given base port. * Checks if the health endpoint of the port responds — if another Go core @@ -261,7 +274,7 @@ async function downloadGoCore(): Promise { console.log(`[go-core] installed to ${dest}`) return dest } catch (err) { - console.warn(`[go-core] download failed: ${err}`) + console.warn(`[go-core] download failed: ${String(err)}`) return null } finally { fs.rmSync(tmpDir, { recursive: true, force: true }) @@ -299,9 +312,10 @@ export async function startGoCore(): Promise { const healthUrl = `http://127.0.0.1:${goCorePort}/health` goCoreProcess = spawn(binary, [], { - env: { ...process.env, GO_CORE_PORT: goCorePort }, + env: childEnv(process.env, goCorePort, process.pid), stdio: ["ignore", "pipe", "pipe"], }) + installExitHook(process, stopGoCore) goCoreProcess.on("error", (err) => { console.warn(`[go-core] failed to start: ${err.message}`) @@ -342,7 +356,7 @@ export async function startGoCore(): Promise { stopGoCore() return false } catch (err) { - console.warn(`[go-core] error: ${err}`) + console.warn(`[go-core] error: ${String(err)}`) return false } } diff --git a/packages/core/test/router/go-core.test.ts b/packages/core/test/router/go-core.test.ts new file mode 100644 index 00000000..cac92bae --- /dev/null +++ b/packages/core/test/router/go-core.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" +import { EventEmitter } from "node:events" +import { childEnv, installExitHook } from "../../src/router/go-core" + +describe("router.go-core.childEnv", () => { + test("passes port and parent pid to the child", () => { + const env = childEnv({ PATH: "/bin" }, "43005", 4321) + expect(env).toEqual({ PATH: "/bin", GO_CORE_PORT: "43005", GO_CORE_PARENT_PID: "4321" }) + }) +}) + +describe("router.go-core.installExitHook", () => { + test("stops the child when the host process exits", () => { + const host = new EventEmitter() + let stopped = 0 + installExitHook(host, () => stopped++) + host.emit("exit") + expect(stopped).toBe(1) + }) + + test("registers a single hook per host even when called repeatedly", () => { + const host = new EventEmitter() + let stopped = 0 + installExitHook(host, () => stopped++) + installExitHook(host, () => stopped++) + expect(host.listenerCount("exit")).toBe(1) + host.emit("exit") + expect(stopped).toBe(1) + }) +})