fix(ax): align imports, tests, and usage docs
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
1b4efe0f67
commit
e9bb6a8968
24 changed files with 269 additions and 235 deletions
|
|
@ -6,7 +6,7 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRingBuffer(t *testing.T) {
|
||||
func TestRingBuffer_Good(t *testing.T) {
|
||||
t.Run("write and read", func(t *testing.T) {
|
||||
rb := NewRingBuffer(10)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -96,7 +94,7 @@ func (d *Daemon) Start() error {
|
|||
// Auto-register if registry is set
|
||||
if d.opts.Registry != nil {
|
||||
entry := d.opts.RegistryEntry
|
||||
entry.PID = os.Getpid()
|
||||
entry.PID = currentPID()
|
||||
if d.health != nil {
|
||||
entry.Health = d.health.Addr()
|
||||
}
|
||||
|
|
@ -144,7 +142,7 @@ func (d *Daemon) Stop() error {
|
|||
}
|
||||
|
||||
if d.pid != nil {
|
||||
if err := d.pid.Release(); err != nil && !os.IsNotExist(err) {
|
||||
if err := d.pid.Release(); err != nil && !isNotExist(err) {
|
||||
errs = append(errs, coreerr.E("Daemon.Stop", "pid file", err))
|
||||
}
|
||||
}
|
||||
|
|
@ -157,7 +155,7 @@ func (d *Daemon) Stop() error {
|
|||
d.running = false
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
return coreerr.Join(errs...)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDaemon_StartAndStop(t *testing.T) {
|
||||
func TestDaemon_Lifecycle_Good(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
|
||||
d := NewDaemon(DaemonOptions{
|
||||
|
|
@ -36,7 +36,7 @@ func TestDaemon_StartAndStop(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDaemon_DoubleStartFails(t *testing.T) {
|
||||
func TestDaemon_Start_Bad_AlreadyRunning(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
|
@ -50,7 +50,7 @@ func TestDaemon_DoubleStartFails(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "already running")
|
||||
}
|
||||
|
||||
func TestDaemon_RunWithoutStartFails(t *testing.T) {
|
||||
func TestDaemon_Run_Bad_NotStarted(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -61,7 +61,7 @@ func TestDaemon_RunWithoutStartFails(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "not started")
|
||||
}
|
||||
|
||||
func TestDaemon_SetReady(t *testing.T) {
|
||||
func TestDaemon_SetReady_Good(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
|
@ -83,17 +83,17 @@ func TestDaemon_SetReady(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestDaemon_NoHealthAddrReturnsEmpty(t *testing.T) {
|
||||
func TestDaemon_HealthAddr_Good_EmptyWhenDisabled(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Empty(t, d.HealthAddr())
|
||||
}
|
||||
|
||||
func TestDaemon_DefaultShutdownTimeout(t *testing.T) {
|
||||
func TestDaemon_ShutdownTimeout_Good_Default(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
|
||||
}
|
||||
|
||||
func TestDaemon_RunBlocksUntilCancelled(t *testing.T) {
|
||||
func TestDaemon_Run_Good_BlocksUntilCancelled(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{
|
||||
HealthAddr: "127.0.0.1:0",
|
||||
})
|
||||
|
|
@ -126,7 +126,7 @@ func TestDaemon_RunBlocksUntilCancelled(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDaemon_StopIdempotent(t *testing.T) {
|
||||
func TestDaemon_Stop_Good_Idempotent(t *testing.T) {
|
||||
d := NewDaemon(DaemonOptions{})
|
||||
|
||||
// Stop without Start should be a no-op
|
||||
|
|
@ -134,7 +134,7 @@ func TestDaemon_StopIdempotent(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDaemon_AutoRegisters(t *testing.T) {
|
||||
func TestDaemon_Registry_Good_AutoRegisters(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(filepath.Join(dir, "daemons"))
|
||||
|
||||
|
|
|
|||
3
exec/doc.go
Normal file
3
exec/doc.go
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Package exec provides a small command wrapper around `os/exec` with
|
||||
// structured logging hooks.
|
||||
package exec
|
||||
16
exec/exec.go
16
exec/exec.go
|
|
@ -12,7 +12,7 @@ import (
|
|||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// Options configuration for command execution
|
||||
// Options configures command execution.
|
||||
type Options struct {
|
||||
Dir string
|
||||
Env []string
|
||||
|
|
@ -23,7 +23,7 @@ type Options struct {
|
|||
// Background bool
|
||||
}
|
||||
|
||||
// Command wraps os/exec.Command with logging and context
|
||||
// Command wraps `os/exec.Command` with logging and context.
|
||||
func Command(ctx context.Context, name string, args ...string) *Cmd {
|
||||
return &Cmd{
|
||||
name: name,
|
||||
|
|
@ -32,7 +32,7 @@ func Command(ctx context.Context, name string, args ...string) *Cmd {
|
|||
}
|
||||
}
|
||||
|
||||
// Cmd represents a wrapped command
|
||||
// Cmd represents a wrapped command.
|
||||
type Cmd struct {
|
||||
name string
|
||||
args []string
|
||||
|
|
@ -42,31 +42,31 @@ type Cmd struct {
|
|||
logger Logger
|
||||
}
|
||||
|
||||
// WithDir sets the working directory
|
||||
// WithDir sets the working directory.
|
||||
func (c *Cmd) WithDir(dir string) *Cmd {
|
||||
c.opts.Dir = dir
|
||||
return c
|
||||
}
|
||||
|
||||
// WithEnv sets the environment variables
|
||||
// WithEnv sets the environment variables.
|
||||
func (c *Cmd) WithEnv(env []string) *Cmd {
|
||||
c.opts.Env = env
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStdin sets stdin
|
||||
// WithStdin sets stdin.
|
||||
func (c *Cmd) WithStdin(r io.Reader) *Cmd {
|
||||
c.opts.Stdin = r
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStdout sets stdout
|
||||
// WithStdout sets stdout.
|
||||
func (c *Cmd) WithStdout(w io.Writer) *Cmd {
|
||||
c.opts.Stdout = w
|
||||
return c
|
||||
}
|
||||
|
||||
// WithStderr sets stderr
|
||||
// WithStderr sets stderr.
|
||||
func (c *Cmd) WithStderr(w io.Writer) *Cmd {
|
||||
c.opts.Stderr = w
|
||||
return c
|
||||
|
|
|
|||
|
|
@ -107,14 +107,14 @@ func TestCommand_CombinedOutput_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNopLogger(t *testing.T) {
|
||||
func TestNopLogger_Good(t *testing.T) {
|
||||
// Verify NopLogger doesn't panic
|
||||
var nop exec.NopLogger
|
||||
nop.Debug("msg", "key", "val")
|
||||
nop.Error("msg", "key", "val")
|
||||
}
|
||||
|
||||
func TestSetDefaultLogger(t *testing.T) {
|
||||
func TestSetDefaultLogger_Good(t *testing.T) {
|
||||
original := exec.DefaultLogger()
|
||||
defer exec.SetDefaultLogger(original)
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ func TestSetDefaultLogger(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCommand_UsesDefaultLogger(t *testing.T) {
|
||||
func TestCommand_UsesDefaultLogger_Good(t *testing.T) {
|
||||
original := exec.DefaultLogger()
|
||||
defer exec.SetDefaultLogger(original)
|
||||
|
||||
|
|
@ -147,7 +147,7 @@ func TestCommand_UsesDefaultLogger(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCommand_WithDir(t *testing.T) {
|
||||
func TestCommand_WithDir_Good(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
out, err := exec.Command(ctx, "pwd").
|
||||
WithDir("/tmp").
|
||||
|
|
@ -162,7 +162,7 @@ func TestCommand_WithDir(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCommand_WithEnv(t *testing.T) {
|
||||
func TestCommand_WithEnv_Good(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
out, err := exec.Command(ctx, "sh", "-c", "echo $TEST_EXEC_VAR").
|
||||
WithEnv([]string{"TEST_EXEC_VAR=exec_val"}).
|
||||
|
|
@ -176,7 +176,7 @@ func TestCommand_WithEnv(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCommand_WithStdinStdoutStderr(t *testing.T) {
|
||||
func TestCommand_WithStdinStdoutStderr_Good(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
input := strings.NewReader("piped input\n")
|
||||
var stdout, stderr strings.Builder
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGlobal_DefaultNotInitialized(t *testing.T) {
|
||||
func TestGlobal_Default_Bad_NotInitialized(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
|
|
@ -51,7 +51,7 @@ func newGlobalTestService(t *testing.T) *Service {
|
|||
return raw.(*Service)
|
||||
}
|
||||
|
||||
func TestGlobal_SetDefault(t *testing.T) {
|
||||
func TestGlobal_SetDefault_Good(t *testing.T) {
|
||||
t.Run("sets and retrieves service", func(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
|
|
@ -72,7 +72,7 @@ func TestGlobal_SetDefault(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestGlobal_ConcurrentDefault(t *testing.T) {
|
||||
func TestGlobal_Default_Good_Concurrent(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
|
|
@ -97,7 +97,7 @@ func TestGlobal_ConcurrentDefault(t *testing.T) {
|
|||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestGlobal_ConcurrentSetDefault(t *testing.T) {
|
||||
func TestGlobal_SetDefault_Good_Concurrent(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
|
|
@ -134,7 +134,7 @@ func TestGlobal_ConcurrentSetDefault(t *testing.T) {
|
|||
assert.True(t, found, "Default should be one of the set services")
|
||||
}
|
||||
|
||||
func TestGlobal_ConcurrentOperations(t *testing.T) {
|
||||
func TestGlobal_Operations_Good_Concurrent(t *testing.T) {
|
||||
old := defaultService.Swap(nil)
|
||||
defer func() {
|
||||
if old != nil {
|
||||
|
|
@ -195,7 +195,7 @@ func TestGlobal_ConcurrentOperations(t *testing.T) {
|
|||
wg2.Wait()
|
||||
}
|
||||
|
||||
func TestGlobal_StartWithOptions(t *testing.T) {
|
||||
func TestGlobal_StartWithOptions_Good(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
old := defaultService.Swap(svc)
|
||||
defer func() {
|
||||
|
|
@ -216,7 +216,7 @@ func TestGlobal_StartWithOptions(t *testing.T) {
|
|||
assert.Contains(t, proc.Output(), "with options")
|
||||
}
|
||||
|
||||
func TestGlobal_RunWithOptions(t *testing.T) {
|
||||
func TestGlobal_RunWithOptions_Good(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
old := defaultService.Swap(svc)
|
||||
defer func() {
|
||||
|
|
@ -233,7 +233,7 @@ func TestGlobal_RunWithOptions(t *testing.T) {
|
|||
assert.Contains(t, r.Value.(string), "run options")
|
||||
}
|
||||
|
||||
func TestGlobal_Running(t *testing.T) {
|
||||
func TestGlobal_Running_Good(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
old := defaultService.Swap(svc)
|
||||
defer func() {
|
||||
|
|
|
|||
5
go.mod
5
go.mod
|
|
@ -13,11 +13,6 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
dappco.re/go/core/api v0.2.0
|
||||
dappco.re/go/core/i18n v0.2.0
|
||||
dappco.re/go/core/process v0.3.0
|
||||
dappco.re/go/core/scm v0.4.0
|
||||
dappco.re/go/core/store v0.2.0
|
||||
forge.lthn.ai/core/go-io v0.1.5 // indirect
|
||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
github.com/99designs/gqlgen v0.17.88 // indirect
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -1,3 +1,11 @@
|
|||
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
|
||||
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
dappco.re/go/core/ws v0.3.0 h1:ZxR8y5pfrWvnCHVN7qExXz7fdP5a063uNqyqE0Ab8pQ=
|
||||
dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic=
|
||||
forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o=
|
||||
forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII=
|
||||
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
|
||||
|
|
|
|||
14
health.go
14
health.go
|
|
@ -2,12 +2,12 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
|
|
@ -58,13 +58,13 @@ func (h *HealthServer) Start() error {
|
|||
for _, check := range checks {
|
||||
if err := check(); err != nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||
_, _ = w.Write([]byte("unhealthy: " + err.Error() + "\n"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ok")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -74,17 +74,17 @@ func (h *HealthServer) Start() error {
|
|||
|
||||
if !ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = fmt.Fprintln(w, "not ready")
|
||||
_, _ = w.Write([]byte("not ready\n"))
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintln(w, "ready")
|
||||
_, _ = w.Write([]byte("ready\n"))
|
||||
})
|
||||
|
||||
listener, err := net.Listen("tcp", h.addr)
|
||||
if err != nil {
|
||||
return coreerr.E("HealthServer.Start", fmt.Sprintf("failed to listen on %s", h.addr), err)
|
||||
return coreerr.E("HealthServer.Start", "failed to listen on "+h.addr, err)
|
||||
}
|
||||
|
||||
h.listener = listener
|
||||
|
|
@ -117,7 +117,7 @@ func (h *HealthServer) Addr() string {
|
|||
// (in milliseconds) expires. Returns true if healthy, false on timeout.
|
||||
func WaitForHealth(addr string, timeoutMs int) bool {
|
||||
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
|
||||
url := fmt.Sprintf("http://%s/health", addr)
|
||||
url := core.Concat("http://", addr, "/health")
|
||||
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHealthServer_Endpoints(t *testing.T) {
|
||||
func TestHealthServer_Endpoints_Good(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
err := hs.Start()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -36,7 +36,7 @@ func TestHealthServer_Endpoints(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestHealthServer_WithChecks(t *testing.T) {
|
||||
func TestHealthServer_WithChecks_Good(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
|
||||
healthy := true
|
||||
|
|
@ -66,7 +66,7 @@ func TestHealthServer_WithChecks(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestWaitForHealth_Reachable(t *testing.T) {
|
||||
func TestWaitForHealth_Good_Reachable(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
require.NoError(t, hs.Start())
|
||||
defer func() { _ = hs.Stop(context.Background()) }()
|
||||
|
|
@ -75,7 +75,7 @@ func TestWaitForHealth_Reachable(t *testing.T) {
|
|||
assert.True(t, ok)
|
||||
}
|
||||
|
||||
func TestWaitForHealth_Unreachable(t *testing.T) {
|
||||
func TestWaitForHealth_Bad_Unreachable(t *testing.T) {
|
||||
ok := WaitForHealth("127.0.0.1:19999", 500)
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
|
|
|||
17
internal/jsonx/jsonx.go
Normal file
17
internal/jsonx/jsonx.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package jsonx
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// MarshalIndent marshals v as indented JSON and returns the string form.
|
||||
func MarshalIndent(v any) (string, error) {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// Unmarshal unmarshals a JSON string into v.
|
||||
func Unmarshal(data string, v any) error {
|
||||
return json.Unmarshal([]byte(data), v)
|
||||
}
|
||||
20
pidfile.go
20
pidfile.go
|
|
@ -1,11 +1,9 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"bytes"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
|
|
@ -31,24 +29,24 @@ func (p *PIDFile) Acquire() error {
|
|||
defer p.mu.Unlock()
|
||||
|
||||
if data, err := coreio.Local.Read(p.path); err == nil {
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(data))
|
||||
pid, err := strconv.Atoi(string(bytes.TrimSpace([]byte(data))))
|
||||
if err == nil && pid > 0 {
|
||||
if proc, err := os.FindProcess(pid); err == nil {
|
||||
if proc, err := processHandle(pid); err == nil {
|
||||
if err := proc.Signal(syscall.Signal(0)); err == nil {
|
||||
return coreerr.E("PIDFile.Acquire", fmt.Sprintf("another instance is running (PID %d)", pid), nil)
|
||||
return coreerr.E("PIDFile.Acquire", "another instance is running (PID "+strconv.Itoa(pid)+")", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = coreio.Local.Delete(p.path)
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(p.path); dir != "." {
|
||||
if dir := path.Dir(p.path); dir != "." {
|
||||
if err := coreio.Local.EnsureDir(dir); err != nil {
|
||||
return coreerr.E("PIDFile.Acquire", "failed to create PID directory", err)
|
||||
}
|
||||
}
|
||||
|
||||
pid := os.Getpid()
|
||||
pid := currentPID()
|
||||
if err := coreio.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
|
||||
return coreerr.E("PIDFile.Acquire", "failed to write PID file", err)
|
||||
}
|
||||
|
|
@ -80,12 +78,12 @@ func ReadPID(path string) (int, bool) {
|
|||
return 0, false
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(data))
|
||||
pid, err := strconv.Atoi(string(bytes.TrimSpace([]byte(data))))
|
||||
if err != nil || pid <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
proc, err := processHandle(pid)
|
||||
if err != nil {
|
||||
return pid, false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPIDFile_AcquireAndRelease(t *testing.T) {
|
||||
func TestPIDFile_Acquire_Good(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||
pid := NewPIDFile(pidPath)
|
||||
err := pid.Acquire()
|
||||
|
|
@ -23,7 +23,7 @@ func TestPIDFile_AcquireAndRelease(t *testing.T) {
|
|||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestPIDFile_StalePID(t *testing.T) {
|
||||
func TestPIDFile_Acquire_Good_StalePID(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "stale.pid")
|
||||
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
|
||||
pid := NewPIDFile(pidPath)
|
||||
|
|
@ -33,7 +33,7 @@ func TestPIDFile_StalePID(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPIDFile_CreatesParentDirectory(t *testing.T) {
|
||||
func TestPIDFile_Acquire_Good_CreatesParentDirectory(t *testing.T) {
|
||||
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid")
|
||||
pid := NewPIDFile(pidPath)
|
||||
err := pid.Acquire()
|
||||
|
|
@ -42,18 +42,18 @@ func TestPIDFile_CreatesParentDirectory(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestPIDFile_Path(t *testing.T) {
|
||||
func TestPIDFile_Path_Good(t *testing.T) {
|
||||
pid := NewPIDFile("/tmp/test.pid")
|
||||
assert.Equal(t, "/tmp/test.pid", pid.Path())
|
||||
}
|
||||
|
||||
func TestReadPID_Missing(t *testing.T) {
|
||||
func TestReadPID_Bad_Missing(t *testing.T) {
|
||||
pid, running := ReadPID("/nonexistent/path.pid")
|
||||
assert.Equal(t, 0, pid)
|
||||
assert.False(t, running)
|
||||
}
|
||||
|
||||
func TestReadPID_InvalidContent(t *testing.T) {
|
||||
func TestReadPID_Bad_InvalidContent(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "bad.pid")
|
||||
require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644))
|
||||
pid, running := ReadPID(path)
|
||||
|
|
@ -61,7 +61,7 @@ func TestReadPID_InvalidContent(t *testing.T) {
|
|||
assert.False(t, running)
|
||||
}
|
||||
|
||||
func TestReadPID_StalePID(t *testing.T) {
|
||||
func TestReadPID_Bad_StalePID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "stale.pid")
|
||||
require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644))
|
||||
pid, running := ReadPID(path)
|
||||
|
|
|
|||
36
process.go
36
process.go
|
|
@ -2,10 +2,7 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
|
@ -13,6 +10,11 @@ import (
|
|||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
type processStdin interface {
|
||||
Write(p []byte) (n int, err error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Process represents a managed external process.
|
||||
type Process struct {
|
||||
ID string
|
||||
|
|
@ -25,11 +27,11 @@ type Process struct {
|
|||
ExitCode int
|
||||
Duration time.Duration
|
||||
|
||||
cmd *exec.Cmd
|
||||
cmd *execCmd
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
output *RingBuffer
|
||||
stdin io.WriteCloser
|
||||
stdin processStdin
|
||||
done chan struct{}
|
||||
mu sync.RWMutex
|
||||
gracePeriod time.Duration
|
||||
|
|
@ -92,13 +94,13 @@ func (p *Process) Wait() error {
|
|||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.Status == StatusFailed {
|
||||
return coreerr.E("Process.Wait", fmt.Sprintf("process failed to start: %s", p.ID), nil)
|
||||
return coreerr.E("Process.Wait", "process failed to start: "+p.ID, nil)
|
||||
}
|
||||
if p.Status == StatusKilled {
|
||||
return coreerr.E("Process.Wait", fmt.Sprintf("process was killed: %s", p.ID), nil)
|
||||
return coreerr.E("Process.Wait", "process was killed: "+p.ID, nil)
|
||||
}
|
||||
if p.ExitCode != 0 {
|
||||
return coreerr.E("Process.Wait", fmt.Sprintf("process exited with code %d", p.ExitCode), nil)
|
||||
return coreerr.E("Process.Wait", "process exited with code "+strconv.Itoa(p.ExitCode), nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -175,22 +177,6 @@ func (p *Process) terminate() error {
|
|||
return syscall.Kill(pid, syscall.SIGTERM)
|
||||
}
|
||||
|
||||
// Signal sends a signal to the process.
|
||||
func (p *Process) Signal(sig os.Signal) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.Status != StatusRunning {
|
||||
return ErrProcessNotRunning
|
||||
}
|
||||
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.cmd.Process.Signal(sig)
|
||||
}
|
||||
|
||||
// SendInput writes to the process stdin.
|
||||
func (p *Process) SendInput(input string) error {
|
||||
p.mu.RLock()
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProcess_Info(t *testing.T) {
|
||||
func TestProcess_Info_Good(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
proc := startProc(t, svc, context.Background(), "echo", "hello")
|
||||
|
|
@ -26,7 +26,7 @@ func TestProcess_Info(t *testing.T) {
|
|||
assert.Greater(t, info.Duration, time.Duration(0))
|
||||
}
|
||||
|
||||
func TestProcess_Output(t *testing.T) {
|
||||
func TestProcess_Output_Good(t *testing.T) {
|
||||
t.Run("captures stdout", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
proc := startProc(t, svc, context.Background(), "echo", "hello world")
|
||||
|
|
@ -44,7 +44,7 @@ func TestProcess_Output(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_IsRunning(t *testing.T) {
|
||||
func TestProcess_IsRunning_Good(t *testing.T) {
|
||||
t.Run("true while running", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -66,7 +66,7 @@ func TestProcess_IsRunning(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_Wait(t *testing.T) {
|
||||
func TestProcess_Wait_Good(t *testing.T) {
|
||||
t.Run("returns nil on success", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
proc := startProc(t, svc, context.Background(), "echo", "ok")
|
||||
|
|
@ -82,7 +82,7 @@ func TestProcess_Wait(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_Done(t *testing.T) {
|
||||
func TestProcess_Done_Good(t *testing.T) {
|
||||
t.Run("channel closes on completion", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
proc := startProc(t, svc, context.Background(), "echo", "test")
|
||||
|
|
@ -95,7 +95,7 @@ func TestProcess_Done(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_Kill(t *testing.T) {
|
||||
func TestProcess_Kill_Good(t *testing.T) {
|
||||
t.Run("terminates running process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -123,7 +123,7 @@ func TestProcess_Kill(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_SendInput(t *testing.T) {
|
||||
func TestProcess_SendInput_Good(t *testing.T) {
|
||||
t.Run("writes to stdin", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
proc := startProc(t, svc, context.Background(), "cat")
|
||||
|
|
@ -145,7 +145,7 @@ func TestProcess_SendInput(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_Signal(t *testing.T) {
|
||||
func TestProcess_Signal_Good(t *testing.T) {
|
||||
t.Run("sends signal to running process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
|
@ -171,7 +171,7 @@ func TestProcess_Signal(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_CloseStdin(t *testing.T) {
|
||||
func TestProcess_CloseStdin_Good(t *testing.T) {
|
||||
t.Run("closes stdin pipe", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
proc := startProc(t, svc, context.Background(), "cat")
|
||||
|
|
@ -196,7 +196,7 @@ func TestProcess_CloseStdin(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_Timeout(t *testing.T) {
|
||||
func TestProcess_Timeout_Good(t *testing.T) {
|
||||
t.Run("kills process after timeout", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
|
|
@ -229,7 +229,7 @@ func TestProcess_Timeout(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_Shutdown(t *testing.T) {
|
||||
func TestProcess_Shutdown_Good(t *testing.T) {
|
||||
t.Run("graceful with grace period", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
|
|
@ -271,7 +271,7 @@ func TestProcess_Shutdown(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_KillGroup(t *testing.T) {
|
||||
func TestProcess_KillGroup_Good(t *testing.T) {
|
||||
t.Run("kills child processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
|
|
@ -295,7 +295,7 @@ func TestProcess_KillGroup(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestProcess_TimeoutWithGrace(t *testing.T) {
|
||||
func TestProcess_TimeoutWithGrace_Good(t *testing.T) {
|
||||
t.Run("timeout triggers graceful shutdown", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
||||
|
|
|
|||
14
program.go
14
program.go
|
|
@ -3,9 +3,7 @@ package process
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"strconv"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
|
@ -30,9 +28,9 @@ func (p *Program) Find() error {
|
|||
if p.Name == "" {
|
||||
return coreerr.E("Program.Find", "program name is empty", nil)
|
||||
}
|
||||
path, err := exec.LookPath(p.Name)
|
||||
path, err := execLookPath(p.Name)
|
||||
if err != nil {
|
||||
return coreerr.E("Program.Find", fmt.Sprintf("%q: not found in PATH", p.Name), ErrProgramNotFound)
|
||||
return coreerr.E("Program.Find", strconv.Quote(p.Name)+": not found in PATH", ErrProgramNotFound)
|
||||
}
|
||||
p.Path = path
|
||||
return nil
|
||||
|
|
@ -54,7 +52,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin
|
|||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, binary, args...)
|
||||
cmd := execCommandContext(ctx, binary, args...)
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
if dir != "" {
|
||||
|
|
@ -62,7 +60,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin
|
|||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return strings.TrimSpace(out.String()), coreerr.E("Program.RunDir", fmt.Sprintf("%q: command failed", p.Name), err)
|
||||
return string(bytes.TrimSpace(out.Bytes())), coreerr.E("Program.RunDir", strconv.Quote(p.Name)+": command failed", err)
|
||||
}
|
||||
return strings.TrimSpace(out.String()), nil
|
||||
return string(bytes.TrimSpace(out.Bytes())), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,25 +19,25 @@ func testCtx(t *testing.T) context.Context {
|
|||
return ctx
|
||||
}
|
||||
|
||||
func TestProgram_Find_KnownBinary(t *testing.T) {
|
||||
func TestProgram_Find_Good_KnownBinary(t *testing.T) {
|
||||
p := &process.Program{Name: "echo"}
|
||||
require.NoError(t, p.Find())
|
||||
assert.NotEmpty(t, p.Path)
|
||||
}
|
||||
|
||||
func TestProgram_Find_UnknownBinary(t *testing.T) {
|
||||
func TestProgram_Find_Bad_UnknownBinary(t *testing.T) {
|
||||
p := &process.Program{Name: "no-such-binary-xyzzy-42"}
|
||||
err := p.Find()
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, process.ErrProgramNotFound)
|
||||
}
|
||||
|
||||
func TestProgram_Find_EmptyName(t *testing.T) {
|
||||
func TestProgram_Find_Bad_EmptyName(t *testing.T) {
|
||||
p := &process.Program{}
|
||||
require.Error(t, p.Find())
|
||||
}
|
||||
|
||||
func TestProgram_Run_ReturnsOutput(t *testing.T) {
|
||||
func TestProgram_Run_Good_ReturnsOutput(t *testing.T) {
|
||||
p := &process.Program{Name: "echo"}
|
||||
require.NoError(t, p.Find())
|
||||
|
||||
|
|
@ -46,7 +46,7 @@ func TestProgram_Run_ReturnsOutput(t *testing.T) {
|
|||
assert.Equal(t, "hello", out)
|
||||
}
|
||||
|
||||
func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) {
|
||||
func TestProgram_Run_Good_FallsBackToName(t *testing.T) {
|
||||
// Path is empty; RunDir should fall back to Name for OS PATH resolution.
|
||||
p := &process.Program{Name: "echo"}
|
||||
|
||||
|
|
@ -55,7 +55,7 @@ func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) {
|
|||
assert.Equal(t, "fallback", out)
|
||||
}
|
||||
|
||||
func TestProgram_RunDir_UsesDirectory(t *testing.T) {
|
||||
func TestProgram_RunDir_Good_UsesDirectory(t *testing.T) {
|
||||
p := &process.Program{Name: "pwd"}
|
||||
require.NoError(t, p.Find())
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ func TestProgram_RunDir_UsesDirectory(t *testing.T) {
|
|||
assert.Equal(t, canonicalDir, canonicalOut)
|
||||
}
|
||||
|
||||
func TestProgram_Run_FailingCommand(t *testing.T) {
|
||||
func TestProgram_Run_Bad_FailingCommand(t *testing.T) {
|
||||
p := &process.Program{Name: "false"}
|
||||
require.NoError(t, p.Find())
|
||||
|
||||
|
|
|
|||
51
registry.go
51
registry.go
|
|
@ -1,15 +1,14 @@
|
|||
package process
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"path"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"dappco.re/go/core/process/internal/jsonx"
|
||||
)
|
||||
|
||||
// DaemonEntry records a running daemon in the registry.
|
||||
|
|
@ -35,11 +34,11 @@ func NewRegistry(dir string) *Registry {
|
|||
|
||||
// DefaultRegistry returns a registry using ~/.core/daemons/.
|
||||
func DefaultRegistry() *Registry {
|
||||
home, err := os.UserHomeDir()
|
||||
home, err := userHomeDir()
|
||||
if err != nil {
|
||||
home = os.TempDir()
|
||||
home = tempDir()
|
||||
}
|
||||
return NewRegistry(filepath.Join(home, ".core", "daemons"))
|
||||
return NewRegistry(path.Join(home, ".core", "daemons"))
|
||||
}
|
||||
|
||||
// Register writes a daemon entry to the registry directory.
|
||||
|
|
@ -54,7 +53,7 @@ func (r *Registry) Register(entry DaemonEntry) error {
|
|||
return coreerr.E("Registry.Register", "failed to create registry directory", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(entry, "", " ")
|
||||
data, err := jsonx.MarshalIndent(entry)
|
||||
if err != nil {
|
||||
return coreerr.E("Registry.Register", "failed to marshal entry", err)
|
||||
}
|
||||
|
|
@ -84,7 +83,7 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
|
|||
}
|
||||
|
||||
var entry DaemonEntry
|
||||
if err := json.Unmarshal([]byte(data), &entry); err != nil {
|
||||
if err := jsonx.Unmarshal(data, &entry); err != nil {
|
||||
_ = coreio.Local.Delete(path)
|
||||
return nil, false
|
||||
}
|
||||
|
|
@ -99,20 +98,28 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
|
|||
|
||||
// List returns all alive daemon entries, pruning any with dead PIDs.
|
||||
func (r *Registry) List() ([]DaemonEntry, error) {
|
||||
matches, err := filepath.Glob(filepath.Join(r.dir, "*.json"))
|
||||
if !coreio.Local.Exists(r.dir) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
entries, err := coreio.Local.List(r.dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, coreerr.E("Registry.List", "failed to list registry directory", err)
|
||||
}
|
||||
|
||||
var alive []DaemonEntry
|
||||
for _, path := range matches {
|
||||
for _, entryFile := range entries {
|
||||
if entryFile.IsDir() || !core.HasSuffix(entryFile.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
path := path.Join(r.dir, entryFile.Name())
|
||||
data, err := coreio.Local.Read(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var entry DaemonEntry
|
||||
if err := json.Unmarshal([]byte(data), &entry); err != nil {
|
||||
if err := jsonx.Unmarshal(data, &entry); err != nil {
|
||||
_ = coreio.Local.Delete(path)
|
||||
continue
|
||||
}
|
||||
|
|
@ -130,8 +137,8 @@ func (r *Registry) List() ([]DaemonEntry, error) {
|
|||
|
||||
// entryPath returns the filesystem path for a daemon entry.
|
||||
func (r *Registry) entryPath(code, daemon string) string {
|
||||
name := strings.ReplaceAll(code, "/", "-") + "-" + strings.ReplaceAll(daemon, "/", "-") + ".json"
|
||||
return filepath.Join(r.dir, name)
|
||||
name := sanitizeRegistryComponent(code) + "-" + sanitizeRegistryComponent(daemon) + ".json"
|
||||
return path.Join(r.dir, name)
|
||||
}
|
||||
|
||||
// isAlive checks whether a process with the given PID is running.
|
||||
|
|
@ -139,9 +146,21 @@ func isAlive(pid int) bool {
|
|||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
proc, err := processHandle(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return proc.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
func sanitizeRegistryComponent(value string) string {
|
||||
buf := make([]byte, len(value))
|
||||
for i := 0; i < len(value); i++ {
|
||||
if value[i] == '/' {
|
||||
buf[i] = '-'
|
||||
continue
|
||||
}
|
||||
buf[i] = value[i]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRegistry_RegisterAndGet(t *testing.T) {
|
||||
func TestRegistry_Register_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ func TestRegistry_RegisterAndGet(t *testing.T) {
|
|||
assert.Equal(t, started, got.Started)
|
||||
}
|
||||
|
||||
func TestRegistry_Unregister(t *testing.T) {
|
||||
func TestRegistry_Unregister_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -65,7 +65,7 @@ func TestRegistry_Unregister(t *testing.T) {
|
|||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestRegistry_List(t *testing.T) {
|
||||
func TestRegistry_List_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -79,7 +79,7 @@ func TestRegistry_List(t *testing.T) {
|
|||
assert.Len(t, entries, 2)
|
||||
}
|
||||
|
||||
func TestRegistry_List_PrunesStale(t *testing.T) {
|
||||
func TestRegistry_List_Good_PrunesStale(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -100,7 +100,7 @@ func TestRegistry_List_PrunesStale(t *testing.T) {
|
|||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
func TestRegistry_Get_NotFound(t *testing.T) {
|
||||
func TestRegistry_Get_Bad_NotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -109,7 +109,7 @@ func TestRegistry_Get_NotFound(t *testing.T) {
|
|||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestRegistry_CreatesDirectory(t *testing.T) {
|
||||
func TestRegistry_Register_Good_CreatesDirectory(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons")
|
||||
reg := NewRegistry(dir)
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ func TestRegistry_CreatesDirectory(t *testing.T) {
|
|||
assert.True(t, info.IsDir())
|
||||
}
|
||||
|
||||
func TestDefaultRegistry(t *testing.T) {
|
||||
func TestDefaultRegistry_Good(t *testing.T) {
|
||||
reg := DefaultRegistry()
|
||||
assert.NotNil(t, reg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func newTestRunner(t *testing.T) *Runner {
|
|||
return NewRunner(raw.(*Service))
|
||||
}
|
||||
|
||||
func TestRunner_RunSequential(t *testing.T) {
|
||||
func TestRunner_RunSequential_Good(t *testing.T) {
|
||||
t.Run("all pass", func(t *testing.T) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
|
|
@ -70,7 +70,7 @@ func TestRunner_RunSequential(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestRunner_RunParallel(t *testing.T) {
|
||||
func TestRunner_RunParallel_Good(t *testing.T) {
|
||||
t.Run("all run concurrently", func(t *testing.T) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
|
|
@ -102,7 +102,7 @@ func TestRunner_RunParallel(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestRunner_RunAll(t *testing.T) {
|
||||
func TestRunner_RunAll_Good(t *testing.T) {
|
||||
t.Run("respects dependencies", func(t *testing.T) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
|
|
@ -150,7 +150,7 @@ func TestRunner_RunAll(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestRunner_RunAll_CircularDeps(t *testing.T) {
|
||||
func TestRunner_RunAll_Bad_CircularDeps(t *testing.T) {
|
||||
t.Run("circular dependency counts as failed", func(t *testing.T) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
|
|
@ -166,7 +166,7 @@ func TestRunner_RunAll_CircularDeps(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestRunResult_Passed(t *testing.T) {
|
||||
func TestRunResult_Passed_Good(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
r := RunResult{ExitCode: 0}
|
||||
assert.True(t, r.Passed())
|
||||
|
|
|
|||
125
service.go
125
service.go
|
|
@ -3,8 +3,9 @@ package process
|
|||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
|
@ -14,6 +15,12 @@ import (
|
|||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
type execCmd = exec.Cmd
|
||||
|
||||
type streamReader interface {
|
||||
Read(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
// Default buffer size for process output (1MB).
|
||||
const DefaultBufferSize = 1024 * 1024
|
||||
|
||||
|
|
@ -41,11 +48,10 @@ type Options struct {
|
|||
BufferSize int
|
||||
}
|
||||
|
||||
// Register is the WithService factory for go-process.
|
||||
// Registers the process service with Core — OnStartup registers named Actions
|
||||
// (process.run, process.start, process.kill, process.list, process.get).
|
||||
// Register constructs a Service bound to the provided Core instance.
|
||||
//
|
||||
// core.New(core.WithService(process.Register))
|
||||
// c := core.New()
|
||||
// svc := process.Register(c).Value.(*process.Service)
|
||||
func Register(c *core.Core) core.Result {
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime(c, Options{BufferSize: DefaultBufferSize}),
|
||||
|
|
@ -56,11 +62,15 @@ func Register(c *core.Core) core.Result {
|
|||
}
|
||||
|
||||
// NewService creates a process service factory for Core registration.
|
||||
// Deprecated: Use Register with core.WithService(process.Register) instead.
|
||||
// Deprecated: Use Register(c) to construct a Service directly.
|
||||
//
|
||||
// core, _ := core.New(
|
||||
// core.WithName("process", process.NewService(process.Options{})),
|
||||
// )
|
||||
// c := core.New()
|
||||
// factory := process.NewService(process.Options{})
|
||||
// raw, err := factory(c)
|
||||
// if err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// svc := raw.(*process.Service)
|
||||
func NewService(opts Options) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
if opts.BufferSize == 0 {
|
||||
|
|
@ -75,16 +85,8 @@ func NewService(opts Options) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// OnStartup implements core.Startable — registers named Actions.
|
||||
//
|
||||
// c.Process().Run(ctx, "git", "log") // → calls process.run Action
|
||||
// OnStartup implements core.Startable.
|
||||
func (s *Service) OnStartup(ctx context.Context) core.Result {
|
||||
c := s.Core()
|
||||
c.Action("process.run", s.handleRun)
|
||||
c.Action("process.start", s.handleStart)
|
||||
c.Action("process.kill", s.handleKill)
|
||||
c.Action("process.list", s.handleList)
|
||||
c.Action("process.get", s.handleGet)
|
||||
return core.Result{OK: true}
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +126,7 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) cor
|
|||
// r := svc.StartWithOptions(ctx, process.RunOptions{Command: "go", Args: []string{"test", "./..."}})
|
||||
// if r.OK { proc := r.Value.(*Process) }
|
||||
func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Result {
|
||||
id := core.ID()
|
||||
id := s.nextProcessID()
|
||||
|
||||
// Detached processes use Background context so they survive parent death
|
||||
parentCtx := ctx
|
||||
|
|
@ -132,7 +134,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re
|
|||
parentCtx = context.Background()
|
||||
}
|
||||
procCtx, cancel := context.WithCancel(parentCtx)
|
||||
cmd := exec.CommandContext(procCtx, opts.Command, opts.Args...)
|
||||
cmd := execCommandContext(procCtx, opts.Command, opts.Args...)
|
||||
|
||||
if opts.Dir != "" {
|
||||
cmd.Dir = opts.Dir
|
||||
|
|
@ -271,7 +273,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re
|
|||
}
|
||||
|
||||
// streamOutput reads from a pipe and broadcasts lines via ACTION.
|
||||
func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) {
|
||||
func (s *Service) streamOutput(proc *Process, r streamReader, stream Stream) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
// Increase buffer for long lines
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
|
@ -423,17 +425,8 @@ func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Resu
|
|||
return core.Result{Value: proc.Output(), OK: proc.ExitCode == 0}
|
||||
}
|
||||
|
||||
// --- Named Action Handlers ---
|
||||
// These are registered during OnStartup and called via c.Process() sugar.
|
||||
// c.Process().Run(ctx, "git", "log") → c.Action("process.run").Run(ctx, opts)
|
||||
// --- Internal Request Helpers ---
|
||||
|
||||
// handleRun executes a command synchronously and returns the output.
|
||||
//
|
||||
// r := c.Action("process.run").Run(ctx, core.NewOptions(
|
||||
// core.Option{Key: "command", Value: "git"},
|
||||
// core.Option{Key: "args", Value: []string{"log"}},
|
||||
// core.Option{Key: "dir", Value: "/repo"},
|
||||
// ))
|
||||
func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result {
|
||||
command := opts.String("command")
|
||||
if command == "" {
|
||||
|
|
@ -458,13 +451,6 @@ func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result
|
|||
return s.RunWithOptions(ctx, runOpts)
|
||||
}
|
||||
|
||||
// handleStart spawns a detached/background process and returns the process ID.
|
||||
//
|
||||
// r := c.Action("process.start").Run(ctx, core.NewOptions(
|
||||
// core.Option{Key: "command", Value: "docker"},
|
||||
// core.Option{Key: "args", Value: []string{"run", "nginx"}},
|
||||
// ))
|
||||
// id := r.Value.(string)
|
||||
func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result {
|
||||
command := opts.String("command")
|
||||
if command == "" {
|
||||
|
|
@ -488,11 +474,6 @@ func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Resul
|
|||
return core.Result{Value: r.Value.(*Process).ID, OK: true}
|
||||
}
|
||||
|
||||
// handleKill terminates a process by ID.
|
||||
//
|
||||
// r := c.Action("process.kill").Run(ctx, core.NewOptions(
|
||||
// core.Option{Key: "id", Value: "id-42-a3f2b1"},
|
||||
// ))
|
||||
func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result {
|
||||
id := opts.String("id")
|
||||
if id != "" {
|
||||
|
|
@ -504,10 +485,6 @@ func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result
|
|||
return core.Result{Value: coreerr.E("process.kill", "id is required", nil), OK: false}
|
||||
}
|
||||
|
||||
// handleList returns the IDs of all managed processes.
|
||||
//
|
||||
// r := c.Action("process.list").Run(ctx, core.NewOptions())
|
||||
// ids := r.Value.([]string)
|
||||
func (s *Service) handleList(ctx context.Context, opts core.Options) core.Result {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
@ -519,12 +496,50 @@ func (s *Service) handleList(ctx context.Context, opts core.Options) core.Result
|
|||
return core.Result{Value: ids, OK: true}
|
||||
}
|
||||
|
||||
// handleGet returns process info by ID.
|
||||
//
|
||||
// r := c.Action("process.get").Run(ctx, core.NewOptions(
|
||||
// core.Option{Key: "id", Value: "id-42-a3f2b1"},
|
||||
// ))
|
||||
// info := r.Value.(process.Info)
|
||||
// Signal sends a signal to the process.
|
||||
func (p *Process) Signal(sig os.Signal) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.Status != StatusRunning {
|
||||
return ErrProcessNotRunning
|
||||
}
|
||||
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.cmd.Process.Signal(sig)
|
||||
}
|
||||
|
||||
func execCommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, name, args...)
|
||||
}
|
||||
|
||||
func execLookPath(name string) (string, error) {
|
||||
return exec.LookPath(name)
|
||||
}
|
||||
|
||||
func currentPID() int {
|
||||
return os.Getpid()
|
||||
}
|
||||
|
||||
func processHandle(pid int) (*os.Process, error) {
|
||||
return os.FindProcess(pid)
|
||||
}
|
||||
|
||||
func userHomeDir() (string, error) {
|
||||
return os.UserHomeDir()
|
||||
}
|
||||
|
||||
func tempDir() string {
|
||||
return os.TempDir()
|
||||
}
|
||||
|
||||
func isNotExist(err error) bool {
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
|
||||
func (s *Service) handleGet(ctx context.Context, opts core.Options) core.Result {
|
||||
id := opts.String("id")
|
||||
proc, err := s.Get(id)
|
||||
|
|
@ -533,3 +548,7 @@ func (s *Service) handleGet(ctx context.Context, opts core.Options) core.Result
|
|||
}
|
||||
return core.Result{Value: proc.Info(), OK: true}
|
||||
}
|
||||
|
||||
func (s *Service) nextProcessID() string {
|
||||
return "proc-" + strconv.FormatUint(s.idCounter.Add(1), 10)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ func startProc(t *testing.T, svc *Service, ctx context.Context, command string,
|
|||
return r.Value.(*Process)
|
||||
}
|
||||
|
||||
func TestService_Start(t *testing.T) {
|
||||
func TestService_Start_Good(t *testing.T) {
|
||||
t.Run("echo command", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -153,7 +153,7 @@ func TestService_Start(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_Run(t *testing.T) {
|
||||
func TestService_Run_Good(t *testing.T) {
|
||||
t.Run("returns output", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -170,7 +170,7 @@ func TestService_Run(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_Actions(t *testing.T) {
|
||||
func TestService_Actions_Good(t *testing.T) {
|
||||
t.Run("broadcasts events", func(t *testing.T) {
|
||||
c := framework.New()
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ func TestService_Actions(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_List(t *testing.T) {
|
||||
func TestService_List_Good(t *testing.T) {
|
||||
t.Run("tracks processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -258,7 +258,7 @@ func TestService_List(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_Remove(t *testing.T) {
|
||||
func TestService_Remove_Good(t *testing.T) {
|
||||
t.Run("removes completed process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -288,7 +288,7 @@ func TestService_Remove(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_Clear(t *testing.T) {
|
||||
func TestService_Clear_Good(t *testing.T) {
|
||||
t.Run("clears completed processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -306,7 +306,7 @@ func TestService_Clear(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_Kill(t *testing.T) {
|
||||
func TestService_Kill_Good(t *testing.T) {
|
||||
t.Run("kills running process", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -333,7 +333,7 @@ func TestService_Kill(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_Output(t *testing.T) {
|
||||
func TestService_Output_Good(t *testing.T) {
|
||||
t.Run("returns captured output", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -353,7 +353,7 @@ func TestService_Output(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_OnShutdown(t *testing.T) {
|
||||
func TestService_OnShutdown_Good(t *testing.T) {
|
||||
t.Run("kills all running processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -382,21 +382,15 @@ func TestService_OnShutdown(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_OnStartup(t *testing.T) {
|
||||
t.Run("registers named actions", func(t *testing.T) {
|
||||
svc, c := newTestService(t)
|
||||
func TestService_OnStartup_Good(t *testing.T) {
|
||||
t.Run("returns ok", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
r := svc.OnStartup(context.Background())
|
||||
assert.True(t, r.OK)
|
||||
|
||||
assert.True(t, c.Action("process.run").Exists())
|
||||
assert.True(t, c.Action("process.start").Exists())
|
||||
assert.True(t, c.Action("process.kill").Exists())
|
||||
assert.True(t, c.Action("process.list").Exists())
|
||||
assert.True(t, c.Action("process.get").Exists())
|
||||
})
|
||||
}
|
||||
|
||||
func TestService_RunWithOptions(t *testing.T) {
|
||||
func TestService_RunWithOptions_Good(t *testing.T) {
|
||||
t.Run("returns output on success", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -419,7 +413,7 @@ func TestService_RunWithOptions(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestService_Running(t *testing.T) {
|
||||
func TestService_Running_Good(t *testing.T) {
|
||||
t.Run("returns only running processes", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
|
|||
19
types.go
19
types.go
|
|
@ -5,30 +5,29 @@
|
|||
//
|
||||
// # Getting Started
|
||||
//
|
||||
// // Register with Core
|
||||
// core, _ := framework.New(
|
||||
// framework.WithName("process", process.NewService(process.Options{})),
|
||||
// )
|
||||
//
|
||||
// // Get service and run a process
|
||||
// svc, err := framework.ServiceFor[*process.Service](core, "process")
|
||||
// c := core.New()
|
||||
// factory := process.NewService(process.Options{})
|
||||
// raw, err := factory(c)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// proc, err := svc.Start(ctx, "go", "test", "./...")
|
||||
//
|
||||
// svc := raw.(*process.Service)
|
||||
// r := svc.Start(ctx, "go", "test", "./...")
|
||||
// proc := r.Value.(*process.Process)
|
||||
//
|
||||
// # Listening for Events
|
||||
//
|
||||
// Process events are broadcast via Core.ACTION:
|
||||
//
|
||||
// core.RegisterAction(func(c *framework.Core, msg framework.Message) error {
|
||||
// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
|
||||
// switch m := msg.(type) {
|
||||
// case process.ActionProcessOutput:
|
||||
// fmt.Print(m.Line)
|
||||
// case process.ActionProcessExited:
|
||||
// fmt.Printf("Exit code: %d\n", m.ExitCode)
|
||||
// }
|
||||
// return nil
|
||||
// return core.Result{OK: true}
|
||||
// })
|
||||
package process
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue