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
29 changes: 22 additions & 7 deletions go-core/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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)
}
}
22 changes: 22 additions & 0 deletions go-core/cmd/server/parent_pid.go
Original file line number Diff line number Diff line change
@@ -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
}
13 changes: 13 additions & 0 deletions go-core/internal/parentwatch/alive_unix.go
Original file line number Diff line number Diff line change
@@ -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)
}
21 changes: 21 additions & 0 deletions go-core/internal/parentwatch/alive_windows.go
Original file line number Diff line number Diff line change
@@ -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
}
22 changes: 22 additions & 0 deletions go-core/internal/parentwatch/watch.go
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
68 changes: 68 additions & 0 deletions go-core/internal/parentwatch/watch_test.go
Original file line number Diff line number Diff line change
@@ -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
}
20 changes: 17 additions & 3 deletions packages/core/src/router/go-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ let goCoreProcess: ChildProcess | null = null
let goCoreReady = false
let goCorePort: string = GO_CORE_PORT

type ExitHost = Pick<NodeJS.EventEmitter, "once">
const hostsWithExitHook = new WeakSet<ExitHost>()

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
Expand Down Expand Up @@ -261,7 +274,7 @@ async function downloadGoCore(): Promise<string | null> {
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 })
Expand Down Expand Up @@ -299,9 +312,10 @@ export async function startGoCore(): Promise<boolean> {
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}`)
Expand Down Expand Up @@ -342,7 +356,7 @@ export async function startGoCore(): Promise<boolean> {
stopGoCore()
return false
} catch (err) {
console.warn(`[go-core] error: ${err}`)
console.warn(`[go-core] error: ${String(err)}`)
return false
}
}
Expand Down
30 changes: 30 additions & 0 deletions packages/core/test/router/go-core.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading