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
.ai/
.gemini
/CODE-FULL.md

View File

@ -1,5 +1,21 @@
# 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)
### 🚀 Features

View File

@ -16,45 +16,62 @@ package main
import (
"context"
"fmt"
"time"
"apigo.cc/go/log"
"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() {
starter.SetInfo("myapp", "1.0.0")
starter.SetAppInfo("myapp", "1.0.0")
starter.OnStart(func(ctx context.Context) {
fmt.Println("Service starting...")
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.")
})
// Register with priority 1
starter.Register("myservice", &MyService{}, 1, 0, 0)
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
- `start`: Start the service in background.
- `stop`: Stop 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.
- `-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
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"
"flag"
"fmt"
"io"
"net"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"apigo.cc/go/cast"
"apigo.cc/go/crypto"
"apigo.cc/go/file"
"apigo.cc/go/id"
"apigo.cc/go/log"
"apigo.cc/go/shell"
"apigo.cc/go/timer"
)
var (
// Default configuration
appName = filepath.Base(os.Args[0])
appVersion = "1.0.0"
pidPath = ".pid"
logPath = ""
stopTimeout = 5 * time.Second
appName = filepath.Base(os.Args[0])
appVersion = "1.0.1"
appUsage = ""
// Internal state
commands = make(map[string]*command)
onStart func(ctx context.Context)
onStop func()
onReload func()
// New Service registry
services = make(map[int][]*managedService)
startedPriorities []int
// Flags
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 {
name string
desc string
@ -43,50 +60,51 @@ type command struct {
}
func init() {
flagSet.StringVar(&pidPath, "pid", ".pid", "PID file path")
flagSet.StringVar(&logPath, "log", "", "Log file path (for background mode)")
AddCommand("start", "Start the service in background", startCmd)
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)
AddCmd("stop", "Stop the service", stopCmd)
AddCmd("restart", "Restart the service", restartCmd)
AddCmd("status", "Show service status", statusCmd)
// Auto-register log writer service with high priority
Register("log-writer", log.WriterService, -100, 0, 0)
}
// SetInfo sets the application name and version.
func SetInfo(name, version string) {
// Register adds a service to be managed by the starter.
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
appVersion = version
}
// SetPidFile sets the default PID file path.
func SetPidFile(path string) {
pidPath = path
// SetInfo is an alias for SetAppInfo.
func SetInfo(name, version string) {
SetAppInfo(name, version)
}
// SetLogFile sets the default log file path.
func SetLogFile(path string) {
logPath = path
// SetUsage sets custom usage text to be displayed in help.
func SetUsage(text string) {
appUsage = text
}
// AddCmd adds a custom command.
func AddCmd(name, desc string, fn func()) {
// AddCommand adds a custom command.
func AddCommand(name, desc string, fn func()) {
commands[name] = &command{name: name, desc: desc, fn: fn}
}
// OnStart sets the function to be called when the service starts.
// The context will be canceled when a stop signal is received.
func OnStart(fn func(ctx context.Context)) {
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
// AddCmd is an alias for AddCommand.
func AddCmd(name, desc string, fn func()) {
AddCommand(name, desc, fn)
}
// Run parses arguments and executes the service.
@ -118,8 +136,11 @@ func Run() {
}
func showHelp() {
fmt.Printf("%s (%s)\n\nUsage:\n %s [command] [options]\n\nCommands:\n",
appName, appVersion, filepath.Base(os.Args[0]))
fmt.Printf("%s (%s)\n\n", appName, appVersion)
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
for cmdName := range commands {
@ -141,22 +162,49 @@ func runForeground() {
savePid(pid)
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())
defer cancel()
// Setup signal handling
sigChan := make(chan os.Signal, 2)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
sigChan := make(chan os.Signal, 10)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2)
if onStart != nil {
go onStart(ctx)
// Start registered services
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 {
if sig == syscall.SIGHUP {
if onReload != nil {
log.DefaultLogger.Info("Received SIGHUP. Reloading...")
onReload()
log.DefaultLogger.Info("Received SIGHUP. Reloading...")
reloadServices()
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
}
@ -165,14 +213,185 @@ func runForeground() {
break
}
cancel() // Trigger context cancellation for onStart
if onStop != nil {
onStop()
}
cancel() // Trigger context cancellation
stopServices()
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() {
pid := loadPid()
if pid > 0 && isProcessRunning(pid) {
@ -180,7 +399,6 @@ func startCmd() {
return
}
// Build arguments for background process
args := []string{}
for i := 1; i < len(os.Args); i++ {
if os.Args[i] != "start" {
@ -189,16 +407,6 @@ func startCmd() {
}
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()
if err != nil {
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))
_ = process.Signal(syscall.SIGTERM)
deadline := time.Now().Add(stopTimeout)
for time.Now().Before(deadline) {
if !isProcessRunning(pid) {
log.DefaultLogger.Info("Stopped OK")
removePid()
return
err := timer.Retry(func() error {
if isProcessRunning(pid) {
return fmt.Errorf("still running")
}
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...")
@ -236,35 +447,89 @@ func stopCmd() {
func restartCmd() {
stopCmd()
time.Sleep(500 * time.Millisecond)
_ = timer.Retry(func() error { return nil }, timer.WithMaxRetries(1), timer.WithBackoff(500*time.Millisecond, 1.0))
startCmd()
}
func statusCmd() {
pid := loadPid()
if pid > 0 && isProcessRunning(pid) {
log.DefaultLogger.Info(fmt.Sprintf("%s is running (PID %d)", appName, pid))
isRunning := pid > 0 && isProcessRunning(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 {
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) {
file.EnsureParentDir(pidPath)
_ = os.WriteFile(pidPath, []byte(strconv.Itoa(p)), 0644)
_ = file.Write(getPidPath(), cast.To[string](p))
}
func loadPid() int {
data, err := os.ReadFile(pidPath)
data, err := file.Read(getPidPath())
if err != nil {
return 0
}
p, _ := strconv.Atoi(strings.TrimSpace(string(data)))
return p
return cast.To[int](data)
}
func removePid() {
_ = os.Remove(pidPath)
_ = os.Remove(getPidPath())
}
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
}