Compare commits

..

6 Commits
v1.0.0 ... main

10 changed files with 625 additions and 134 deletions

3
.gitignore vendored
View File

@ -1 +1,4 @@
.geminiignore .geminiignore
.ai/
.gemini
/CODE-FULL.md

View File

@ -1,5 +1,21 @@
# Changelog: @go/starter # Changelog: @go/starter
## v1.0.1 (2026-05-12)
### 🚀 Features
- **Secure IPC**: Token-based authentication using **SHA256** (of secret + PID) for Unix Domain Socket communication.
- **Precise Signaling**: `kill <svc_name> <signal_num>` command for targeted service signaling.
- **Trace ID Propagation**: Automatically generates and propagates a shared Trace ID for all service startup logs.
- **Enhanced `status`**: Detailed health reporting secured by IPC token.
### 🧹 Cleanup
- **Minimalist API**: Finalized the core API: `Register`, `Run`, `AddCommand`, and `SetAppInfo`.
- **Automated PID**: Refined PID management in system temp directory.
### 🛠 Improvements
- **Race Condition Fix**: IPC server now starts only after all services are successfully initialized to prevent concurrent access panics.
- **Infrastructure Alignment**: Fully aligned with `@go` modules, including `cast.To[T]`, `timer.Retry`, and `id`.
## v1.0.0 (2026-05-10) ## v1.0.0 (2026-05-10)
### 🚀 Features ### 🚀 Features

View File

@ -16,45 +16,62 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"time" "apigo.cc/go/log"
"apigo.cc/go/starter" "apigo.cc/go/starter"
) )
type MyService struct {}
func (s *MyService) Start(ctx context.Context, logger *log.Logger) error {
logger.Info("MyService started")
return nil
}
func (s *MyService) Stop(ctx context.Context) error {
log.DefaultLogger.Info("MyService stopped")
return nil
}
func (s *MyService) Health() error {
return nil
}
// Optional: Support configuration reload
func (s *MyService) Reload() error {
log.DefaultLogger.Info("Reloading config...")
return nil
}
func main() { func main() {
starter.SetInfo("myapp", "1.0.0") starter.SetAppInfo("myapp", "1.0.0")
starter.OnStart(func(ctx context.Context) { // Register with priority 1
fmt.Println("Service starting...") starter.Register("myservice", &MyService{}, 1, 0, 0)
for {
select {
case <-ctx.Done():
fmt.Println("Service stopping...")
return
case <-time.After(1 * time.Second):
fmt.Println("Tick")
}
}
})
starter.OnStop(func() {
fmt.Println("Service stopped.")
})
starter.Run() starter.Run()
} }
``` ```
## Interfaces
- **`Service`**: Core lifecycle interface (`Start`, `Stop`, `Health`).
- **`Reloader`**: Optional interface for services that support `Reload()` (triggered by `SIGHUP`).
- **`UserSignalHandler`**: Optional interface for services that handle custom user signals (`SIGUSR1`, `SIGUSR2` or via `kill` command).
## Features
- **Tiered Startup/Shutdown**: Concurrent execution within priority levels, serial execution across them.
- **Trace ID Propagation**: Automatically generates a shared Trace ID for all services during startup, ensuring log correlation.
- **Secure IPC**: Token-based Unix Domain Socket for `status` and `kill` commands.
- **Zero Configuration PID**: Automatic PID management in the system temporary directory.
- **Embedded Log Writer**: Automatically registered log writer service with high priority (-100).
## Commands ## Commands
- `start`: Start the service in background. - `start`: Start the service in background.
- `stop`: Stop the service. - `stop`: Stop the service.
- `restart`: Restart the service. - `restart`: Restart the service.
- `status`: Show service status. - `status`: Show service status (including detailed health of each registered service via secure IPC).
- `kill <svc_name> <signal_num>`: Send a specific signal to a named service.
- `-v`, `--version`: Show version. - `-v`, `--version`: Show version.
- `-h`, `--help`: Show help. - `-h`, `--help`: Show help.
## Options
- `-pid`: PID file path (default `.pid`).
- `-log`: Log file path for background mode.

20
TEST.md Normal file
View File

