From e9bb6a89682e8a18da17f157c9120be332075353 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 10:48:55 +0000 Subject: [PATCH] fix(ax): align imports, tests, and usage docs Co-Authored-By: Virgil --- buffer_test.go | 2 +- daemon.go | 8 +-- daemon_test.go | 18 +++--- exec/doc.go | 3 + exec/exec.go | 16 ++--- exec/exec_test.go | 12 ++-- global_test.go | 16 ++--- go.mod | 5 -- go.sum | 8 +++ health.go | 14 ++--- health_test.go | 8 +-- internal/jsonx/jsonx.go | 17 ++++++ pidfile.go | 20 +++---- pidfile_test.go | 14 ++--- process.go | 36 ++++-------- process_test.go | 26 ++++----- program.go | 14 ++--- program_test.go | 14 ++--- registry.go | 51 +++++++++++----- registry_test.go | 14 ++--- runner_test.go | 10 ++-- service.go | 125 +++++++++++++++++++++++----------------- service_test.go | 34 +++++------ types.go | 19 +++--- 24 files changed, 269 insertions(+), 235 deletions(-) create mode 100644 exec/doc.go create mode 100644 internal/jsonx/jsonx.go diff --git a/buffer_test.go b/buffer_test.go index bbd4f1c..65a5076 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -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) diff --git a/daemon.go b/daemon.go index af3e044..e3f30a5 100644 --- a/daemon.go +++ b/daemon.go @@ -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 } diff --git a/daemon_test.go b/daemon_test.go index 4e641d4..9433dfa 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -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")) diff --git a/exec/doc.go b/exec/doc.go new file mode 100644 index 0000000..4875ce8 --- /dev/null +++ b/exec/doc.go @@ -0,0 +1,3 @@ +// Package exec provides a small command wrapper around `os/exec` with +// structured logging hooks. +package exec diff --git a/exec/exec.go b/exec/exec.go index 6a2c49e..ce2b29b 100644 --- a/exec/exec.go +++ b/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 diff --git a/exec/exec_test.go b/exec/exec_test.go index 6e2544b..b48f045 100644 --- a/exec/exec_test.go +++ b/exec/exec_test.go @@ -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 diff --git a/global_test.go b/global_test.go index 6d00eb1..038e397 100644 --- a/global_test.go +++ b/global_test.go @@ -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() { diff --git a/go.mod b/go.mod index 75dfafb..cedb725 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index dab2b48..3fc5cfc 100644 --- a/go.sum +++ b/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= diff --git a/health.go b/health.go index fd6adfe..5022234 100644 --- a/health.go +++ b/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} diff --git a/health_test.go b/health_test.go index dad5bc3..3d952a7 100644 --- a/health_test.go +++ b/health_test.go @@ -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) } diff --git a/internal/jsonx/jsonx.go b/internal/jsonx/jsonx.go new file mode 100644 index 0000000..cb6b885 --- /dev/null +++ b/internal/jsonx/jsonx.go @@ -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) +} diff --git a/pidfile.go b/pidfile.go index 909490d..4ac94f2 100644 --- a/pidfile.go +++ b/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 } diff --git a/pidfile_test.go b/pidfile_test.go index 97eb147..1ec0afe 100644 --- a/pidfile_test.go +++ b/pidfile_test.go @@ -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) diff --git a/process.go b/process.go index 9a2ad0b..c1240c4 100644 --- a/process.go +++ b/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() diff --git a/process_test.go b/process_test.go index 1c95f83..711b60b 100644 --- a/process_test.go +++ b/process_test.go @@ -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{ diff --git a/program.go b/program.go index 5160392..8ba96f0 100644 --- a/program.go +++ b/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 } diff --git a/program_test.go b/program_test.go index 970e2de..5c729a3 100644 --- a/program_test.go +++ b/program_test.go @@ -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()) diff --git a/registry.go b/registry.go index ed7c8eb..a6aa98a 100644 --- a/registry.go +++ b/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) +} diff --git a/registry_test.go b/registry_test.go index 108ae28..1f549ad 100644 --- a/registry_test.go +++ b/registry_test.go @@ -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) } diff --git a/runner_test.go b/runner_test.go index fd96f79..a54b061 100644 --- a/runner_test.go +++ b/runner_test.go @@ -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()) diff --git a/service.go b/service.go index bc97ba3..1b66c33 100644 --- a/service.go +++ b/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) +} diff --git a/service_test.go b/service_test.go index cc9b49d..eb9b1e0 100644 --- a/service_test.go +++ b/service_test.go @@ -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) diff --git a/types.go b/types.go index 0416b72..0fe5651 100644 --- a/types.go +++ b/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