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) + }) +})