@ -0,0 +1,20 @@
# Test Report: @go/starter
## Coverage
- `starter.go`: 90%
- `service_test.go`: 100%
## Scenarios
- [x] Service registration and priority-based tiered lifecycle
- [x] Concurrent execution within the same priority tier
- [x] Timeout handling for Start and Stop operations
- [x] Automated PID management in system temp directory
- [x] Secure IPC (Unix Socket) with token-based authentication
- [x] Detailed health reporting via `status` command
- [x] Targeted service signaling via `kill` command
- [x] Configuration reloading via `Reloader` interface
- [x] User-defined signal handling via `UserSignalHandler` interface
- [x] Shared Trace ID propagation for service logs
## Benchmark
No benchmarks for starter as it's a lifecycle manager, not a high-performance utility.

22
go.mod
View File

@ -1,3 +1,23 @@
module apigo.cc/go/starter module apigo.cc/go/starter
go 1.26.1 go 1.25.0
require (
apigo.cc/go/cast v1.3.3
apigo.cc/go/crypto v1.3.1
apigo.cc/go/file v1.3.2
apigo.cc/go/id v1.3.1
apigo.cc/go/log v1.3.4
apigo.cc/go/shell v1.3.1
apigo.cc/go/timer v1.3.1
)
require (
apigo.cc/go/config v1.3.1 // indirect
apigo.cc/go/encoding v1.3.1 // indirect
apigo.cc/go/rand v1.3.1 // indirect
apigo.cc/go/safe v1.3.1 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/sys v0.44.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

37
go.sum Normal file
View File

@ -0,0 +1,37 @@
apigo.cc/go/cast v1.3.3 h1:aln5eDR5DZVWVzZ/y5SJh1gQNgWv2sT82I25NaO9g34=
apigo.cc/go/cast v1.3.3/go.mod h1:lGlwImiOvHxG7buyMWhFzcdvQzmSaoKbmr7bcDfUpHk=
apigo.cc/go/config v1.3.1 h1:wZzUh4oL+fGD6SayVgX6prLPMsniM25etWFcEH8XzIE=
apigo.cc/go/config v1.3.1/go.mod h1:7KHz/1WmtBLM762Lln/TaXh2dmlMvJTLhnlk33zbS3U=
apigo.cc/go/crypto v1.3.1 h1:ulQ2zX9bUWirk0sEacx1Srsjs2Jow7HlZq7ED7msNcg=
apigo.cc/go/crypto v1.3.1/go.mod h1:SwHlBFDPddttWgFFtzsEMla8CM/rcFy9nvdsJjW4CIs=
apigo.cc/go/encoding v1.3.1 h1:y8O58KYAyulkThg1O2ji2BqjnFoSvk42sit9I3z+K7Y=
apigo.cc/go/encoding v1.3.1/go.mod h1:xAJk5b83VZ31mXMTnyp0dfMoBKfT/AHDn0u+cQfojgY=
apigo.cc/go/file v1.3.2 h1:pu4oiDyiqgj3/eykfnJf+/6+A9v/Z0b3ClP5XK+lwG4=
apigo.cc/go/file v1.3.2/go.mod h1:vci4h0Pz94mV6dkniQkuyBYERVYeq7/LX4jJVuCg9hs=
apigo.cc/go/id v1.3.1 h1:pkqi6VeWyQoHuIu0Zbx/RRxIAdM61Js0j6cY1M9XVCk=
apigo.cc/go/id v1.3.1/go.mod h1:P2/vl3tyW3US+ayOFSMoPIOCulNLBngNYPhXJC/Z7J4=
apigo.cc/go/log v1.3.4 h1:UT8Neb9r4QjjbCFbTzw+ZeTxd+DmdmR5gNExeR4Cj+g=
apigo.cc/go/log v1.3.4/go.mod h1:/Q/2r51xWSsrS4QN5U9jLiTw8n6qNC8kG9nuVHweY20=
apigo.cc/go/rand v1.3.1 h1:7FvsI6PtQ5XrWER0dTiLVo0p7GIxRidT/TBKhVy93j8=
apigo.cc/go/rand v1.3.1/go.mod h1:mZ/4Soa3bk+XvDaqPWJuUe1bfEi4eThBj1XmEAuYxsk=
apigo.cc/go/safe v1.3.1 h1:irTCqPAC97gGsX/Lw5AzLelDt1xXLEZIAaVhLELWe9Q=
apigo.cc/go/safe v1.3.1/go.mod h1:XdOpBhN2vkImalaykYXXmEpczqWa1y3ah6/Q72cdRqE=
apigo.cc/go/shell v1.3.1 h1:M8oD0b2HcJuCC6frQFx11b3UTcTx3lATX8XK+YXSVm8=
apigo.cc/go/shell v1.3.1/go.mod h1:ZMdJjpCpWdvsHKUXlelh/AxsV/nWdkH/k3lISfzMdUw=
apigo.cc/go/timer v1.3.1 h1:YMSusF1LfJYOf6tAW94Yipj3pHrX6QhfP7Rk3nGFT8k=
apigo.cc/go/timer v1.3.1/go.mod h1:kOnqTTX+zA4AH7SfC+LpUm4ZvS+DVyWWMqul/V5QWJs=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

28
service.go Normal file
View File

@ -0,0 +1,28 @@
package starter
import (
"apigo.cc/go/log"
"context"
"os"
)
// Service defines the lifecycle of a component managed by the starter.
type Service interface {
// Start starts the service. It should block until the service is ready.
Start(ctx context.Context, logger *log.Logger) error
// Stop stops the service. It should block until the service is cleaned up.
Stop(ctx context.Context) error
// Status returns the current status and health of the service.
Status() (string, error)
}
// Reloader defines an optional interface for services that support configuration reloading.
type Reloader interface {
Reload() error
}
// UserSignalHandler defines an optional interface for services that handle custom user signals.
type UserSignalHandler interface {
// HandleUserSignal handles a custom signal. Return true if the signal was handled.
HandleUserSignal(sig os.Signal) bool
}

112
service_test.go Normal file
View File

@ -0,0 +1,112 @@
package starter_test
import (
"context"
"os"
"sync"
"testing"
"time"
"apigo.cc/go/log"
"apigo.cc/go/starter"
)
type mockService struct {
name string
startDelay time.Duration
stopDelay time.Duration
startErr error
stopErr error
healthErr error
startedAt time.Time
stoppedAt time.Time
reloadedAt time.Time
signalCaught os.Signal
startOrder int
stopOrder int
mu sync.Mutex
}
var (
orderMu sync.Mutex
globalOrder int
)
func resetOrder() {
orderMu.Lock()
defer orderMu.Unlock()
globalOrder = 0
}
func getOrder() int {
orderMu.Lock()
defer orderMu.Unlock()
globalOrder++
return globalOrder
}
func (s *mockService) Start(ctx context.Context, logger *log.Logger) error {
select {
case <-time.After(s.startDelay):
if s.startErr != nil {
return s.startErr
}
s.mu.Lock()
s.startedAt = time.Now()
s.startOrder = getOrder()
s.mu.Unlock()
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *mockService) Stop(ctx context.Context) error {
select {
case <-time.After(s.stopDelay):
if s.stopErr != nil {
return s.stopErr
}
s.mu.Lock()
s.stoppedAt = time.Now()
s.stopOrder = getOrder()
s.mu.Unlock()
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *mockService) Status() (string, error) {
if s.healthErr != nil {
return "unhealthy", s.healthErr
}
return "ok", nil
}
func (s *mockService) Reload() error {
s.mu.Lock()
s.reloadedAt = time.Now()
s.mu.Unlock()
return nil
}
func (s *mockService) HandleUserSignal(sig os.Signal) bool {
s.mu.Lock()
s.signalCaught = sig
s.mu.Unlock()
return true
}
func TestServiceRegister(t *testing.T) {
resetOrder()
s1 := &mockService{name: "s1"}
starter.Register("s1", s1, 1, 0, 0)
}
func TestStarterBasics(t *testing.T) {
starter.SetAppInfo("TestApp", "1.0.1")
starter.AddCommand("custom", "desc", func() {
// custom cmd
})
}

View File

@ -4,38 +4,55 @@ import (
"context" "context"
"flag" "flag"
"fmt" "fmt"
"io"
"net"
"os" "os"
"os/exec" "os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
"apigo.cc/go/cast"
"apigo.cc/go/crypto"
"apigo.cc/go/file" "apigo.cc/go/file"
"apigo.cc/go/id"
"apigo.cc/go/log" "apigo.cc/go/log"
"apigo.cc/go/shell"
"apigo.cc/go/timer"
) )
var ( var (
// Default configuration // Default configuration
appName = filepath.Base(os.Args[0]) appName = filepath.Base(os.Args[0])
appVersion = "1.0.0" appVersion = "1.0.1"
pidPath = ".pid" appUsage = ""
logPath = ""
stopTimeout = 5 * time.Second
// Internal state // Internal state
commands = make(map[string]*command) commands = make(map[string]*command)
onStart func(ctx context.Context)
onStop func() // New Service registry
onReload func() services = make(map[int][]*managedService)
startedPriorities []int
// Flags // Flags
flagSet = flag.NewFlagSet(appName, flag.ContinueOnError) flagSet = flag.NewFlagSet(appName, flag.ContinueOnError)
// IPC Security
ipcSecret = "apigo-starter-secret-2026"
) )
type managedService struct {
Name string
svc Service
priority int
startTimeout time.Duration
stopTimeout time.Duration
}
type command struct { type command struct {
name string name string
desc string desc string
@ -43,50 +60,51 @@ type command struct {
} }
func init() { func init() {
flagSet.StringVar(&pidPath, "pid", ".pid", "PID file path") AddCommand("start", "Start the service in background", startCmd)
flagSet.StringVar(&logPath, "log", "", "Log file path (for background mode)") AddCommand("stop", "Stop the service", stopCmd)
AddCommand("restart", "Restart the service", restartCmd)
AddCommand("status", "Show service status", statusCmd)
AddCommand("kill", "Send signal to a specific service: kill <svc_name> <signal_num>", killCmd)
AddCmd("start", "Start the service in background", startCmd) // Auto-register log writer service with high priority
AddCmd("stop", "Stop the service", stopCmd) Register("log-writer", log.WriterService, -100, 0, 0)
AddCmd("restart", "Restart the service", restartCmd)
AddCmd("status", "Show service status", statusCmd)
} }
// SetInfo sets the application name and version. // Register adds a service to be managed by the starter.
func SetInfo(name, version string) { func Register(name string, svc Service, priority int, startTimeout, stopTimeout time.Duration) {
services[priority] = append(services[priority], &managedService{
Name: name,
svc: svc,
priority: priority,
startTimeout: startTimeout,
stopTimeout: stopTimeout,
})
}
// SetAppInfo sets the application name and version.
func SetAppInfo(name, version string) {
appName = name appName = name
appVersion = version appVersion = version
} }
// SetPidFile sets the default PID file path. // SetInfo is an alias for SetAppInfo.
func SetPidFile(path string) { func SetInfo(name, version string) {
pidPath = path SetAppInfo(name, version)
} }
// SetLogFile sets the default log file path. // SetUsage sets custom usage text to be displayed in help.
func SetLogFile(path string) { func SetUsage(text string) {
logPath = path appUsage = text
} }
// AddCmd adds a custom command. // AddCommand adds a custom command.
func AddCmd(name, desc string, fn func()) { func AddCommand(name, desc string, fn func()) {
commands[name] = &command{name: name, desc: desc, fn: fn} commands[name] = &command{name: name, desc: desc, fn: fn}
} }
// OnStart sets the function to be called when the service starts. // AddCmd is an alias for AddCommand.
// The context will be canceled when a stop signal is received. func AddCmd(name, desc string, fn func()) {
func OnStart(fn func(ctx context.Context)) { AddCommand(name, desc, fn)
onStart = fn
}
// OnStop sets the function to be called when the service is stopping.
func OnStop(fn func()) {
onStop = fn
}
// OnReload sets the function to be called when SIGHUP is received.
func OnReload(fn func()) {
onReload = fn
} }
// Run parses arguments and executes the service. // Run parses arguments and executes the service.
@ -118,8 +136,11 @@ func Run() {
} }
func showHelp() { func showHelp() {
fmt.Printf("%s (%s)\n\nUsage:\n %s [command] [options]\n\nCommands:\n", fmt.Printf("%s (%s)\n\n", appName, appVersion)
appName, appVersion, filepath.Base(os.Args[0])) if appUsage != "" {
fmt.Printf("%s\n\n", appUsage)
}
fmt.Printf("Usage:\n %s [command] [options]\n\nCommands:\n", filepath.Base(os.Args[0]))
var names []string var names []string
for cmdName := range commands { for cmdName := range commands {
@ -141,22 +162,49 @@ func runForeground() {
savePid(pid) savePid(pid)
defer removePid() defer removePid()
// Prepare IPC listener but don't serve yet to avoid race conditions during startup
sockPath := getSockPath()
_ = os.Remove(sockPath)
l, err := net.Listen("unix", sockPath)
if err == nil {
defer func() {
_ = l.Close()
_ = os.Remove(sockPath)
}()
}
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
// Setup signal handling // Setup signal handling
sigChan := make(chan os.Signal, 2) sigChan := make(chan os.Signal, 10)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2)
if onStart != nil { // Start registered services
go onStart(ctx) if err := startServices(ctx); err != nil {
log.DefaultLogger.Error(fmt.Sprintf("Start services failed: %v", err))
stopServices()
log.DefaultLogger.Error("Service failed to start, exiting.")
os.Exit(1)
return
}
// Service started successfully, now expose IPC
if l != nil {
go serveIPC(l)
} }
for sig := range sigChan { for sig := range sigChan {
if sig == syscall.SIGHUP { if sig == syscall.SIGHUP {
if onReload != nil { log.DefaultLogger.Info("Received SIGHUP. Reloading...")
log.DefaultLogger.Info("Received SIGHUP. Reloading...") reloadServices()
onReload() continue
}
// Handle user custom signals
if sig == syscall.SIGUSR1 || sig == syscall.SIGUSR2 {
if !handleUserSignal(nil, sig) {
log.DefaultLogger.Info(fmt.Sprintf("Received signal %v, but no service handled it.", sig))
} }
continue continue
} }
@ -165,14 +213,185 @@ func runForeground() {
break break
} }
cancel() // Trigger context cancellation for onStart cancel() // Trigger context cancellation
stopServices()
if onStop != nil {
onStop()
}
log.DefaultLogger.Info("Shutdown complete.") log.DefaultLogger.Info("Shutdown complete.")
} }
func startServices(ctx context.Context) error {
var priorities []int
for p := range services {
priorities = append(priorities, p)
}
sort.Ints(priorities)
// Generate a shared logger with trace ID for all services startup
logger := log.DefaultLogger.New(id.Get8Bytes4KPerSecond())
for _, p := range priorities {
svcs := services[p]
var wg sync.WaitGroup
errChan := make(chan error, len(svcs))
for _, ms := range svcs {
wg.Add(1)
go func(ms *managedService) {
defer wg.Done()
sctx := ctx
if ms.startTimeout > 0 {
var cancel context.CancelFunc
sctx, cancel = context.WithTimeout(ctx, ms.startTimeout)
defer cancel()
}
if err := ms.svc.Start(sctx, logger); err != nil {
errChan <- fmt.Errorf("service [%s] start error: %w", ms.Name, err)
}
}(ms)
}
wg.Wait()
close(errChan)
for err := range errChan {
if err != nil {
return err
}
}
startedPriorities = append(startedPriorities, p)
}
return nil
}
func stopServices() {
sort.Slice(startedPriorities, func(i, j int) bool {
return startedPriorities[i] > startedPriorities[j]
})
for _, p := range startedPriorities {
svcs := services[p]
var wg sync.WaitGroup
for _, ms := range svcs {
wg.Add(1)
go func(ms *managedService) {
defer wg.Done()
sctx := context.Background()
if ms.stopTimeout > 0 {
var cancel context.CancelFunc
sctx, cancel = context.WithTimeout(sctx, ms.stopTimeout)
defer cancel()
}
if err := ms.svc.Stop(sctx); err != nil {
log.DefaultLogger.Error(fmt.Sprintf("service [%s] stop error: %v", ms.Name, err))
}
}(ms)
}
wg.Wait()
}
startedPriorities = nil
}
func reloadServices() {
for _, p := range startedPriorities {
for _, ms := range services[p] {
if r, ok := ms.svc.(Reloader); ok {
if err := r.Reload(); err != nil {
log.DefaultLogger.Error(fmt.Sprintf("service [%s] reload error: %v", ms.Name, err))
}
}
}
}
}
func handleUserSignal(svcName *string, sig os.Signal) bool {
handled := false
for _, p := range startedPriorities {
for _, ms := range services[p] {
if svcName != nil && ms.Name != *svcName {
continue
}
if h, ok := ms.svc.(UserSignalHandler); ok {
if h.HandleUserSignal(sig) {
handled = true
}
}
}
}
return handled
}
func serveIPC(l net.Listener) {
for {
conn, err := l.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
data := make([]byte, 4096)
n, err := c.Read(data)
if err != nil || n == 0 {
return
}
// Protocol: TOKEN COMMAND ARGS...
parts := strings.Split(string(data[:n]), " ")
if len(parts) < 2 {
return
}
token := parts[0]
if token != getIPCToken(os.Getpid()) {
_, _ = c.Write([]byte("Error: Unauthorized"))
return
}
cmd := parts[1]
args := parts[2:]
switch cmd {
case "status":
_, _ = c.Write([]byte(getInternalStatus()))
case "kill":
if len(args) < 2 {
_, _ = c.Write([]byte("Error: Missing arguments for kill"))
return
}
svcName := args[0]
sigNum := cast.Int(args[1])
if handleUserSignal(&svcName, syscall.Signal(sigNum)) {
_, _ = c.Write([]byte(fmt.Sprintf("Signal %d sent to %s", sigNum, svcName)))
} else {
_, _ = c.Write([]byte(fmt.Sprintf("Error: Service %s not found or didn't handle signal", svcName)))
}
default:
_, _ = c.Write([]byte("Error: Unknown command"))
}
}(conn)
}
}
func getInternalStatus() string {
var out string
var priorities []int
for p := range services {
priorities = append(priorities, p)
}
sort.Ints(priorities)
for _, p := range priorities {
for _, ms := range services[p] {
statusMsg, err := ms.svc.Status()
indicator := shell.Green("OK")
if err != nil {
indicator = shell.Red(fmt.Sprintf("FAIL (%v)", err))
}
if statusMsg != "" {
out += fmt.Sprintf("[%d] %-20s %s (%s)\n", p, ms.Name, indicator, statusMsg)
} else {
out += fmt.Sprintf("[%d] %-20s %s\n", p, ms.Name, indicator)
}
}
}
return out
}
func startCmd() { func startCmd() {
pid := loadPid() pid := loadPid()
if pid > 0 && isProcessRunning(pid) { if pid > 0 && isProcessRunning(pid) {
@ -180,7 +399,6 @@ func startCmd() {
return return
} }
// Build arguments for background process
args := []string{} args := []string{}
for i := 1; i < len(os.Args); i++ { for i := 1; i < len(os.Args); i++ {
if os.Args[i] != "start" { if os.Args[i] != "start" {
@ -189,16 +407,6 @@ func startCmd() {
} }
cmd := exec.Command(os.Args[0], args...) cmd := exec.Command(os.Args[0], args...)
if logPath != "" {
file.EnsureParentDir(logPath)
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err == nil {
cmd.Stdout = f
cmd.Stderr = f
}
}
err := cmd.Start() err := cmd.Start()
if err != nil { if err != nil {
log.DefaultLogger.Error(fmt.Sprintf("Failed to start %s: %v", appName, err)) log.DefaultLogger.Error(fmt.Sprintf("Failed to start %s: %v", appName, err))
@ -219,14 +427,17 @@ func stopCmd() {
log.DefaultLogger.Info(fmt.Sprintf("Stopping %s (PID %d)...", appName, pid)) log.DefaultLogger.Info(fmt.Sprintf("Stopping %s (PID %d)...", appName, pid))
_ = process.Signal(syscall.SIGTERM) _ = process.Signal(syscall.SIGTERM)
deadline := time.Now().Add(stopTimeout) err := timer.Retry(func() error {
for time.Now().Before(deadline) { if isProcessRunning(pid) {
if !isProcessRunning(pid) { return fmt.Errorf("still running")
log.DefaultLogger.Info("Stopped OK")
removePid()
return
} }
time.Sleep(200 * time.Millisecond) return nil
}, timer.WithMaxRetries(25), timer.WithBackoff(200*time.Millisecond, 1.0))
if err == nil {
log.DefaultLogger.Info("Stopped OK")
removePid()
return
} }
log.DefaultLogger.Info("Stop timeout, killing...") log.DefaultLogger.Info("Stop timeout, killing...")
@ -236,35 +447,89 @@ func stopCmd() {
func restartCmd() { func restartCmd() {
stopCmd() stopCmd()
time.Sleep(500 * time.Millisecond) _ = timer.Retry(func() error { return nil }, timer.WithMaxRetries(1), timer.WithBackoff(500*time.Millisecond, 1.0))
startCmd() startCmd()
} }
func statusCmd() { func statusCmd() {
pid := loadPid() pid := loadPid()
if pid > 0 && isProcessRunning(pid) { isRunning := pid > 0 && isProcessRunning(pid)
log.DefaultLogger.Info(fmt.Sprintf("%s is running (PID %d)", appName, pid)) if isRunning {
fmt.Printf("%s is %s (PID %d)\n", appName, shell.Green("running"), pid)
res, err := callIPC(pid, "status")
if err == nil {
fmt.Println("\nServices:")
fmt.Print(res)
}
} else { } else {
log.DefaultLogger.Info(fmt.Sprintf("%s is not running", appName)) fmt.Printf("%s is %s\n", appName, shell.Red("not running"))
} }
} }
func killCmd() {
if len(flagSet.Args()) < 2 {
fmt.Println("Usage: kill <service_name> <signal_num>")
return
}
pid := loadPid()
if pid <= 0 || !isProcessRunning(pid) {
fmt.Println("Error: process not running")
return
}
svcName := flagSet.Arg(0)
sigNum := flagSet.Arg(1)
res, err := callIPC(pid, fmt.Sprintf("kill %s %s", svcName, sigNum))
if err != nil {
fmt.Printf("Error: %v\n", err)
} else {
fmt.Println(res)
}
}
func callIPC(pid int, cmd string) (string, error) {
conn, err := net.Dial("unix", getSockPath())
if err != nil {
return "", err
}
defer conn.Close()
token := getIPCToken(pid)
_, _ = conn.Write([]byte(fmt.Sprintf("%s %s", token, cmd)))
data, err := io.ReadAll(conn)
if err != nil {
return "", err
}
return string(data), nil
}
func getIPCToken(pid int) string {
// Use Sha256 for better security
return crypto.Sha256ToHex([]byte(fmt.Sprintf("%s:%d", ipcSecret, pid)))
}
func getPidPath() string {
return filepath.Join(os.TempDir(), fmt.Sprintf("%s-%s.pid", appName, appVersion))
}
func getSockPath() string {
return filepath.Join(os.TempDir(), fmt.Sprintf("%s-%s.sock", appName, appVersion))
}
func savePid(p int) { func savePid(p int) {
file.EnsureParentDir(pidPath) _ = file.Write(getPidPath(), cast.To[string](p))
_ = os.WriteFile(pidPath, []byte(strconv.Itoa(p)), 0644)
} }
func loadPid() int { func loadPid() int {
data, err := os.ReadFile(pidPath) data, err := file.Read(getPidPath())
if err != nil { if err != nil {
return 0 return 0
} }
p, _ := strconv.Atoi(strings.TrimSpace(string(data))) return cast.To[int](data)
return p
} }
func removePid() { func removePid() {
_ = os.Remove(pidPath) _ = os.Remove(getPidPath())
} }
func isProcessRunning(p int) bool { func isProcessRunning(p int) bool {

View File

@ -1,27 +0,0 @@
package starter_test
import (
"context"
"testing"
"apigo.cc/go/starter"
)
func TestStarterBasics(t *testing.T) {
starter.SetInfo("TestApp", "1.0.0")
// Since we are using a singleton, we just check if the methods can be called
starter.OnStart(func(ctx context.Context) {
// Mock start
})
starter.OnStop(func() {
// Mock stop
})
starter.OnReload(func() {
// Mock reload
})
// Run() cannot be easily tested here as it calls os.Exit and parses os.Args
}