diff --git a/actions.go b/actions.go index a9eddd8..ae238e7 100644 --- a/actions.go +++ b/actions.go @@ -1,16 +1,195 @@ package process import ( - "context" "syscall" "time" - - "dappco.re/go/core" ) // --- ACTION messages (broadcast via Core.ACTION) --- +// TaskProcessStart requests asynchronous process execution through Core.PERFORM. +// The handler returns a snapshot of the started process immediately. +// +// Example: +// +// c.PERFORM(process.TaskProcessStart{Command: "sleep", Args: []string{"10"}}) +type TaskProcessStart struct { + Command string + Args []string + Dir string + Env []string + // DisableCapture skips buffering process output before returning it. + DisableCapture bool + // Detach runs the command in its own process group. + Detach bool + // Timeout bounds the execution duration. + Timeout time.Duration + // GracePeriod controls SIGTERM-to-SIGKILL escalation. + GracePeriod time.Duration + // KillGroup terminates the entire process group instead of only the leader. + KillGroup bool +} + +// TaskProcessRun requests synchronous command execution through Core.PERFORM. +// The handler returns the combined command output on success. +// +// Example: +// +// c.PERFORM(process.TaskProcessRun{Command: "echo", Args: []string{"hello"}}) +type TaskProcessRun struct { + Command string + Args []string + Dir string + Env []string + // DisableCapture skips buffering process output before returning it. + DisableCapture bool + // Detach runs the command in its own process group. + Detach bool + // Timeout bounds the execution duration. + Timeout time.Duration + // GracePeriod controls SIGTERM-to-SIGKILL escalation. + GracePeriod time.Duration + // KillGroup terminates the entire process group instead of only the leader. + KillGroup bool +} + +// TaskProcessKill requests termination of a managed process by ID or PID. +// +// Example: +// +// c.PERFORM(process.TaskProcessKill{ID: "proc-1"}) +type TaskProcessKill struct { + // ID identifies a managed process started by this service. + ID string + // PID targets a process directly when ID is not available. + PID int +} + +// TaskProcessSignal requests signalling a managed process by ID or PID through Core.PERFORM. +// Signal 0 is allowed for liveness checks. +// +// Example: +// +// c.PERFORM(process.TaskProcessSignal{ID: "proc-1", Signal: syscall.SIGTERM}) +type TaskProcessSignal struct { + // ID identifies a managed process started by this service. + ID string + // PID targets a process directly when ID is not available. + PID int + // Signal is delivered to the process or process group. + Signal syscall.Signal +} + +// TaskProcessGet requests a snapshot of a managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessGet{ID: "proc-1"}) +type TaskProcessGet struct { + // ID identifies a managed process started by this service. + ID string +} + +// TaskProcessWait waits for a managed process to finish through Core.PERFORM. +// Successful exits return an Info snapshot. Unsuccessful exits return a +// TaskProcessWaitError value that preserves the final snapshot. +// +// Example: +// +// c.PERFORM(process.TaskProcessWait{ID: "proc-1"}) +type TaskProcessWait struct { + // ID identifies a managed process started by this service. + ID string +} + +// TaskProcessWaitError is returned as the task value when TaskProcessWait +// completes with a non-successful process outcome. It preserves the final +// process snapshot while still behaving like the underlying wait error. +type TaskProcessWaitError struct { + Info Info + Err error +} + +// Error implements error. +func (e *TaskProcessWaitError) Error() string { + if e == nil || e.Err == nil { + return "" + } + return e.Err.Error() +} + +// Unwrap returns the underlying wait error. +func (e *TaskProcessWaitError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +// TaskProcessOutput requests the captured output of a managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessOutput{ID: "proc-1"}) +type TaskProcessOutput struct { + // ID identifies a managed process started by this service. + ID string +} + +// TaskProcessInput writes data to the stdin of a managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessInput{ID: "proc-1", Input: "hello\n"}) +type TaskProcessInput struct { + // ID identifies a managed process started by this service. + ID string + // Input is written verbatim to the process stdin pipe. + Input string +} + +// TaskProcessCloseStdin closes the stdin pipe of a managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessCloseStdin{ID: "proc-1"}) +type TaskProcessCloseStdin struct { + // ID identifies a managed process started by this service. + ID string +} + +// TaskProcessList requests a snapshot of managed processes through Core.PERFORM. +// If RunningOnly is true, only active processes are returned. +// +// Example: +// +// c.PERFORM(process.TaskProcessList{RunningOnly: true}) +type TaskProcessList struct { + RunningOnly bool +} + +// TaskProcessRemove removes a completed managed process through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessRemove{ID: "proc-1"}) +type TaskProcessRemove struct { + // ID identifies a managed process started by this service. + ID string +} + +// TaskProcessClear removes all completed managed processes through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessClear{}) +type TaskProcessClear struct{} + // ActionProcessStarted is broadcast when a process begins execution. +// +// Example: +// +// case process.ActionProcessStarted: fmt.Println("started", msg.ID) type ActionProcessStarted struct { ID string Command string @@ -21,6 +200,10 @@ type ActionProcessStarted struct { // ActionProcessOutput is broadcast for each line of output. // Subscribe to this for real-time streaming. +// +// Example: +// +// case process.ActionProcessOutput: fmt.Println(msg.Line) type ActionProcessOutput struct { ID string Line string @@ -29,126 +212,23 @@ type ActionProcessOutput struct { // ActionProcessExited is broadcast when a process completes. // Check ExitCode for success (0) or failure. +// +// Example: +// +// case process.ActionProcessExited: fmt.Println(msg.ExitCode) type ActionProcessExited struct { ID string ExitCode int Duration time.Duration - Error error // Non-nil if failed to start or was killed + Error error // Set for failed starts, non-zero exits, or killed processes. } // ActionProcessKilled is broadcast when a process is terminated. +// +// Example: +// +// case process.ActionProcessKilled: fmt.Println(msg.Signal) type ActionProcessKilled struct { ID string Signal string } - -// --- Core Action Handlers --------------------------------------------------- - -func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result { - command := opts.String("command") - if command == "" { - return core.Result{Value: core.E("process.run", "command is required", nil), OK: false} - } - - runOpts := RunOptions{ - Command: command, - Dir: opts.String("dir"), - } - if r := opts.Get("args"); r.OK { - runOpts.Args = optionStrings(r.Value) - } - if r := opts.Get("env"); r.OK { - runOpts.Env = optionStrings(r.Value) - } - - return s.runCommand(ctx, runOpts) -} - -func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result { - command := opts.String("command") - if command == "" { - return core.Result{Value: core.E("process.start", "command is required", nil), OK: false} - } - - runOpts := RunOptions{ - Command: command, - Dir: opts.String("dir"), - Detach: opts.Bool("detach"), - } - if r := opts.Get("args"); r.OK { - runOpts.Args = optionStrings(r.Value) - } - if r := opts.Get("env"); r.OK { - runOpts.Env = optionStrings(r.Value) - } - - r := s.StartWithOptions(ctx, runOpts) - if !r.OK { - return r - } - return core.Result{Value: r.Value.(*ManagedProcess).ID, OK: true} -} - -func (s *Service) handleKill(_ context.Context, opts core.Options) core.Result { - id := opts.String("id") - if id != "" { - if err := s.Kill(id); err != nil { - if core.Is(err, ErrProcessNotFound) { - return core.Result{Value: core.E("process.kill", core.Concat("not found: ", id), nil), OK: false} - } - return core.Result{Value: core.E("process.kill", core.Concat("kill failed: ", id), err), OK: false} - } - return core.Result{OK: true} - } - - pid := opts.Int("pid") - if pid > 0 { - proc, err := processHandle(pid) - if err != nil { - return core.Result{Value: core.E("process.kill", core.Concat("find pid failed: ", core.Sprintf("%d", pid)), err), OK: false} - } - if err := proc.Signal(syscall.SIGTERM); err != nil { - return core.Result{Value: core.E("process.kill", core.Concat("signal failed: ", core.Sprintf("%d", pid)), err), OK: false} - } - return core.Result{OK: true} - } - - return core.Result{Value: core.E("process.kill", "need id or pid", nil), OK: false} -} - -func (s *Service) handleList(_ context.Context, _ core.Options) core.Result { - return core.Result{Value: s.managed.Names(), OK: true} -} - -func (s *Service) handleGet(_ context.Context, opts core.Options) core.Result { - id := opts.String("id") - if id == "" { - return core.Result{Value: core.E("process.get", "id is required", nil), OK: false} - } - proc, err := s.Get(id) - if err != nil { - return core.Result{Value: core.E("process.get", core.Concat("not found: ", id), err), OK: false} - } - return core.Result{Value: proc.Info(), OK: true} -} - -func optionStrings(value any) []string { - switch typed := value.(type) { - case nil: - return nil - case []string: - return append([]string(nil), typed...) - case []any: - result := make([]string, 0, len(typed)) - for _, item := range typed { - text, ok := item.(string) - if !ok { - return nil - } - result = append(result, text) - } - return result - default: - return nil - } -} diff --git a/buffer.go b/buffer.go index 761f80b..88c61a2 100644 --- a/buffer.go +++ b/buffer.go @@ -4,8 +4,6 @@ import "sync" // RingBuffer is a fixed-size circular buffer that overwrites old data. // Thread-safe for concurrent reads and writes. -// -// rb := process.NewRingBuffer(1024) type RingBuffer struct { data []byte size int @@ -16,13 +14,10 @@ type RingBuffer struct { } // NewRingBuffer creates a ring buffer with the given capacity. -// -// rb := process.NewRingBuffer(256) func NewRingBuffer(size int) *RingBuffer { - if size <= 0 { - size = 1 + if size < 0 { + size = 0 } - return &RingBuffer{ data: make([]byte, size), size: size, @@ -34,6 +29,10 @@ func (rb *RingBuffer) Write(p []byte) (n int, err error) { rb.mu.Lock() defer rb.mu.Unlock() + if rb.size == 0 { + return len(p), nil + } + for _, b := range p { rb.data[rb.end] = b rb.end = (rb.end + 1) % rb.size diff --git a/buffer_test.go b/buffer_test.go index 2c54cbd..59443bc 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -69,4 +69,18 @@ func TestRingBuffer_Basics_Good(t *testing.T) { bytes[0] = 'x' assert.Equal(t, "hello", rb.String()) }) + + t.Run("zero or negative capacity is a no-op", func(t *testing.T) { + for _, size := range []int{0, -1} { + rb := NewRingBuffer(size) + + n, err := rb.Write([]byte("discarded")) + assert.NoError(t, err) + assert.Equal(t, len("discarded"), n) + assert.Equal(t, 0, rb.Cap()) + assert.Equal(t, 0, rb.Len()) + assert.Equal(t, "", rb.String()) + assert.Nil(t, rb.Bytes()) + } + }) } diff --git a/daemon.go b/daemon.go index a2173ee..6199fc6 100644 --- a/daemon.go +++ b/daemon.go @@ -2,15 +2,22 @@ package process import ( "context" + "errors" + "os" "sync" "time" - "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) // DaemonOptions configures daemon mode execution. // -// opts := process.DaemonOptions{PIDFile: "/tmp/process.pid", HealthAddr: "127.0.0.1:0"} +// Example: +// +// opts := process.DaemonOptions{ +// PIDFile: "/var/run/myapp.pid", +// HealthAddr: "127.0.0.1:0", +// } type DaemonOptions struct { // PIDFile path for single-instance enforcement. // Leave empty to skip PID file management. @@ -32,13 +39,11 @@ type DaemonOptions struct { Registry *Registry // RegistryEntry provides the code and daemon name for registration. - // PID, Health, and Started are filled automatically. + // PID, Health, Project, Binary, and Started are filled automatically. RegistryEntry DaemonEntry } // Daemon manages daemon lifecycle: PID file, health server, graceful shutdown. -// -// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"}) type Daemon struct { opts DaemonOptions pid *PIDFile @@ -49,7 +54,9 @@ type Daemon struct { // NewDaemon creates a daemon runner with the given options. // -// daemon := process.NewDaemon(process.DaemonOptions{PIDFile: "/tmp/process.pid"}) +// Example: +// +// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"}) func NewDaemon(opts DaemonOptions) *Daemon { if opts.ShutdownTimeout == 0 { opts.ShutdownTimeout = 30 * time.Second @@ -72,12 +79,16 @@ func NewDaemon(opts DaemonOptions) *Daemon { } // Start initialises the daemon (PID file, health server). +// +// Example: +// +// if err := daemon.Start(); err != nil { return err } func (d *Daemon) Start() error { d.mu.Lock() defer d.mu.Unlock() if d.running { - return core.E("daemon.start", "daemon already running", nil) + return coreerr.E("Daemon.Start", "daemon already running", nil) } if d.pid != nil { @@ -95,38 +106,52 @@ func (d *Daemon) Start() error { } } - d.running = true - // Auto-register if registry is set if d.opts.Registry != nil { entry := d.opts.RegistryEntry - entry.PID = currentPID() + entry.PID = os.Getpid() if d.health != nil { entry.Health = d.health.Addr() } + if entry.Project == "" { + if wd, err := os.Getwd(); err == nil { + entry.Project = wd + } + } + if entry.Binary == "" { + if binary, err := os.Executable(); err == nil { + entry.Binary = binary + } + } if err := d.opts.Registry.Register(entry); err != nil { if d.health != nil { - shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout) - _ = d.health.Stop(shutdownCtx) - cancel() + _ = d.health.Stop(context.Background()) } if d.pid != nil { _ = d.pid.Release() } - d.running = false - return core.E("daemon.start", "registry", err) + return coreerr.E("Daemon.Start", "registry", err) } } + d.running = true return nil } // Run blocks until the context is cancelled. +// +// Example: +// +// if err := daemon.Run(ctx); err != nil { return err } func (d *Daemon) Run(ctx context.Context) error { + if ctx == nil { + return coreerr.E("Daemon.Run", "daemon context is required", ErrDaemonContextRequired) + } + d.mu.Lock() if !d.running { d.mu.Unlock() - return core.E("daemon.run", "daemon not started - call Start() first", nil) + return coreerr.E("Daemon.Run", "daemon not started - call Start() first", nil) } d.mu.Unlock() @@ -136,6 +161,10 @@ func (d *Daemon) Run(ctx context.Context) error { } // Stop performs graceful shutdown. +// +// Example: +// +// _ = daemon.Stop() func (d *Daemon) Stop() error { d.mu.Lock() defer d.mu.Unlock() @@ -149,45 +178,75 @@ func (d *Daemon) Stop() error { shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout) defer cancel() + // Mark the daemon unavailable before tearing down listeners or registry state. if d.health != nil { d.health.SetReady(false) + } + + if d.health != nil { if err := d.health.Stop(shutdownCtx); err != nil { - errs = append(errs, core.E("daemon.stop", "health server", err)) + errs = append(errs, coreerr.E("Daemon.Stop", "health server", err)) } } if d.pid != nil { - if err := d.pid.Release(); err != nil && !isNotExist(err) { - errs = append(errs, core.E("daemon.stop", "pid file", err)) + if err := d.pid.Release(); err != nil && !os.IsNotExist(err) { + errs = append(errs, coreerr.E("Daemon.Stop", "pid file", err)) } } - // Auto-unregister + // Auto-unregister after the daemon has stopped serving traffic and + // relinquished its PID file. if d.opts.Registry != nil { if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil { - errs = append(errs, core.E("daemon.stop", "registry", err)) + errs = append(errs, coreerr.E("Daemon.Stop", "registry", err)) } } d.running = false if len(errs) > 0 { - return core.ErrorJoin(errs...) + return errors.Join(errs...) } return nil } -// SetReady sets the daemon readiness status for health checks. +// SetReady sets the daemon readiness status for `/ready`. +// +// Example: +// +// daemon.SetReady(false) func (d *Daemon) SetReady(ready bool) { if d.health != nil { d.health.SetReady(ready) } } +// Ready reports whether the daemon is currently ready for traffic. +// +// Example: +// +// if daemon.Ready() { +// // expose the service to callers +// } +func (d *Daemon) Ready() bool { + if d.health != nil { + return d.health.Ready() + } + return false +} + // HealthAddr returns the health server address, or empty if disabled. +// +// Example: +// +// addr := daemon.HealthAddr() func (d *Daemon) HealthAddr() string { if d.health != nil { return d.health.Addr() } return "" } + +// ErrDaemonContextRequired is returned when Run is called without a context. +var ErrDaemonContextRequired = coreerr.E("", "daemon context is required", nil) diff --git a/daemon_test.go b/daemon_test.go index 0bfb27d..57c2cc6 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -4,16 +4,17 @@ import ( "context" "net/http" "os" + "path/filepath" + "sync" "testing" "time" - "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestDaemon_Lifecycle_Good(t *testing.T) { - pidPath := core.JoinPath(t.TempDir(), "test.pid") +func TestDaemon_StartAndStop(t *testing.T) { + pidPath := filepath.Join(t.TempDir(), "test.pid") d := NewDaemon(DaemonOptions{ PIDFile: pidPath, @@ -36,7 +37,166 @@ func TestDaemon_Lifecycle_Good(t *testing.T) { require.NoError(t, err) } -func TestDaemon_AlreadyRunning_Bad(t *testing.T) { +func TestDaemon_StopMarksNotReadyBeforeShutdownCompletes(t *testing.T) { + blockCheck := make(chan struct{}) + checkEntered := make(chan struct{}) + var once sync.Once + + d := NewDaemon(DaemonOptions{ + HealthAddr: "127.0.0.1:0", + ShutdownTimeout: 5 * time.Second, + HealthChecks: []HealthCheck{ + func() error { + once.Do(func() { close(checkEntered) }) + <-blockCheck + return nil + }, + }, + }) + + err := d.Start() + require.NoError(t, err) + + addr := d.HealthAddr() + require.NotEmpty(t, addr) + + healthErr := make(chan error, 1) + go func() { + resp, err := http.Get("http://" + addr + "/health") + if err != nil { + healthErr <- err + return + } + _ = resp.Body.Close() + healthErr <- nil + }() + + select { + case <-checkEntered: + case <-time.After(2 * time.Second): + t.Fatal("/health request did not enter the blocking check") + } + + stopDone := make(chan error, 1) + go func() { + stopDone <- d.Stop() + }() + + require.Eventually(t, func() bool { + return !d.Ready() + }, 500*time.Millisecond, 10*time.Millisecond, "daemon should become not ready before shutdown completes") + + select { + case err := <-stopDone: + t.Fatalf("daemon stopped too early: %v", err) + default: + } + + close(blockCheck) + + select { + case err := <-stopDone: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("daemon stop did not finish after health check unblocked") + } + + select { + case err := <-healthErr: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("/health request did not finish") + } +} + +func TestDaemon_StopUnregistersAfterHealthShutdownCompletes(t *testing.T) { + blockCheck := make(chan struct{}) + checkEntered := make(chan struct{}) + var once sync.Once + dir := t.TempDir() + reg := NewRegistry(filepath.Join(dir, "registry")) + + d := NewDaemon(DaemonOptions{ + HealthAddr: "127.0.0.1:0", + ShutdownTimeout: 5 * time.Second, + Registry: reg, + RegistryEntry: DaemonEntry{ + Code: "test-app", + Daemon: "serve", + }, + HealthChecks: []HealthCheck{ + func() error { + once.Do(func() { close(checkEntered) }) + <-blockCheck + return nil + }, + }, + }) + + err := d.Start() + require.NoError(t, err) + + addr := d.HealthAddr() + require.NotEmpty(t, addr) + + healthErr := make(chan error, 1) + go func() { + resp, err := http.Get("http://" + addr + "/health") + if err != nil { + healthErr <- err + return + } + _ = resp.Body.Close() + healthErr <- nil + }() + + select { + case <-checkEntered: + case <-time.After(2 * time.Second): + t.Fatal("/health request did not enter the blocking check") + } + + stopDone := make(chan error, 1) + go func() { + stopDone <- d.Stop() + }() + + require.Eventually(t, func() bool { + return !d.Ready() + }, 500*time.Millisecond, 10*time.Millisecond, "daemon should become not ready before shutdown completes") + + _, ok := reg.Get("test-app", "serve") + assert.True(t, ok, "daemon should remain registered until health shutdown completes") + + select { + case err := <-stopDone: + t.Fatalf("daemon stopped too early: %v", err) + default: + } + + close(blockCheck) + + select { + case err := <-stopDone: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("daemon stop did not finish after health check unblocked") + } + + require.Eventually(t, func() bool { + _, ok := reg.Get("test-app", "serve") + return !ok + }, 500*time.Millisecond, 10*time.Millisecond, "daemon should unregister after health shutdown completes") + + select { + case err := <-healthErr: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("/health request did not finish") + } +} + +func TestDaemon_DoubleStartFails(t *testing.T) { d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", }) @@ -50,7 +210,7 @@ func TestDaemon_AlreadyRunning_Bad(t *testing.T) { assert.Contains(t, err.Error(), "already running") } -func TestDaemon_RunUnstarted_Bad(t *testing.T) { +func TestDaemon_RunWithoutStartFails(t *testing.T) { d := NewDaemon(DaemonOptions{}) ctx, cancel := context.WithCancel(context.Background()) @@ -61,7 +221,15 @@ func TestDaemon_RunUnstarted_Bad(t *testing.T) { assert.Contains(t, err.Error(), "not started") } -func TestDaemon_SetReady_Good(t *testing.T) { +func TestDaemon_RunNilContextFails(t *testing.T) { + d := NewDaemon(DaemonOptions{}) + + err := d.Run(nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrDaemonContextRequired) +} + +func TestDaemon_SetReady(t *testing.T) { d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", }) @@ -75,25 +243,32 @@ func TestDaemon_SetReady_Good(t *testing.T) { resp, _ := http.Get("http://" + addr + "/ready") assert.Equal(t, http.StatusOK, resp.StatusCode) _ = resp.Body.Close() + assert.True(t, d.Ready()) d.SetReady(false) + assert.False(t, d.Ready()) resp, _ = http.Get("http://" + addr + "/ready") assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) _ = resp.Body.Close() } -func TestDaemon_HealthAddrDisabled_Good(t *testing.T) { +func TestDaemon_ReadyWithoutHealthServer(t *testing.T) { + d := NewDaemon(DaemonOptions{}) + assert.False(t, d.Ready()) +} + +func TestDaemon_NoHealthAddrReturnsEmpty(t *testing.T) { d := NewDaemon(DaemonOptions{}) assert.Empty(t, d.HealthAddr()) } -func TestDaemon_DefaultTimeout_Good(t *testing.T) { +func TestDaemon_DefaultShutdownTimeout(t *testing.T) { d := NewDaemon(DaemonOptions{}) assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout) } -func TestDaemon_RunBlocking_Good(t *testing.T) { +func TestDaemon_RunBlocksUntilCancelled(t *testing.T) { d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", }) @@ -126,7 +301,7 @@ func TestDaemon_RunBlocking_Good(t *testing.T) { } } -func TestDaemon_StopIdempotent_Good(t *testing.T) { +func TestDaemon_StopIdempotent(t *testing.T) { d := NewDaemon(DaemonOptions{}) // Stop without Start should be a no-op @@ -134,9 +309,13 @@ func TestDaemon_StopIdempotent_Good(t *testing.T) { assert.NoError(t, err) } -func TestDaemon_AutoRegister_Good(t *testing.T) { +func TestDaemon_AutoRegisters(t *testing.T) { dir := t.TempDir() - reg := NewRegistry(core.JoinPath(dir, "daemons")) + reg := NewRegistry(filepath.Join(dir, "daemons")) + wd, err := os.Getwd() + require.NoError(t, err) + exe, err := os.Executable() + require.NoError(t, err) d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", @@ -147,7 +326,7 @@ func TestDaemon_AutoRegister_Good(t *testing.T) { }, }) - err := d.Start() + err = d.Start() require.NoError(t, err) // Should be registered @@ -155,6 +334,8 @@ func TestDaemon_AutoRegister_Good(t *testing.T) { require.True(t, ok) assert.Equal(t, os.Getpid(), entry.PID) assert.NotEmpty(t, entry.Health) + assert.Equal(t, wd, entry.Project) + assert.Equal(t, exe, entry.Binary) // Stop should unregister err = d.Stop() @@ -163,3 +344,40 @@ func TestDaemon_AutoRegister_Good(t *testing.T) { _, ok = reg.Get("test-app", "serve") assert.False(t, ok) } + +func TestDaemon_StartRollsBackOnRegistryFailure(t *testing.T) { + dir := t.TempDir() + + pidPath := filepath.Join(dir, "daemon.pid") + regDir := filepath.Join(dir, "registry") + require.NoError(t, os.MkdirAll(regDir, 0o755)) + require.NoError(t, os.Chmod(regDir, 0o555)) + + d := NewDaemon(DaemonOptions{ + PIDFile: pidPath, + HealthAddr: "127.0.0.1:0", + Registry: NewRegistry(regDir), + RegistryEntry: DaemonEntry{ + Code: "broken", + Daemon: "start", + }, + }) + + err := d.Start() + require.Error(t, err) + + _, statErr := os.Stat(pidPath) + assert.True(t, os.IsNotExist(statErr)) + + addr := d.HealthAddr() + require.NotEmpty(t, addr) + + client := &http.Client{Timeout: 250 * time.Millisecond} + resp, reqErr := client.Get("http://" + addr + "/health") + if resp != nil { + _ = resp.Body.Close() + } + assert.Error(t, reqErr) + + assert.NoError(t, d.Stop()) +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..ba540e6 --- /dev/null +++ b/errors.go @@ -0,0 +1,12 @@ +package process + +import coreerr "dappco.re/go/core/log" + +// ServiceError wraps a service-level failure with a message string. +// +// Example: +// +// return process.ServiceError("context is required", process.ErrContextRequired) +func ServiceError(message string, err error) error { + return coreerr.E("ServiceError", message, err) +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..61d3f42 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,15 @@ +package process + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceError(t *testing.T) { + err := ServiceError("service failed", ErrContextRequired) + require.Error(t, err) + assert.Contains(t, err.Error(), "service failed") + assert.ErrorIs(t, err, ErrContextRequired) +} diff --git a/exec/exec.go b/exec/exec.go index 368979c..5e76ada 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -3,27 +3,34 @@ package exec import ( "bytes" "context" - "io" + "fmt" "os" "os/exec" + "strings" - "dappco.re/go/core" + coreerr "dappco.re/go/core/log" + goio "io" ) +// ErrCommandContextRequired is returned when a command is created without a context. +var ErrCommandContextRequired = coreerr.E("", "exec: command context is required", nil) + // Options configures command execution. -// -// opts := exec.Options{Dir: "/workspace", Env: []string{"CI=1"}} type Options struct { Dir string Env []string - Stdin io.Reader - Stdout io.Writer - Stderr io.Writer + Stdin goio.Reader + Stdout goio.Writer + Stderr goio.Writer + // Background runs the command asynchronously and returns from Run immediately. + Background bool } -// Command wraps `os/exec.Command` with logging and context. +// Command wraps os/exec.Command with logging and context. // -// cmd := exec.Command(ctx, "git", "status").WithDir("/workspace") +// Example: +// +// cmd := exec.Command(ctx, "go", "test", "./...") func Command(ctx context.Context, name string, args ...string) *Cmd { return &Cmd{ name: name, @@ -43,31 +50,51 @@ type Cmd struct { } // WithDir sets the working directory. +// +// Example: +// +// cmd.WithDir("/tmp") func (c *Cmd) WithDir(dir string) *Cmd { c.opts.Dir = dir return c } // WithEnv sets the environment variables. +// +// Example: +// +// cmd.WithEnv([]string{"CGO_ENABLED=0"}) func (c *Cmd) WithEnv(env []string) *Cmd { c.opts.Env = env return c } // WithStdin sets stdin. -func (c *Cmd) WithStdin(r io.Reader) *Cmd { +// +// Example: +// +// cmd.WithStdin(strings.NewReader("input")) +func (c *Cmd) WithStdin(r goio.Reader) *Cmd { c.opts.Stdin = r return c } // WithStdout sets stdout. -func (c *Cmd) WithStdout(w io.Writer) *Cmd { +// +// Example: +// +// cmd.WithStdout(os.Stdout) +func (c *Cmd) WithStdout(w goio.Writer) *Cmd { c.opts.Stdout = w return c } // WithStderr sets stderr. -func (c *Cmd) WithStderr(w io.Writer) *Cmd { +// +// Example: +// +// cmd.WithStderr(os.Stderr) +func (c *Cmd) WithStderr(w goio.Writer) *Cmd { c.opts.Stderr = w return c } @@ -79,14 +106,56 @@ func (c *Cmd) WithLogger(l Logger) *Cmd { return c } +// WithBackground configures whether Run should wait for the command to finish. +func (c *Cmd) WithBackground(background bool) *Cmd { + c.opts.Background = background + return c +} + +// Start launches the command. +// +// Example: +// +// if err := cmd.Start(); err != nil { return err } +func (c *Cmd) Start() error { + if err := c.prepare(); err != nil { + return err + } + c.logDebug("executing command") + + if err := c.cmd.Start(); err != nil { + wrapped := wrapError("Cmd.Start", err, c.name, c.args) + c.logError("command failed", wrapped) + return wrapped + } + + if c.opts.Background { + go func(cmd *exec.Cmd) { + _ = cmd.Wait() + }(c.cmd) + } + + return nil +} + // Run executes the command and waits for it to finish. // It automatically logs the command execution at debug level. +// +// Example: +// +// if err := cmd.Run(); err != nil { return err } func (c *Cmd) Run() error { - c.prepare() + if c.opts.Background { + return c.Start() + } + + if err := c.prepare(); err != nil { + return err + } c.logDebug("executing command") if err := c.cmd.Run(); err != nil { - wrapped := wrapError("exec.cmd.run", err, c.name, c.args) + wrapped := wrapError("Cmd.Run", err, c.name, c.args) c.logError("command failed", wrapped) return wrapped } @@ -94,13 +163,23 @@ func (c *Cmd) Run() error { } // Output runs the command and returns its standard output. +// +// Example: +// +// out, err := cmd.Output() func (c *Cmd) Output() ([]byte, error) { - c.prepare() + if c.opts.Background { + return nil, coreerr.E("Cmd.Output", "background execution is incompatible with Output", nil) + } + + if err := c.prepare(); err != nil { + return nil, err + } c.logDebug("executing command") out, err := c.cmd.Output() if err != nil { - wrapped := wrapError("exec.cmd.output", err, c.name, c.args) + wrapped := wrapError("Cmd.Output", err, c.name, c.args) c.logError("command failed", wrapped) return nil, wrapped } @@ -108,26 +187,35 @@ func (c *Cmd) Output() ([]byte, error) { } // CombinedOutput runs the command and returns its combined standard output and standard error. +// +// Example: +// +// out, err := cmd.CombinedOutput() func (c *Cmd) CombinedOutput() ([]byte, error) { - c.prepare() + if c.opts.Background { + return nil, coreerr.E("Cmd.CombinedOutput", "background execution is incompatible with CombinedOutput", nil) + } + + if err := c.prepare(); err != nil { + return nil, err + } c.logDebug("executing command") out, err := c.cmd.CombinedOutput() if err != nil { - wrapped := wrapError("exec.cmd.combined_output", err, c.name, c.args) + wrapped := wrapError("Cmd.CombinedOutput", err, c.name, c.args) c.logError("command failed", wrapped) return out, wrapped } return out, nil } -func (c *Cmd) prepare() { - ctx := c.ctx - if ctx == nil { - ctx = context.Background() +func (c *Cmd) prepare() error { + if c.ctx == nil { + return coreerr.E("Cmd.prepare", "exec: command context is required", ErrCommandContextRequired) } - c.cmd = exec.CommandContext(ctx, c.name, c.args...) + c.cmd = exec.CommandContext(c.ctx, c.name, c.args...) c.cmd.Dir = c.opts.Dir if len(c.opts.Env) > 0 { @@ -137,27 +225,31 @@ func (c *Cmd) prepare() { c.cmd.Stdin = c.opts.Stdin c.cmd.Stdout = c.opts.Stdout c.cmd.Stderr = c.opts.Stderr + return nil } // RunQuiet executes the command suppressing stdout unless there is an error. // Useful for internal commands. // -// _ = exec.RunQuiet(ctx, "go", "test", "./...") +// Example: +// +// err := exec.RunQuiet(ctx, "go", "vet", "./...") func RunQuiet(ctx context.Context, name string, args ...string) error { var stderr bytes.Buffer cmd := Command(ctx, name, args...).WithStderr(&stderr) if err := cmd.Run(); err != nil { - return core.E("exec.run_quiet", core.Trim(stderr.String()), err) + // Include stderr in error message + return coreerr.E("RunQuiet", strings.TrimSpace(stderr.String()), err) } return nil } func wrapError(caller string, err error, name string, args []string) error { - cmdStr := commandString(name, args) + cmdStr := name + " " + strings.Join(args, " ") if exitErr, ok := err.(*exec.ExitError); ok { - return core.E(caller, core.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err) + return coreerr.E(caller, fmt.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err) } - return core.E(caller, core.Sprintf("failed to execute %q", cmdStr), err) + return coreerr.E(caller, fmt.Sprintf("failed to execute %q", cmdStr), err) } func (c *Cmd) getLogger() Logger { @@ -168,17 +260,9 @@ func (c *Cmd) getLogger() Logger { } func (c *Cmd) logDebug(msg string) { - c.getLogger().Debug(msg, "cmd", c.name, "args", core.Join(" ", c.args...)) + c.getLogger().Debug(msg, "cmd", c.name, "args", strings.Join(c.args, " ")) } func (c *Cmd) logError(msg string, err error) { - c.getLogger().Error(msg, "cmd", c.name, "args", core.Join(" ", c.args...), "err", err) -} - -func commandString(name string, args []string) string { - if len(args) == 0 { - return name - } - parts := append([]string{name}, args...) - return core.Join(" ", parts...) + c.getLogger().Error(msg, "cmd", c.name, "args", strings.Join(c.args, " "), "err", err) } diff --git a/exec/exec_test.go b/exec/exec_test.go index c3323f0..d5a498e 100644 --- a/exec/exec_test.go +++ b/exec/exec_test.go @@ -2,10 +2,17 @@ package exec_test import ( "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" "testing" + "time" - "dappco.re/go/core" "dappco.re/go/core/process/exec" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // mockLogger captures log calls for testing @@ -27,7 +34,7 @@ func (m *mockLogger) Error(msg string, keyvals ...any) { m.errorCalls = append(m.errorCalls, logCall{msg, keyvals}) } -func TestCommand_Run_Good(t *testing.T) { +func TestCommand_Run_Good_LogsDebug(t *testing.T) { logger := &mockLogger{} ctx := context.Background() @@ -49,7 +56,7 @@ func TestCommand_Run_Good(t *testing.T) { } } -func TestCommand_Run_Bad(t *testing.T) { +func TestCommand_Run_Bad_LogsError(t *testing.T) { logger := &mockLogger{} ctx := context.Background() @@ -71,14 +78,6 @@ func TestCommand_Run_Bad(t *testing.T) { } } -func TestCommand_Run_WithNilContext_Good(t *testing.T) { - var ctx context.Context - - if err := exec.Command(ctx, "echo", "hello").Run(); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - func TestCommand_Output_Good(t *testing.T) { logger := &mockLogger{} ctx := context.Background() @@ -89,7 +88,7 @@ func TestCommand_Output_Good(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if core.Trim(string(out)) != "test" { + if strings.TrimSpace(string(out)) != "test" { t.Errorf("expected 'test', got %q", string(out)) } if len(logger.debugCalls) != 1 { @@ -107,7 +106,7 @@ func TestCommand_CombinedOutput_Good(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if core.Trim(string(out)) != "combined" { + if strings.TrimSpace(string(out)) != "combined" { t.Errorf("expected 'combined', got %q", string(out)) } if len(logger.debugCalls) != 1 { @@ -115,14 +114,14 @@ func TestCommand_CombinedOutput_Good(t *testing.T) { } } -func TestNopLogger_Methods_Good(t *testing.T) { +func TestNopLogger(t *testing.T) { // Verify NopLogger doesn't panic var nop exec.NopLogger nop.Debug("msg", "key", "val") nop.Error("msg", "key", "val") } -func TestLogger_SetDefault_Good(t *testing.T) { +func TestSetDefaultLogger(t *testing.T) { original := exec.DefaultLogger() defer exec.SetDefaultLogger(original) @@ -140,7 +139,30 @@ func TestLogger_SetDefault_Good(t *testing.T) { } } -func TestCommand_UsesDefaultLogger_Good(t *testing.T) { +func TestDefaultLogger_IsConcurrentSafe(t *testing.T) { + original := exec.DefaultLogger() + defer exec.SetDefaultLogger(original) + + logger := &mockLogger{} + + var wg sync.WaitGroup + for i := 0; i < 32; i++ { + wg.Add(2) + go func() { + defer wg.Done() + exec.SetDefaultLogger(logger) + }() + go func() { + defer wg.Done() + _ = exec.DefaultLogger() + }() + } + wg.Wait() + + assert.NotNil(t, exec.DefaultLogger()) +} + +func TestCommand_UsesDefaultLogger(t *testing.T) { original := exec.DefaultLogger() defer exec.SetDefaultLogger(original) @@ -155,7 +177,7 @@ func TestCommand_UsesDefaultLogger_Good(t *testing.T) { } } -func TestCommand_WithDir_Good(t *testing.T) { +func TestCommand_WithDir(t *testing.T) { ctx := context.Background() out, err := exec.Command(ctx, "pwd"). WithDir("/tmp"). @@ -164,13 +186,13 @@ func TestCommand_WithDir_Good(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - trimmed := core.Trim(string(out)) + trimmed := strings.TrimSpace(string(out)) if trimmed != "/tmp" && trimmed != "/private/tmp" { t.Errorf("expected /tmp or /private/tmp, got %q", trimmed) } } -func TestCommand_WithEnv_Good(t *testing.T) { +func TestCommand_WithEnv(t *testing.T) { ctx := context.Background() out, err := exec.Command(ctx, "sh", "-c", "echo $TEST_EXEC_VAR"). WithEnv([]string{"TEST_EXEC_VAR=exec_val"}). @@ -179,32 +201,100 @@ func TestCommand_WithEnv_Good(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if core.Trim(string(out)) != "exec_val" { + if strings.TrimSpace(string(out)) != "exec_val" { t.Errorf("expected 'exec_val', got %q", string(out)) } } -func TestCommand_WithStdinStdoutStderr_Good(t *testing.T) { +func TestCommand_WithStdinStdoutStderr(t *testing.T) { ctx := context.Background() - input := core.NewReader("piped input\n") - stdout := core.NewBuilder() - stderr := core.NewBuilder() + input := strings.NewReader("piped input\n") + var stdout, stderr strings.Builder err := exec.Command(ctx, "cat"). WithStdin(input). - WithStdout(stdout). - WithStderr(stderr). + WithStdout(&stdout). + WithStderr(&stderr). WithLogger(&mockLogger{}). Run() if err != nil { t.Fatalf("unexpected error: %v", err) } - if core.Trim(stdout.String()) != "piped input" { + if strings.TrimSpace(stdout.String()) != "piped input" { t.Errorf("expected 'piped input', got %q", stdout.String()) } } -func TestRunQuiet_Command_Good(t *testing.T) { +func TestCommand_Run_Background(t *testing.T) { + logger := &mockLogger{} + ctx := context.Background() + dir := t.TempDir() + marker := filepath.Join(dir, "marker.txt") + + start := time.Now() + err := exec.Command(ctx, "sh", "-c", fmt.Sprintf("sleep 0.2; printf done > %q", marker)). + WithBackground(true). + WithLogger(logger). + Run() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if elapsed := time.Since(start); elapsed > 100*time.Millisecond { + t.Fatalf("background run took too long: %s", elapsed) + } + + deadline := time.Now().Add(2 * time.Second) + for { + data, readErr := os.ReadFile(marker) + if readErr == nil && strings.TrimSpace(string(data)) == "done" { + break + } + if time.Now().After(deadline) { + t.Fatalf("background command did not create marker file") + } + time.Sleep(20 * time.Millisecond) + } +} + +func TestCommand_NilContextRejected(t *testing.T) { + t.Run("start", func(t *testing.T) { + err := exec.Command(nil, "echo", "test").Start() + require.Error(t, err) + assert.ErrorIs(t, err, exec.ErrCommandContextRequired) + }) + + t.Run("run", func(t *testing.T) { + err := exec.Command(nil, "echo", "test").Run() + require.Error(t, err) + assert.ErrorIs(t, err, exec.ErrCommandContextRequired) + }) + + t.Run("output", func(t *testing.T) { + _, err := exec.Command(nil, "echo", "test").Output() + require.Error(t, err) + assert.ErrorIs(t, err, exec.ErrCommandContextRequired) + }) + + t.Run("combined output", func(t *testing.T) { + _, err := exec.Command(nil, "echo", "test").CombinedOutput() + require.Error(t, err) + assert.ErrorIs(t, err, exec.ErrCommandContextRequired) + }) +} + +func TestCommand_Output_BackgroundRejected(t *testing.T) { + ctx := context.Background() + + _, err := exec.Command(ctx, "echo", "test"). + WithBackground(true). + Output() + if err == nil { + t.Fatal("expected error") + } +} + +func TestRunQuiet_Good(t *testing.T) { ctx := context.Background() err := exec.RunQuiet(ctx, "echo", "quiet") if err != nil { @@ -212,7 +302,7 @@ func TestRunQuiet_Command_Good(t *testing.T) { } } -func TestRunQuiet_Command_Bad(t *testing.T) { +func TestRunQuiet_Bad(t *testing.T) { ctx := context.Background() err := exec.RunQuiet(ctx, "sh", "-c", "echo fail >&2; exit 1") if err == nil { diff --git a/exec/logger.go b/exec/logger.go index ba9713e..5ff59e6 100644 --- a/exec/logger.go +++ b/exec/logger.go @@ -1,19 +1,23 @@ package exec +import "sync" + // Logger interface for command execution logging. // Compatible with pkg/log.Logger and other structured loggers. -// -// exec.SetDefaultLogger(myLogger) type Logger interface { // Debug logs a debug-level message with optional key-value pairs. + // + // Example: + // logger.Debug("starting", "cmd", "go") Debug(msg string, keyvals ...any) // Error logs an error-level message with optional key-value pairs. + // + // Example: + // logger.Error("failed", "cmd", "go", "err", err) Error(msg string, keyvals ...any) } // NopLogger is a no-op logger that discards all messages. -// -// var logger exec.NopLogger type NopLogger struct{} // Debug discards the message (no-op implementation). @@ -22,13 +26,23 @@ func (NopLogger) Debug(string, ...any) {} // Error discards the message (no-op implementation). func (NopLogger) Error(string, ...any) {} -var defaultLogger Logger = NopLogger{} +var _ Logger = NopLogger{} + +var ( + defaultLoggerMu sync.RWMutex + defaultLogger Logger = NopLogger{} +) // SetDefaultLogger sets the package-level default logger. // Commands without an explicit logger will use this. // -// exec.SetDefaultLogger(myLogger) +// Example: +// +// exec.SetDefaultLogger(logger) func SetDefaultLogger(l Logger) { + defaultLoggerMu.Lock() + defer defaultLoggerMu.Unlock() + if l == nil { l = NopLogger{} } @@ -37,7 +51,12 @@ func SetDefaultLogger(l Logger) { // DefaultLogger returns the current default logger. // +// Example: +// // logger := exec.DefaultLogger() func DefaultLogger() Logger { + defaultLoggerMu.RLock() + defer defaultLoggerMu.RUnlock() + return defaultLogger } diff --git a/global_test.go b/global_test.go new file mode 100644 index 0000000..f68ec92 --- /dev/null +++ b/global_test.go @@ -0,0 +1,456 @@ +package process + +import ( + "context" + "os/exec" + "sync" + "syscall" + "testing" + "time" + + framework "dappco.re/go/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGlobal_DefaultNotInitialized(t *testing.T) { + // Reset global state for this test + old := defaultService.Swap(nil) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + assert.Nil(t, Default()) + + _, err := Start(context.Background(), "echo", "test") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + _, err = Run(context.Background(), "echo", "test") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + _, err = Get("proc-1") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + _, err = Output("proc-1") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + err = Input("proc-1", "test") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + err = CloseStdin("proc-1") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + assert.Nil(t, List()) + assert.Nil(t, Running()) + + err = Remove("proc-1") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + // Clear is a no-op without a default service. + Clear() + + err = Kill("proc-1") + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + err = KillPID(1234) + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + _, err = StartWithOptions(context.Background(), RunOptions{Command: "echo"}) + assert.ErrorIs(t, err, ErrServiceNotInitialized) + + _, err = RunWithOptions(context.Background(), RunOptions{Command: "echo"}) + assert.ErrorIs(t, err, ErrServiceNotInitialized) +} + +func newGlobalTestService(t *testing.T) *Service { + t.Helper() + c := framework.New() + factory := NewService(Options{}) + raw, err := factory(c) + require.NoError(t, err) + return raw.(*Service) +} + +func TestGlobal_SetDefault(t *testing.T) { + t.Run("sets and retrieves service", func(t *testing.T) { + old := defaultService.Swap(nil) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + svc := newGlobalTestService(t) + + err := SetDefault(svc) + require.NoError(t, err) + assert.Equal(t, svc, Default()) + }) + + t.Run("errors on nil", func(t *testing.T) { + err := SetDefault(nil) + assert.Error(t, err) + }) +} + +func TestGlobal_Register(t *testing.T) { + c := framework.New() + + result := Register(c) + require.True(t, result.OK) + + svc, ok := result.Value.(*Service) + require.True(t, ok) + require.NotNil(t, svc) + assert.NotNil(t, svc.ServiceRuntime) + assert.Equal(t, DefaultBufferSize, svc.bufSize) +} + +func TestGlobal_ConcurrentDefault(t *testing.T) { + old := defaultService.Swap(nil) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + svc := newGlobalTestService(t) + + err := SetDefault(svc) + require.NoError(t, err) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + s := Default() + assert.NotNil(t, s) + assert.Equal(t, svc, s) + }() + } + wg.Wait() +} + +func TestGlobal_ConcurrentSetDefault(t *testing.T) { + old := defaultService.Swap(nil) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + var services []*Service + for i := 0; i < 10; i++ { + svc := newGlobalTestService(t) + services = append(services, svc) + } + + var wg sync.WaitGroup + for _, svc := range services { + wg.Add(1) + go func(s *Service) { + defer wg.Done() + _ = SetDefault(s) + }(svc) + } + wg.Wait() + + final := Default() + assert.NotNil(t, final) + + found := false + for _, svc := range services { + if svc == final { + found = true + break + } + } + assert.True(t, found, "Default should be one of the set services") +} + +func TestGlobal_ConcurrentOperations(t *testing.T) { + old := defaultService.Swap(nil) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + svc := newGlobalTestService(t) + + err := SetDefault(svc) + require.NoError(t, err) + + var wg sync.WaitGroup + var processes []*Process + var procMu sync.Mutex + + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + proc, err := Start(context.Background(), "echo", "concurrent") + if err == nil { + procMu.Lock() + processes = append(processes, proc) + procMu.Unlock() + } + }() + } + + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = List() + _ = Running() + }() + } + + wg.Wait() + + procMu.Lock() + for _, p := range processes { + <-p.Done() + } + procMu.Unlock() + + assert.Len(t, processes, 20) + + var wg2 sync.WaitGroup + for _, p := range processes { + wg2.Add(1) + go func(id string) { + defer wg2.Done() + got, err := Get(id) + assert.NoError(t, err) + assert.NotNil(t, got) + }(p.ID) + } + wg2.Wait() +} + +func TestGlobal_StartWithOptions(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := StartWithOptions(context.Background(), RunOptions{ + Command: "echo", + Args: []string{"with", "options"}, + }) + require.NoError(t, err) + + <-proc.Done() + + assert.Equal(t, 0, proc.ExitCode) + assert.Contains(t, proc.Output(), "with options") +} + +func TestGlobal_RunWithOptions(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + output, err := RunWithOptions(context.Background(), RunOptions{ + Command: "echo", + Args: []string{"run", "options"}, + }) + require.NoError(t, err) + assert.Contains(t, output, "run options") +} + +func TestGlobal_Output(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "echo", "global-output") + require.NoError(t, err) + <-proc.Done() + + output, err := Output(proc.ID) + require.NoError(t, err) + assert.Contains(t, output, "global-output") +} + +func TestGlobal_InputAndCloseStdin(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "cat") + require.NoError(t, err) + + err = Input(proc.ID, "global-input\n") + require.NoError(t, err) + + err = CloseStdin(proc.ID) + require.NoError(t, err) + + <-proc.Done() + + assert.Contains(t, proc.Output(), "global-input") +} + +func TestGlobal_Wait(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "echo", "global-wait") + require.NoError(t, err) + + info, err := Wait(proc.ID) + require.NoError(t, err) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 0, info.ExitCode) +} + +func TestGlobal_Signal(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + err = Signal(proc.ID, syscall.SIGTERM) + require.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been signalled through the global helper") + } +} + +func TestGlobal_SignalPID(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + err := SignalPID(cmd.Process.Pid, syscall.SIGTERM) + require.NoError(t, err) + + select { + case err := <-waitCh: + require.Error(t, err) + case <-time.After(2 * time.Second): + t.Fatal("unmanaged process should have been signalled through the global helper") + } +} + +func TestGlobal_Running(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + proc, err := Start(ctx, "sleep", "60") + require.NoError(t, err) + + running := Running() + assert.Len(t, running, 1) + assert.Equal(t, proc.ID, running[0].ID) + + cancel() + <-proc.Done() + + running = Running() + assert.Len(t, running, 0) +} + +func TestGlobal_RemoveAndClear(t *testing.T) { + svc, _ := newTestService(t) + + old := defaultService.Swap(svc) + defer func() { + if old != nil { + defaultService.Store(old) + } + }() + + proc, err := Start(context.Background(), "echo", "remove-me") + require.NoError(t, err) + <-proc.Done() + + err = Remove(proc.ID) + require.NoError(t, err) + + _, err = Get(proc.ID) + require.ErrorIs(t, err, ErrProcessNotFound) + + proc2, err := Start(context.Background(), "echo", "clear-me") + require.NoError(t, err) + <-proc2.Done() + + Clear() + + _, err = Get(proc2.ID) + require.ErrorIs(t, err, ErrProcessNotFound) +} diff --git a/go.mod b/go.mod index 74ee92e..25b5f51 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,17 @@ go 1.26.0 require ( dappco.re/go/core v0.8.0-alpha.1 + dappco.re/go/core/api v0.2.0 dappco.re/go/core/io v0.2.0 + dappco.re/go/core/log v0.1.0 dappco.re/go/core/ws v0.3.0 - dappco.re/go/core/api v0.1.5 github.com/gin-gonic/gin v1.12.0 + github.com/gorilla/websocket v1.5.3 github.com/stretchr/testify v1.11.1 ) require ( - dappco.re/go/core/log v0.1.0 // indirect - dappco.re/go/core/io v0.1.5 // indirect - dappco.re/go/core/log v0.0.4 // indirect + forge.lthn.ai/core/go-log v0.0.4 // indirect github.com/99designs/gqlgen v0.17.88 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect @@ -67,7 +67,6 @@ require ( github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.4.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect diff --git a/go.sum b/go.sum index 5cf7b04..2db9f1d 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,13 @@ dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/api v0.2.0 h1:5OcN9nawpp18Jp6dB1OwI2CBfs0Tacb0y0zqxFB6TJ0= +dappco.re/go/core/api v0.2.0/go.mod h1:AtgNAx8lDY+qhVObFdNQOjSUQrHX1BeiDdMuA6RIfzo= 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= -forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc= diff --git a/health.go b/health.go index 26a426f..a5a2ca0 100644 --- a/health.go +++ b/health.go @@ -2,34 +2,35 @@ package process import ( "context" + "fmt" + "io" "net" "net/http" + "strings" "sync" "time" - "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) -// HealthCheck is a function that returns nil if healthy. -// -// check := process.HealthCheck(func() error { return nil }) +// HealthCheck is a function that returns nil when the service is healthy. type HealthCheck func() error -// HealthServer provides HTTP /health and /ready endpoints for process monitoring. -// -// hs := process.NewHealthServer("127.0.0.1:0") +// HealthServer provides HTTP `/health` and `/ready` endpoints for process monitoring. type HealthServer struct { addr string server *http.Server listener net.Listener - mu sync.Mutex + mu sync.RWMutex ready bool checks []HealthCheck } // NewHealthServer creates a health check server on the given address. // -// hs := process.NewHealthServer("127.0.0.1:0") +// Example: +// +// server := process.NewHealthServer("127.0.0.1:0") func NewHealthServer(addr string) *HealthServer { return &HealthServer{ addr: addr, @@ -38,114 +39,240 @@ func NewHealthServer(addr string) *HealthServer { } // AddCheck registers a health check function. +// +// Example: +// +// server.AddCheck(func() error { return nil }) func (h *HealthServer) AddCheck(check HealthCheck) { h.mu.Lock() h.checks = append(h.checks, check) h.mu.Unlock() } -// SetReady sets the readiness status. +// SetReady sets the readiness status used by `/ready`. +// +// Example: +// +// server.SetReady(false) func (h *HealthServer) SetReady(ready bool) { h.mu.Lock() h.ready = ready h.mu.Unlock() } +// Ready reports whether `/ready` currently returns HTTP 200. +// +// Example: +// +// if server.Ready() { +// // publish the service +// } +func (h *HealthServer) Ready() bool { + h.mu.RLock() + defer h.mu.RUnlock() + return h.ready +} + // Start begins serving health check endpoints. +// +// Example: +// +// if err := server.Start(); err != nil { return err } func (h *HealthServer) Start() error { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - h.mu.Lock() - checks := h.checks - h.mu.Unlock() + checks := h.checksSnapshot() for _, check := range checks { + if check == nil { + continue + } if err := check(); err != nil { w.WriteHeader(http.StatusServiceUnavailable) - _, _ = w.Write([]byte("unhealthy: " + err.Error() + "\n")) + _, _ = fmt.Fprintf(w, "unhealthy: %v\n", err) return } } w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok\n")) + _, _ = fmt.Fprintln(w, "ok") }) mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { - h.mu.Lock() + h.mu.RLock() ready := h.ready - h.mu.Unlock() + h.mu.RUnlock() if !ready { w.WriteHeader(http.StatusServiceUnavailable) - _, _ = w.Write([]byte("not ready\n")) + _, _ = fmt.Fprintln(w, "not ready") return } w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ready\n")) + _, _ = fmt.Fprintln(w, "ready") }) listener, err := net.Listen("tcp", h.addr) if err != nil { - return core.E("health.start", core.Concat("failed to listen on ", h.addr), err) + return coreerr.E("HealthServer.Start", fmt.Sprintf("failed to listen on %s", h.addr), err) } server := &http.Server{Handler: mux} + h.mu.Lock() h.listener = listener h.server = server + h.mu.Unlock() - go func(srv *http.Server, ln net.Listener) { - _ = srv.Serve(ln) - }(server, listener) + go func() { + _ = server.Serve(listener) + }() return nil } +// checksSnapshot returns a stable copy of the registered health checks. +func (h *HealthServer) checksSnapshot() []HealthCheck { + h.mu.RLock() + defer h.mu.RUnlock() + + if len(h.checks) == 0 { + return nil + } + + checks := make([]HealthCheck, len(h.checks)) + copy(checks, h.checks) + return checks +} + // Stop gracefully shuts down the health server. +// +// Example: +// +// _ = server.Stop(context.Background()) func (h *HealthServer) Stop(ctx context.Context) error { h.mu.Lock() server := h.server h.server = nil h.listener = nil + h.ready = false h.mu.Unlock() if server == nil { return nil } - return server.Shutdown(ctx) } // Addr returns the actual address the server is listening on. +// +// Example: +// +// addr := server.Addr() func (h *HealthServer) Addr() string { + h.mu.RLock() + defer h.mu.RUnlock() if h.listener != nil { return h.listener.Addr().String() } return h.addr } -// WaitForHealth polls a health endpoint until it responds 200 or the timeout -// (in milliseconds) expires. Returns true if healthy, false on timeout. +// WaitForHealth polls `/health` until it responds 200 or the timeout expires. // -// ok := process.WaitForHealth("127.0.0.1:9000", 2_000) +// Example: +// +// if !process.WaitForHealth("127.0.0.1:8080", 5_000) { +// return errors.New("service did not become ready") +// } func WaitForHealth(addr string, timeoutMs int) bool { + ok, _ := ProbeHealth(addr, timeoutMs) + return ok +} + +// ProbeHealth polls `/health` until it responds 200 or the timeout expires. +// It returns the health status and the last observed failure reason. +// +// Example: +// +// ok, reason := process.ProbeHealth("127.0.0.1:8080", 5_000) +func ProbeHealth(addr string, timeoutMs int) (bool, string) { deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) - url := core.Concat("http://", addr, "/health") + url := fmt.Sprintf("http://%s/health", addr) client := &http.Client{Timeout: 2 * time.Second} + var lastReason string for time.Now().Before(deadline) { resp, err := client.Get(url) if err == nil { - resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() if resp.StatusCode == http.StatusOK { - return true + return true, "" } + lastReason = strings.TrimSpace(string(body)) + if lastReason == "" { + lastReason = resp.Status + } + } else { + lastReason = err.Error() } time.Sleep(200 * time.Millisecond) } - return false + if lastReason == "" { + lastReason = "health check timed out" + } + return false, lastReason +} + +// WaitForReady polls `/ready` until it responds 200 or the timeout expires. +// +// Example: +// +// if !process.WaitForReady("127.0.0.1:8080", 5_000) { +// return errors.New("service did not become ready") +// } +func WaitForReady(addr string, timeoutMs int) bool { + ok, _ := ProbeReady(addr, timeoutMs) + return ok +} + +// ProbeReady polls `/ready` until it responds 200 or the timeout expires. +// It returns the readiness status and the last observed failure reason. +// +// Example: +// +// ok, reason := process.ProbeReady("127.0.0.1:8080", 5_000) +func ProbeReady(addr string, timeoutMs int) (bool, string) { + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + url := fmt.Sprintf("http://%s/ready", addr) + + client := &http.Client{Timeout: 2 * time.Second} + var lastReason string + + for time.Now().Before(deadline) { + resp, err := client.Get(url) + if err == nil { + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return true, "" + } + lastReason = strings.TrimSpace(string(body)) + if lastReason == "" { + lastReason = resp.Status + } + } else { + lastReason = err.Error() + } + time.Sleep(200 * time.Millisecond) + } + + if lastReason == "" { + lastReason = "readiness check timed out" + } + return false, lastReason } diff --git a/health_test.go b/health_test.go index e3e025d..faf9b3b 100644 --- a/health_test.go +++ b/health_test.go @@ -9,8 +9,9 @@ import ( "github.com/stretchr/testify/require" ) -func TestHealthServer_Endpoints_Good(t *testing.T) { +func TestHealthServer_Endpoints(t *testing.T) { hs := NewHealthServer("127.0.0.1:0") + assert.True(t, hs.Ready()) err := hs.Start() require.NoError(t, err) defer func() { _ = hs.Stop(context.Background()) }() @@ -29,6 +30,7 @@ func TestHealthServer_Endpoints_Good(t *testing.T) { _ = resp.Body.Close() hs.SetReady(false) + assert.False(t, hs.Ready()) resp, err = http.Get("http://" + addr + "/ready") require.NoError(t, err) @@ -36,7 +38,16 @@ func TestHealthServer_Endpoints_Good(t *testing.T) { _ = resp.Body.Close() } -func TestHealthServer_WithChecks_Good(t *testing.T) { +func TestHealthServer_Ready(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + + assert.True(t, hs.Ready()) + + hs.SetReady(false) + assert.False(t, hs.Ready()) +} + +func TestHealthServer_WithChecks(t *testing.T) { hs := NewHealthServer("127.0.0.1:0") healthy := true @@ -66,13 +77,36 @@ func TestHealthServer_WithChecks_Good(t *testing.T) { _ = resp.Body.Close() } -func TestHealthServer_StopImmediately_Good(t *testing.T) { +func TestHealthServer_NilCheckIgnored(t *testing.T) { hs := NewHealthServer("127.0.0.1:0") - require.NoError(t, hs.Start()) - require.NoError(t, hs.Stop(context.Background())) + + var check HealthCheck + hs.AddCheck(check) + + err := hs.Start() + require.NoError(t, err) + defer func() { _ = hs.Stop(context.Background()) }() + + addr := hs.Addr() + + resp, err := http.Get("http://" + addr + "/health") + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + _ = resp.Body.Close() } -func TestWaitForHealth_Reachable_Good(t *testing.T) { +func TestHealthServer_ChecksSnapshotIsStable(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + + hs.AddCheck(func() error { return nil }) + snapshot := hs.checksSnapshot() + hs.AddCheck(func() error { return assert.AnError }) + + require.Len(t, snapshot, 1) + require.NotNil(t, snapshot[0]) +} + +func TestWaitForHealth_Reachable(t *testing.T) { hs := NewHealthServer("127.0.0.1:0") require.NoError(t, hs.Start()) defer func() { _ = hs.Stop(context.Background()) }() @@ -81,7 +115,34 @@ func TestWaitForHealth_Reachable_Good(t *testing.T) { assert.True(t, ok) } -func TestWaitForHealth_Unreachable_Bad(t *testing.T) { +func TestWaitForHealth_Unreachable(t *testing.T) { ok := WaitForHealth("127.0.0.1:19999", 500) assert.False(t, ok) } + +func TestWaitForReady_Reachable(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + require.NoError(t, hs.Start()) + defer func() { _ = hs.Stop(context.Background()) }() + + ok := WaitForReady(hs.Addr(), 2_000) + assert.True(t, ok) +} + +func TestWaitForReady_Unreachable(t *testing.T) { + ok := WaitForReady("127.0.0.1:19999", 500) + assert.False(t, ok) +} + +func TestHealthServer_StopMarksNotReady(t *testing.T) { + hs := NewHealthServer("127.0.0.1:0") + require.NoError(t, hs.Start()) + + require.NotEmpty(t, hs.Addr()) + assert.True(t, hs.Ready()) + + require.NoError(t, hs.Stop(context.Background())) + + assert.False(t, hs.Ready()) + assert.NotEmpty(t, hs.Addr()) +} diff --git a/pidfile.go b/pidfile.go index 6db566f..1b3d009 100644 --- a/pidfile.go +++ b/pidfile.go @@ -12,18 +12,30 @@ import ( ) // PIDFile manages a process ID file for single-instance enforcement. +// +// Example: +// +// pidFile := process.NewPIDFile("/var/run/myapp.pid") type PIDFile struct { path string mu sync.Mutex } // NewPIDFile creates a PID file manager. +// +// Example: +// +// pidFile := process.NewPIDFile("/var/run/myapp.pid") func NewPIDFile(path string) *PIDFile { return &PIDFile{path: path} } // Acquire writes the current PID to the file. // Returns error if another instance is running. +// +// Example: +// +// if err := pidFile.Acquire(); err != nil { return err } func (p *PIDFile) Acquire() error { p.mu.Lock() defer p.mu.Unlock() @@ -55,6 +67,10 @@ func (p *PIDFile) Acquire() error { } // Release removes the PID file. +// +// Example: +// +// _ = pidFile.Release() func (p *PIDFile) Release() error { p.mu.Lock() defer p.mu.Unlock() @@ -65,6 +81,10 @@ func (p *PIDFile) Release() error { } // Path returns the PID file path. +// +// Example: +// +// path := pidFile.Path() func (p *PIDFile) Path() string { return p.path } @@ -72,6 +92,10 @@ func (p *PIDFile) Path() string { // ReadPID reads a PID file and checks if the process is still running. // Returns (pid, true) if the process is alive, (pid, false) if dead/stale, // or (0, false) if the file doesn't exist or is invalid. +// +// Example: +// +// pid, running := process.ReadPID("/var/run/myapp.pid") func ReadPID(path string) (int, bool) { data, err := coreio.Local.Read(path) if err != nil { diff --git a/pkg/api/provider.go b/pkg/api/provider.go index ad89ad1..f0b48ef 100644 --- a/pkg/api/provider.go +++ b/pkg/api/provider.go @@ -5,15 +5,21 @@ package api import ( + "context" "net/http" "os" "strconv" + "strings" + "sync" "syscall" + "time" + "dappco.re/go/core" + "dappco.re/go/core/api" + "dappco.re/go/core/api/pkg/provider" + coreerr "dappco.re/go/core/log" process "dappco.re/go/core/process" "dappco.re/go/core/ws" - "forge.lthn.ai/core/api" - "forge.lthn.ai/core/api/pkg/provider" "github.com/gin-gonic/gin" ) @@ -22,7 +28,10 @@ import ( // and provider.Renderable. type ProcessProvider struct { registry *process.Registry + service *process.Service + runner *process.Runner hub *ws.Hub + actions sync.Once } // compile-time interface checks @@ -33,17 +42,25 @@ var ( _ provider.Renderable = (*ProcessProvider)(nil) ) -// NewProvider creates a process provider backed by the given daemon registry. +// NewProvider creates a process provider backed by the given daemon registry +// and optional process service for pipeline execution. +// // The WS hub is used to emit daemon state change events. Pass nil for hub // if WebSocket streaming is not needed. -func NewProvider(registry *process.Registry, hub *ws.Hub) *ProcessProvider { +func NewProvider(registry *process.Registry, service *process.Service, hub *ws.Hub) *ProcessProvider { if registry == nil { registry = process.DefaultRegistry() } - return &ProcessProvider{ + p := &ProcessProvider{ registry: registry, + service: service, hub: hub, } + if service != nil { + p.runner = process.NewRunner(service) + } + p.registerProcessEvents() + return p } // Name implements api.RouteGroup. @@ -79,6 +96,17 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) { rg.GET("/daemons/:code/:daemon", p.getDaemon) rg.POST("/daemons/:code/:daemon/stop", p.stopDaemon) rg.GET("/daemons/:code/:daemon/health", p.healthCheck) + rg.GET("/processes", p.listProcesses) + rg.POST("/processes", p.startProcess) + rg.POST("/processes/run", p.runProcess) + rg.GET("/processes/:id", p.getProcess) + rg.GET("/processes/:id/output", p.getProcessOutput) + rg.POST("/processes/:id/wait", p.waitProcess) + rg.POST("/processes/:id/input", p.inputProcess) + rg.POST("/processes/:id/close-stdin", p.closeProcessStdin) + rg.POST("/processes/:id/kill", p.killProcess) + rg.POST("/processes/:id/signal", p.signalProcess) + rg.POST("/pipelines/run", p.runPipeline) } // Describe implements api.DescribableGroup. @@ -119,8 +147,6 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { "daemon": map[string]any{"type": "string"}, "pid": map[string]any{"type": "integer"}, "health": map[string]any{"type": "string"}, - "project": map[string]any{"type": "string"}, - "binary": map[string]any{"type": "string"}, "started": map[string]any{"type": "string", "format": "date-time"}, }, }, @@ -142,7 +168,7 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { Method: "GET", Path: "/daemons/:code/:daemon/health", Summary: "Check daemon health", - Description: "Probes the daemon's health endpoint and returns the result.", + Description: "Probes the daemon's health endpoint and returns the result, including a failure reason when unhealthy.", Tags: []string{"process"}, Response: map[string]any{ "type": "object", @@ -153,6 +179,232 @@ func (p *ProcessProvider) Describe() []api.RouteDescription { }, }, }, + { + Method: "GET", + Path: "/processes", + Summary: "List managed processes", + Description: "Returns the current process service snapshot as serialisable process info entries. Pass runningOnly=true to limit results to active processes.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "startedAt": map[string]any{"type": "string", "format": "date-time"}, + "running": map[string]any{"type": "boolean"}, + "status": map[string]any{"type": "string"}, + "exitCode": map[string]any{"type": "integer"}, + "duration": map[string]any{"type": "integer"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + }, + }, + { + Method: "POST", + Path: "/processes", + Summary: "Start a managed process", + Description: "Starts a process asynchronously and returns its initial snapshot immediately.", + Tags: []string{"process"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "env": map[string]any{"type": "array"}, + "disableCapture": map[string]any{"type": "boolean"}, + "detach": map[string]any{"type": "boolean"}, + "timeout": map[string]any{"type": "integer"}, + "gracePeriod": map[string]any{"type": "integer"}, + "killGroup": map[string]any{"type": "boolean"}, + }, + "required": []string{"command"}, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "startedAt": map[string]any{"type": "string", "format": "date-time"}, + "running": map[string]any{"type": "boolean"}, + "status": map[string]any{"type": "string"}, + "exitCode": map[string]any{"type": "integer"}, + "duration": map[string]any{"type": "integer"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + }, + { + Method: "POST", + Path: "/processes/run", + Summary: "Run a managed process", + Description: "Runs a process synchronously and returns its combined output on success.", + Tags: []string{"process"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "env": map[string]any{"type": "array"}, + "disableCapture": map[string]any{"type": "boolean"}, + "detach": map[string]any{"type": "boolean"}, + "timeout": map[string]any{"type": "integer"}, + "gracePeriod": map[string]any{"type": "integer"}, + "killGroup": map[string]any{"type": "boolean"}, + }, + "required": []string{"command"}, + }, + Response: map[string]any{ + "type": "string", + }, + }, + { + Method: "GET", + Path: "/processes/:id", + Summary: "Get a managed process", + Description: "Returns a single managed process by ID as a process info snapshot.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "startedAt": map[string]any{"type": "string", "format": "date-time"}, + "running": map[string]any{"type": "boolean"}, + "status": map[string]any{"type": "string"}, + "exitCode": map[string]any{"type": "integer"}, + "duration": map[string]any{"type": "integer"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + }, + { + Method: "GET", + Path: "/processes/:id/output", + Summary: "Get process output", + Description: "Returns the captured stdout and stderr for a managed process.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "string", + }, + }, + { + Method: "POST", + Path: "/processes/:id/wait", + Summary: "Wait for a managed process", + Description: "Blocks until the process exits and returns the final process snapshot. Non-zero exits include the snapshot in the error details payload.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string"}, + "command": map[string]any{"type": "string"}, + "args": map[string]any{"type": "array"}, + "dir": map[string]any{"type": "string"}, + "startedAt": map[string]any{"type": "string", "format": "date-time"}, + "running": map[string]any{"type": "boolean"}, + "status": map[string]any{"type": "string"}, + "exitCode": map[string]any{"type": "integer"}, + "duration": map[string]any{"type": "integer"}, + "pid": map[string]any{"type": "integer"}, + }, + }, + }, + { + Method: "POST", + Path: "/processes/:id/input", + Summary: "Write process input", + Description: "Writes the provided input string to a managed process stdin pipe.", + Tags: []string{"process"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "input": map[string]any{"type": "string"}, + }, + "required": []string{"input"}, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "written": map[string]any{"type": "boolean"}, + }, + }, + }, + { + Method: "POST", + Path: "/processes/:id/close-stdin", + Summary: "Close process stdin", + Description: "Closes the stdin pipe of a managed process so it can exit cleanly.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "closed": map[string]any{"type": "boolean"}, + }, + }, + }, + { + Method: "POST", + Path: "/processes/:id/kill", + Summary: "Kill a managed process", + Description: "Sends SIGKILL to the managed process identified by ID, or to a raw OS PID when the path value is numeric.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "killed": map[string]any{"type": "boolean"}, + }, + }, + }, + { + Method: "POST", + Path: "/processes/:id/signal", + Summary: "Signal a managed process", + Description: "Sends a Unix signal to the managed process identified by ID, or to a raw OS PID when the path value is numeric.", + Tags: []string{"process"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "signal": map[string]any{"type": "string"}, + }, + "required": []string{"signal"}, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "signalled": map[string]any{"type": "boolean"}, + }, + }, + }, + { + Method: "POST", + Path: "/pipelines/run", + Summary: "Run a process pipeline", + Description: "Executes a list of process specs using the configured runner in sequential, parallel, or dependency-aware mode.", + Tags: []string{"process"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "results": map[string]any{ + "type": "array", + }, + "duration": map[string]any{"type": "integer"}, + "passed": map[string]any{"type": "integer"}, + "failed": map[string]any{"type": "integer"}, + "skipped": map[string]any{"type": "integer"}, + }, + }, + }, } } @@ -167,6 +419,9 @@ func (p *ProcessProvider) listDaemons(c *gin.Context) { if entries == nil { entries = []process.DaemonEntry{} } + for _, entry := range entries { + p.emitEvent("process.daemon.started", daemonEventPayload(entry)) + } c.JSON(http.StatusOK, api.OK(entries)) } @@ -179,6 +434,7 @@ func (p *ProcessProvider) getDaemon(c *gin.Context) { c.JSON(http.StatusNotFound, api.Fail("not_found", "daemon not found or not running")) return } + p.emitEvent("process.daemon.started", daemonEventPayload(*entry)) c.JSON(http.StatusOK, api.OK(entry)) } @@ -235,16 +491,14 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) { return } - healthy := process.WaitForHealth(entry.Health, 2000) - reason := "" - if !healthy { - reason = "health endpoint did not report healthy" - } + healthy, reason := process.ProbeHealth(entry.Health, 2000) result := map[string]any{ "healthy": healthy, "address": entry.Health, - "reason": reason, + } + if !healthy && reason != "" { + result["reason"] = reason } // Emit health event @@ -262,15 +516,346 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) { c.JSON(statusCode, api.OK(result)) } +func (p *ProcessProvider) listProcesses(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + procs := p.service.List() + if runningOnly, _ := strconv.ParseBool(c.Query("runningOnly")); runningOnly { + procs = p.service.Running() + } + infos := make([]process.Info, 0, len(procs)) + for _, proc := range procs { + infos = append(infos, proc.Info()) + } + + c.JSON(http.StatusOK, api.OK(infos)) +} + +func (p *ProcessProvider) startProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + var req process.TaskProcessStart + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + if strings.TrimSpace(req.Command) == "" { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "command is required")) + return + } + + proc, err := p.service.StartWithOptions(c.Request.Context(), process.RunOptions{ + Command: req.Command, + Args: req.Args, + Dir: req.Dir, + Env: req.Env, + DisableCapture: req.DisableCapture, + Detach: req.Detach, + Timeout: req.Timeout, + GracePeriod: req.GracePeriod, + KillGroup: req.KillGroup, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("start_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(proc.Info())) +} + +func (p *ProcessProvider) runProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + var req process.TaskProcessRun + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + if strings.TrimSpace(req.Command) == "" { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", "command is required")) + return + } + + output, err := p.service.RunWithOptions(c.Request.Context(), process.RunOptions{ + Command: req.Command, + Args: req.Args, + Dir: req.Dir, + Env: req.Env, + DisableCapture: req.DisableCapture, + Detach: req.Detach, + Timeout: req.Timeout, + GracePeriod: req.GracePeriod, + KillGroup: req.KillGroup, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, api.FailWithDetails("run_failed", err.Error(), map[string]any{ + "output": output, + })) + return + } + + c.JSON(http.StatusOK, api.OK(output)) +} + +func (p *ProcessProvider) getProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + proc, err := p.service.Get(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, api.Fail("not_found", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(proc.Info())) +} + +func (p *ProcessProvider) getProcessOutput(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + output, err := p.service.Output(c.Param("id")) + if err != nil { + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("not_found", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(output)) +} + +func (p *ProcessProvider) waitProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + info, err := p.service.Wait(c.Param("id")) + if err != nil { + status := http.StatusInternalServerError + switch { + case err == process.ErrProcessNotFound: + status = http.StatusNotFound + case info.Status == process.StatusExited || info.Status == process.StatusKilled: + status = http.StatusConflict + } + c.JSON(status, api.FailWithDetails("wait_failed", err.Error(), info)) + return + } + + c.JSON(http.StatusOK, api.OK(info)) +} + +type processInputRequest struct { + Input string `json:"input"` +} + +func (p *ProcessProvider) inputProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + var req processInputRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + + if err := p.service.Input(c.Param("id"), req.Input); err != nil { + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound || err == process.ErrProcessNotRunning { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("input_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"written": true})) +} + +func (p *ProcessProvider) closeProcessStdin(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + if err := p.service.CloseStdin(c.Param("id")); err != nil { + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("close_stdin_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"closed": true})) +} + +func (p *ProcessProvider) killProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + id := c.Param("id") + if err := p.service.Kill(id); err != nil { + if pid, ok := pidFromString(id); ok { + if pidErr := p.service.KillPID(pid); pidErr == nil { + c.JSON(http.StatusOK, api.OK(map[string]any{"killed": true})) + return + } else { + err = pidErr + } + } + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("kill_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"killed": true})) +} + +type processSignalRequest struct { + Signal string `json:"signal"` +} + +func (p *ProcessProvider) signalProcess(c *gin.Context) { + if p.service == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("service_unavailable", "process service is not configured")) + return + } + + var req processSignalRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + + sig, err := parseSignal(req.Signal) + if err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_signal", err.Error())) + return + } + + id := c.Param("id") + if err := p.service.Signal(id, sig); err != nil { + if pid, ok := pidFromString(id); ok { + if pidErr := p.service.SignalPID(pid, sig); pidErr == nil { + c.JSON(http.StatusOK, api.OK(map[string]any{"signalled": true})) + return + } else { + err = pidErr + } + } + status := http.StatusInternalServerError + if err == process.ErrProcessNotFound || err == process.ErrProcessNotRunning { + status = http.StatusNotFound + } + c.JSON(status, api.Fail("signal_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(map[string]any{"signalled": true})) +} + +type pipelineRunRequest struct { + Mode string `json:"mode"` + Specs []process.RunSpec `json:"specs"` +} + +func (p *ProcessProvider) runPipeline(c *gin.Context) { + if p.runner == nil { + c.JSON(http.StatusServiceUnavailable, api.Fail("runner_unavailable", "pipeline runner is not configured")) + return + } + + var req pipelineRunRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, api.Fail("invalid_request", err.Error())) + return + } + + mode := strings.ToLower(strings.TrimSpace(req.Mode)) + if mode == "" { + mode = "all" + } + + ctx := c.Request.Context() + if ctx == nil { + ctx = context.Background() + } + + var ( + result *process.RunAllResult + err error + ) + + switch mode { + case "all": + result, err = p.runner.RunAll(ctx, req.Specs) + case "sequential": + result, err = p.runner.RunSequential(ctx, req.Specs) + case "parallel": + result, err = p.runner.RunParallel(ctx, req.Specs) + default: + c.JSON(http.StatusBadRequest, api.Fail("invalid_mode", "mode must be one of: all, sequential, parallel")) + return + } + if err != nil { + c.JSON(http.StatusBadRequest, api.Fail("pipeline_failed", err.Error())) + return + } + + c.JSON(http.StatusOK, api.OK(result)) +} + // emitEvent sends a WS event if the hub is available. func (p *ProcessProvider) emitEvent(channel string, data any) { if p.hub == nil { return } - _ = p.hub.SendToChannel(channel, ws.Message{ + msg := ws.Message{ Type: ws.TypeEvent, Data: data, + } + _ = p.hub.Broadcast(ws.Message{ + Type: msg.Type, + Channel: channel, + Data: data, }) + _ = p.hub.SendToChannel(channel, msg) +} + +func daemonEventPayload(entry process.DaemonEntry) map[string]any { + return map[string]any{ + "code": entry.Code, + "daemon": entry.Daemon, + "pid": entry.PID, + "health": entry.Health, + "project": entry.Project, + "binary": entry.Binary, + "started": entry.Started, + } } // PIDAlive checks whether a PID is still running. Exported for use by @@ -291,3 +876,125 @@ func intParam(c *gin.Context, name string) int { v, _ := strconv.Atoi(c.Param(name)) return v } + +func pidFromString(value string) (int, bool) { + pid, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || pid <= 0 { + return 0, false + } + return pid, true +} + +func parseSignal(value string) (syscall.Signal, error) { + trimmed := strings.TrimSpace(strings.ToUpper(value)) + if trimmed == "" { + return 0, coreerr.E("ProcessProvider.parseSignal", "signal is required", nil) + } + + if n, err := strconv.Atoi(trimmed); err == nil { + return syscall.Signal(n), nil + } + + switch trimmed { + case "SIGTERM", "TERM": + return syscall.SIGTERM, nil + case "SIGKILL", "KILL": + return syscall.SIGKILL, nil + case "SIGINT", "INT": + return syscall.SIGINT, nil + case "SIGQUIT", "QUIT": + return syscall.SIGQUIT, nil + case "SIGHUP", "HUP": + return syscall.SIGHUP, nil + case "SIGSTOP", "STOP": + return syscall.SIGSTOP, nil + case "SIGCONT", "CONT": + return syscall.SIGCONT, nil + case "SIGUSR1", "USR1": + return syscall.SIGUSR1, nil + case "SIGUSR2", "USR2": + return syscall.SIGUSR2, nil + default: + return 0, coreerr.E("ProcessProvider.parseSignal", "unsupported signal", nil) + } +} + +func (p *ProcessProvider) registerProcessEvents() { + if p == nil || p.hub == nil || p.service == nil { + return + } + + coreApp := p.service.Core() + if coreApp == nil { + return + } + + p.actions.Do(func() { + coreApp.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + p.forwardProcessEvent(msg) + return core.Result{OK: true} + }) + }) +} + +func (p *ProcessProvider) forwardProcessEvent(msg core.Message) { + switch m := msg.(type) { + case process.ActionProcessStarted: + payload := p.processEventPayload(m.ID) + payload["id"] = m.ID + payload["command"] = m.Command + payload["args"] = append([]string(nil), m.Args...) + payload["dir"] = m.Dir + payload["pid"] = m.PID + if _, ok := payload["startedAt"]; !ok { + payload["startedAt"] = time.Now().UTC() + } + p.emitEvent("process.started", payload) + case process.ActionProcessOutput: + p.emitEvent("process.output", map[string]any{ + "id": m.ID, + "line": m.Line, + "stream": m.Stream, + }) + case process.ActionProcessExited: + payload := p.processEventPayload(m.ID) + payload["id"] = m.ID + payload["exitCode"] = m.ExitCode + payload["duration"] = m.Duration + if m.Error != nil { + payload["error"] = m.Error.Error() + } + p.emitEvent("process.exited", payload) + case process.ActionProcessKilled: + payload := p.processEventPayload(m.ID) + payload["id"] = m.ID + payload["signal"] = m.Signal + payload["exitCode"] = -1 + p.emitEvent("process.killed", payload) + } +} + +func (p *ProcessProvider) processEventPayload(id string) map[string]any { + if p == nil || p.service == nil || id == "" { + return map[string]any{} + } + + proc, err := p.service.Get(id) + if err != nil { + return map[string]any{} + } + + info := proc.Info() + return map[string]any{ + "id": info.ID, + "command": info.Command, + "args": append([]string(nil), info.Args...), + "dir": info.Dir, + "startedAt": info.StartedAt, + "running": info.Running, + "status": info.Status, + "exitCode": info.ExitCode, + "duration": info.Duration, + "pid": info.PID, + } +} diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index 625c9a1..a9331d0 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -3,15 +3,24 @@ package api_test import ( + "context" + "encoding/json" "net/http" "net/http/httptest" "os" + "os/exec" + "strconv" + "strings" "testing" + "time" + core "dappco.re/go/core" + goapi "dappco.re/go/core/api" process "dappco.re/go/core/process" processapi "dappco.re/go/core/process/pkg/api" - goapi "forge.lthn.ai/core/api" + corews "dappco.re/go/core/ws" "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,17 +30,17 @@ func init() { } func TestProcessProvider_Name_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) assert.Equal(t, "process", p.Name()) } func TestProcessProvider_BasePath_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) assert.Equal(t, "/api/process", p.BasePath()) } func TestProcessProvider_Channels_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) channels := p.Channels() assert.Contains(t, channels, "process.daemon.started") assert.Contains(t, channels, "process.daemon.stopped") @@ -39,9 +48,9 @@ func TestProcessProvider_Channels_Good(t *testing.T) { } func TestProcessProvider_Describe_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) descs := p.Describe() - assert.GreaterOrEqual(t, len(descs), 4) + assert.GreaterOrEqual(t, len(descs), 5) // Verify all descriptions have required fields for _, d := range descs { @@ -51,20 +60,25 @@ func TestProcessProvider_Describe_Good(t *testing.T) { assert.NotEmpty(t, d.Tags) } + foundPipelineRoute := false + foundSignalRoute := false for _, d := range descs { - if d.Path == "/daemons/:code/:daemon/health" { - props, ok := d.Response["properties"].(map[string]any) - require.True(t, ok) - assert.Contains(t, props, "reason") + if d.Method == "POST" && d.Path == "/pipelines/run" { + foundPipelineRoute = true + } + if d.Method == "POST" && d.Path == "/processes/:id/signal" { + foundSignalRoute = true } } + assert.True(t, foundPipelineRoute, "pipeline route should be described") + assert.True(t, foundSignalRoute, "signal route should be described") } func TestProcessProvider_ListDaemons_Good(t *testing.T) { // Use a temp directory so the registry has no daemons dir := t.TempDir() registry := newTestRegistry(dir) - p := processapi.NewProvider(registry, nil) + p := processapi.NewProvider(registry, nil, nil) r := setupRouter(p) w := httptest.NewRecorder() @@ -73,14 +87,58 @@ func TestProcessProvider_ListDaemons_Good(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) - body := w.Body.String() - assert.NotEmpty(t, body) + var resp goapi.Response[[]any] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.True(t, resp.Success) +} + +func TestProcessProvider_ListDaemons_BroadcastsStarted_Good(t *testing.T) { + dir := t.TempDir() + registry := newTestRegistry(dir) + require.NoError(t, registry.Register(process.DaemonEntry{ + Code: "test", + Daemon: "serve", + PID: os.Getpid(), + })) + + hub := corews.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + p := processapi.NewProvider(registry, nil, hub) + server := httptest.NewServer(hub.Handler()) + defer server.Close() + + conn := connectWS(t, server.URL) + defer conn.Close() + + require.Eventually(t, func() bool { + return hub.ClientCount() == 1 + }, time.Second, 10*time.Millisecond) + + r := setupRouter(p) + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/api/process/daemons", nil) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + events := readWSEvents(t, conn, "process.daemon.started") + started := events["process.daemon.started"] + require.NotNil(t, started) + + startedData := started.Data.(map[string]any) + assert.Equal(t, "test", startedData["code"]) + assert.Equal(t, "serve", startedData["daemon"]) + assert.Equal(t, float64(os.Getpid()), startedData["pid"]) } func TestProcessProvider_GetDaemon_Bad(t *testing.T) { dir := t.TempDir() registry := newTestRegistry(dir) - p := processapi.NewProvider(registry, nil) + p := processapi.NewProvider(registry, nil, nil) r := setupRouter(p) w := httptest.NewRecorder() @@ -90,29 +148,45 @@ func TestProcessProvider_GetDaemon_Bad(t *testing.T) { assert.Equal(t, http.StatusNotFound, w.Code) } -func TestProcessProvider_HealthCheck_NoEndpoint_Good(t *testing.T) { +func TestProcessProvider_HealthCheck_Bad(t *testing.T) { dir := t.TempDir() registry := newTestRegistry(dir) + + healthSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte("upstream health check failed")) + })) + defer healthSrv.Close() + + hostPort := strings.TrimPrefix(healthSrv.URL, "http://") require.NoError(t, registry.Register(process.DaemonEntry{ Code: "test", - Daemon: "nohealth", + Daemon: "broken", PID: os.Getpid(), + Health: hostPort, })) - p := processapi.NewProvider(registry, nil) + p := processapi.NewProvider(registry, nil, nil) r := setupRouter(p) w := httptest.NewRecorder() - req, _ := http.NewRequest("GET", "/api/process/daemons/test/nohealth/health", nil) + req, _ := http.NewRequest("GET", "/api/process/daemons/test/broken/health", nil) r.ServeHTTP(w, req) - assert.Equal(t, http.StatusOK, w.Code) - assert.Contains(t, w.Body.String(), "no health endpoint configured") - assert.Contains(t, w.Body.String(), "\"reason\"") + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var resp goapi.Response[map[string]any] + err := json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + + assert.Equal(t, false, resp.Data["healthy"]) + assert.Equal(t, hostPort, resp.Data["address"]) + assert.Equal(t, "upstream health check failed", resp.Data["reason"]) } func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) + p := processapi.NewProvider(nil, nil, nil) engine, err := goapi.New() require.NoError(t, err) @@ -122,8 +196,8 @@ func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) { assert.Equal(t, "process", engine.Groups()[0].Name()) } -func TestProcessProvider_StreamGroup_Good(t *testing.T) { - p := processapi.NewProvider(nil, nil) +func TestProcessProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) { + p := processapi.NewProvider(nil, nil, nil) engine, err := goapi.New() require.NoError(t, err) @@ -135,6 +209,600 @@ func TestProcessProvider_StreamGroup_Good(t *testing.T) { assert.Contains(t, channels, "process.daemon.started") } +func TestProcessProvider_RunPipeline_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + + body := strings.NewReader(`{ + "mode": "parallel", + "specs": [ + {"name": "first", "command": "echo", "args": ["1"]}, + {"name": "second", "command": "echo", "args": ["2"]} + ] + }`) + req, err := http.NewRequest("POST", "/api/process/pipelines/run", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[process.RunAllResult] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + assert.True(t, resp.Success) + assert.Equal(t, 2, resp.Data.Passed) + assert.Len(t, resp.Data.Results, 2) +} + +func TestProcessProvider_RunPipeline_Unavailable(t *testing.T) { + p := processapi.NewProvider(nil, nil, nil) + + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/pipelines/run", strings.NewReader(`{"mode":"all","specs":[]}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestProcessProvider_ListProcesses_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "echo", "hello-api") + require.NoError(t, err) + <-proc.Done() + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/api/process/processes", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + require.Len(t, resp.Data, 1) + assert.Equal(t, proc.ID, resp.Data[0].ID) + assert.Equal(t, "echo", resp.Data[0].Command) +} + +func TestProcessProvider_ListProcesses_RunningOnly_Good(t *testing.T) { + svc := newTestProcessService(t) + + runningProc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + exitedProc, err := svc.Start(context.Background(), "echo", "done") + require.NoError(t, err) + <-exitedProc.Done() + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/api/process/processes?runningOnly=true", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[[]process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + require.Len(t, resp.Data, 1) + assert.Equal(t, runningProc.ID, resp.Data[0].ID) + assert.Equal(t, process.StatusRunning, resp.Data[0].Status) + + require.NoError(t, svc.Kill(runningProc.ID)) + <-runningProc.Done() +} + +func TestProcessProvider_StartProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + body := strings.NewReader(`{ + "command": "sleep", + "args": ["60"], + "detach": true, + "killGroup": true + }`) + req, err := http.NewRequest("POST", "/api/process/processes", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, "sleep", resp.Data.Command) + assert.Equal(t, process.StatusRunning, resp.Data.Status) + assert.True(t, resp.Data.Running) + assert.NotEmpty(t, resp.Data.ID) + + managed, err := svc.Get(resp.Data.ID) + require.NoError(t, err) + require.NoError(t, svc.Kill(managed.ID)) + select { + case <-managed.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process should have been killed after start test") + } +} + +func TestProcessProvider_RunProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + body := strings.NewReader(`{ + "command": "echo", + "args": ["run-check"] + }`) + req, err := http.NewRequest("POST", "/api/process/processes/run", body) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[string] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Contains(t, resp.Data, "run-check") +} + +func TestProcessProvider_GetProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "echo", "single") + require.NoError(t, err) + <-proc.Done() + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/api/process/processes/"+proc.ID, nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, proc.ID, resp.Data.ID) + assert.Equal(t, "echo", resp.Data.Command) +} + +func TestProcessProvider_GetProcessOutput_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "echo", "output-check") + require.NoError(t, err) + <-proc.Done() + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("GET", "/api/process/processes/"+proc.ID+"/output", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[string] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Contains(t, resp.Data, "output-check") +} + +func TestProcessProvider_WaitProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "echo", "wait-check") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/wait", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[process.Info] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, proc.ID, resp.Data.ID) + assert.Equal(t, process.StatusExited, resp.Data.Status) + assert.Equal(t, 0, resp.Data.ExitCode) +} + +func TestProcessProvider_WaitProcess_NonZeroExit_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/wait", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusConflict, w.Code) + + var resp goapi.Response[any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.False(t, resp.Success) + require.NotNil(t, resp.Error) + assert.Equal(t, "wait_failed", resp.Error.Code) + assert.Contains(t, resp.Error.Message, "process exited with code 7") + + details, ok := resp.Error.Details.(map[string]any) + require.True(t, ok) + assert.Equal(t, "exited", details["status"]) + assert.Equal(t, float64(7), details["exitCode"]) + assert.Equal(t, proc.ID, details["id"]) +} + +func TestProcessProvider_InputAndCloseStdin_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + inputReq := strings.NewReader("{\"input\":\"hello-api\\n\"}") + inputHTTPReq, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/input", inputReq) + require.NoError(t, err) + inputHTTPReq.Header.Set("Content-Type", "application/json") + + inputResp := httptest.NewRecorder() + r.ServeHTTP(inputResp, inputHTTPReq) + + assert.Equal(t, http.StatusOK, inputResp.Code) + + closeReq, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/close-stdin", nil) + require.NoError(t, err) + + closeResp := httptest.NewRecorder() + r.ServeHTTP(closeResp, closeReq) + + assert.Equal(t, http.StatusOK, closeResp.Code) + + select { + case <-proc.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process should have exited after stdin was closed") + } + + assert.Contains(t, proc.Output(), "hello-api") +} + +func TestProcessProvider_KillProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/kill", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[map[string]any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, true, resp.Data["killed"]) + + select { + case <-proc.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process should have been killed") + } + assert.Equal(t, process.StatusKilled, proc.Status) +} + +func TestProcessProvider_KillProcess_ByPID_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/process/processes/"+strconv.Itoa(cmd.Process.Pid)+"/kill", nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[map[string]any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, true, resp.Data["killed"]) + + select { + case err := <-waitCh: + require.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("unmanaged process should have been killed by PID") + } +} + +func TestProcessProvider_SignalProcess_Good(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/signal", strings.NewReader(`{"signal":"SIGTERM"}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[map[string]any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, true, resp.Data["signalled"]) + + select { + case <-proc.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process should have been signalled") + } + assert.Equal(t, process.StatusKilled, proc.Status) +} + +func TestProcessProvider_SignalProcess_ByPID_Good(t *testing.T) { + svc := newTestProcessService(t) + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/api/process/processes/"+strconv.Itoa(cmd.Process.Pid)+"/signal", strings.NewReader(`{"signal":"SIGTERM"}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var resp goapi.Response[map[string]any] + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + require.True(t, resp.Success) + assert.Equal(t, true, resp.Data["signalled"]) + + select { + case err := <-waitCh: + require.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("unmanaged process should have been signalled by PID") + } +} + +func TestProcessProvider_SignalProcess_InvalidSignal_Bad(t *testing.T) { + svc := newTestProcessService(t) + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + p := processapi.NewProvider(nil, svc, nil) + r := setupRouter(p) + w := httptest.NewRecorder() + + req, err := http.NewRequest("POST", "/api/process/processes/"+proc.ID+"/signal", strings.NewReader(`{"signal":"NOPE"}`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.True(t, proc.IsRunning()) + + require.NoError(t, svc.Kill(proc.ID)) + <-proc.Done() +} + +func TestProcessProvider_BroadcastsProcessEvents_Good(t *testing.T) { + svc := newTestProcessService(t) + hub := corews.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + _ = processapi.NewProvider(nil, svc, hub) + + server := httptest.NewServer(hub.Handler()) + defer server.Close() + + conn := connectWS(t, server.URL) + defer conn.Close() + + require.Eventually(t, func() bool { + return hub.ClientCount() == 1 + }, time.Second, 10*time.Millisecond) + + proc, err := svc.Start(context.Background(), "sh", "-c", "echo live-event") + require.NoError(t, err) + <-proc.Done() + + events := readWSEvents(t, conn, "process.started", "process.output", "process.exited") + + started := events["process.started"] + require.NotNil(t, started) + startedData := started.Data.(map[string]any) + assert.Equal(t, proc.ID, startedData["id"]) + assert.Equal(t, "sh", startedData["command"]) + assert.Equal(t, float64(proc.Info().PID), startedData["pid"]) + + output := events["process.output"] + require.NotNil(t, output) + outputData := output.Data.(map[string]any) + assert.Equal(t, proc.ID, outputData["id"]) + assert.Equal(t, "stdout", outputData["stream"]) + assert.Contains(t, outputData["line"], "live-event") + + exited := events["process.exited"] + require.NotNil(t, exited) + exitedData := exited.Data.(map[string]any) + assert.Equal(t, proc.ID, exitedData["id"]) + assert.Equal(t, float64(0), exitedData["exitCode"]) +} + +func TestProcessProvider_BroadcastsKilledEvents_Good(t *testing.T) { + svc := newTestProcessService(t) + hub := corews.NewHub() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go hub.Run(ctx) + + _ = processapi.NewProvider(nil, svc, hub) + + server := httptest.NewServer(hub.Handler()) + defer server.Close() + + conn := connectWS(t, server.URL) + defer conn.Close() + + require.Eventually(t, func() bool { + return hub.ClientCount() == 1 + }, time.Second, 10*time.Millisecond) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + require.NoError(t, svc.Kill(proc.ID)) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been killed") + } + + events := readWSEvents(t, conn, "process.killed", "process.exited") + + killed := events["process.killed"] + require.NotNil(t, killed) + killedData := killed.Data.(map[string]any) + assert.Equal(t, proc.ID, killedData["id"]) + assert.Equal(t, "SIGKILL", killedData["signal"]) + assert.Equal(t, float64(-1), killedData["exitCode"]) + + exited := events["process.exited"] + require.NotNil(t, exited) + exitedData := exited.Data.(map[string]any) + assert.Equal(t, proc.ID, exitedData["id"]) + assert.Equal(t, float64(-1), exitedData["exitCode"]) +} + +func TestProcessProvider_ProcessRoutes_Unavailable(t *testing.T) { + p := processapi.NewProvider(nil, nil, nil) + r := setupRouter(p) + + cases := []string{ + "/api/process/processes", + "/api/process/processes/anything", + "/api/process/processes/anything/output", + "/api/process/processes/anything/wait", + "/api/process/processes/anything/input", + "/api/process/processes/anything/close-stdin", + "/api/process/processes/anything/kill", + } + + for _, path := range cases { + w := httptest.NewRecorder() + method := "GET" + switch { + case strings.HasSuffix(path, "/kill"), + strings.HasSuffix(path, "/wait"), + strings.HasSuffix(path, "/input"), + strings.HasSuffix(path, "/close-stdin"): + method = "POST" + } + req, err := http.NewRequest(method, path, nil) + require.NoError(t, err) + r.ServeHTTP(w, req) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + } +} + // -- Test helpers ------------------------------------------------------------- func setupRouter(p *processapi.ProcessProvider) *gin.Engine { @@ -148,3 +816,58 @@ func setupRouter(p *processapi.ProcessProvider) *gin.Engine { func newTestRegistry(dir string) *process.Registry { return process.NewRegistry(dir) } + +func newTestProcessService(t *testing.T) *process.Service { + t.Helper() + + c := core.New() + factory := process.NewService(process.Options{}) + raw, err := factory(c) + require.NoError(t, err) + + return raw.(*process.Service) +} + +func connectWS(t *testing.T, serverURL string) *websocket.Conn { + t.Helper() + + wsURL := "ws" + strings.TrimPrefix(serverURL, "http") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + require.NoError(t, err) + return conn +} + +func readWSEvents(t *testing.T, conn *websocket.Conn, channels ...string) map[string]corews.Message { + t.Helper() + + want := make(map[string]struct{}, len(channels)) + for _, channel := range channels { + want[channel] = struct{}{} + } + + events := make(map[string]corews.Message, len(channels)) + deadline := time.Now().Add(3 * time.Second) + + for len(events) < len(channels) && time.Now().Before(deadline) { + require.NoError(t, conn.SetReadDeadline(time.Now().Add(500*time.Millisecond))) + + _, payload, err := conn.ReadMessage() + require.NoError(t, err) + + for _, line := range strings.Split(strings.TrimSpace(string(payload)), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + + var msg corews.Message + require.NoError(t, json.Unmarshal([]byte(line), &msg)) + + if _, ok := want[msg.Channel]; ok { + events[msg.Channel] = msg + } + } + } + + require.Len(t, events, len(channels)) + return events +} diff --git a/pkg/api/ui/dist/core-process.js b/pkg/api/ui/dist/core-process.js index 15fb352..b9711a6 100644 --- a/pkg/api/ui/dist/core-process.js +++ b/pkg/api/ui/dist/core-process.js @@ -3,18 +3,18 @@ * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const K = globalThis, se = K.ShadowRoot && (K.ShadyCSS === void 0 || K.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, ie = Symbol(), ae = /* @__PURE__ */ new WeakMap(); -let ye = class { +const V = globalThis, ie = V.ShadowRoot && (V.ShadyCSS === void 0 || V.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, re = Symbol(), le = /* @__PURE__ */ new WeakMap(); +let ve = class { constructor(e, t, i) { - if (this._$cssResult$ = !0, i !== ie) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + if (this._$cssResult$ = !0, i !== re) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); this.cssText = e, this.t = t; } get styleSheet() { let e = this.o; const t = this.t; - if (se && e === void 0) { + if (ie && e === void 0) { const i = t !== void 0 && t.length === 1; - i && (e = ae.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), i && ae.set(t, e)); + i && (e = le.get(t)), e === void 0 && ((this.o = e = new CSSStyleSheet()).replaceSync(this.cssText), i && le.set(t, e)); } return e; } @@ -22,30 +22,30 @@ let ye = class { return this.cssText; } }; -const Ae = (s) => new ye(typeof s == "string" ? s : s + "", void 0, ie), q = (s, ...e) => { - const t = s.length === 1 ? s[0] : e.reduce((i, o, n) => i + ((r) => { - if (r._$cssResult$ === !0) return r.cssText; - if (typeof r == "number") return r; - throw Error("Value passed to 'css' function must be a 'css' function result: " + r + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); - })(o) + s[n + 1], s[0]); - return new ye(t, s, ie); -}, ke = (s, e) => { - if (se) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); +const ke = (s) => new ve(typeof s == "string" ? s : s + "", void 0, re), F = (s, ...e) => { + const t = s.length === 1 ? s[0] : e.reduce((i, r, n) => i + ((o) => { + if (o._$cssResult$ === !0) return o.cssText; + if (typeof o == "number") return o; + throw Error("Value passed to 'css' function must be a 'css' function result: " + o + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); + })(r) + s[n + 1], s[0]); + return new ve(t, s, re); +}, Ae = (s, e) => { + if (ie) s.adoptedStyleSheets = e.map((t) => t instanceof CSSStyleSheet ? t : t.styleSheet); else for (const t of e) { - const i = document.createElement("style"), o = K.litNonce; - o !== void 0 && i.setAttribute("nonce", o), i.textContent = t.cssText, s.appendChild(i); + const i = document.createElement("style"), r = V.litNonce; + r !== void 0 && i.setAttribute("nonce", r), i.textContent = t.cssText, s.appendChild(i); } -}, le = se ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => { +}, ce = ie ? (s) => s : (s) => s instanceof CSSStyleSheet ? ((e) => { let t = ""; for (const i of e.cssRules) t += i.cssText; - return Ae(t); + return ke(t); })(s) : s; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const { is: Se, defineProperty: Pe, getOwnPropertyDescriptor: Ce, getOwnPropertyNames: Ee, getOwnPropertySymbols: Ue, getPrototypeOf: Oe } = Object, A = globalThis, ce = A.trustedTypes, ze = ce ? ce.emptyScript : "", X = A.reactiveElementPolyfillSupport, j = (s, e) => s, J = { toAttribute(s, e) { +const { is: Pe, defineProperty: Ce, getOwnPropertyDescriptor: Ee, getOwnPropertyNames: Ue, getOwnPropertySymbols: Oe, getPrototypeOf: Te } = Object, k = globalThis, de = k.trustedTypes, ze = de ? de.emptyScript : "", Y = k.reactiveElementPolyfillSupport, j = (s, e) => s, Z = { toAttribute(s, e) { switch (e) { case Boolean: s = s ? ze : null; @@ -73,55 +73,55 @@ const { is: Se, defineProperty: Pe, getOwnPropertyDescriptor: Ce, getOwnProperty } } return t; -} }, oe = (s, e) => !Se(s, e), de = { attribute: !0, type: String, converter: J, reflect: !1, useDefault: !1, hasChanged: oe }; -Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), A.litPropertyMetadata ?? (A.litPropertyMetadata = /* @__PURE__ */ new WeakMap()); -let D = class extends HTMLElement { +} }, oe = (s, e) => !Pe(s, e), he = { attribute: !0, type: String, converter: Z, reflect: !1, useDefault: !1, hasChanged: oe }; +Symbol.metadata ?? (Symbol.metadata = Symbol("metadata")), k.litPropertyMetadata ?? (k.litPropertyMetadata = /* @__PURE__ */ new WeakMap()); +let z = class extends HTMLElement { static addInitializer(e) { this._$Ei(), (this.l ?? (this.l = [])).push(e); } static get observedAttributes() { return this.finalize(), this._$Eh && [...this._$Eh.keys()]; } - static createProperty(e, t = de) { + static createProperty(e, t = he) { if (t.state && (t.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(e) && ((t = Object.create(t)).wrapped = !0), this.elementProperties.set(e, t), !t.noAccessor) { - const i = Symbol(), o = this.getPropertyDescriptor(e, i, t); - o !== void 0 && Pe(this.prototype, e, o); + const i = Symbol(), r = this.getPropertyDescriptor(e, i, t); + r !== void 0 && Ce(this.prototype, e, r); } } static getPropertyDescriptor(e, t, i) { - const { get: o, set: n } = Ce(this.prototype, e) ?? { get() { + const { get: r, set: n } = Ee(this.prototype, e) ?? { get() { return this[t]; - }, set(r) { - this[t] = r; + }, set(o) { + this[t] = o; } }; - return { get: o, set(r) { - const l = o == null ? void 0 : o.call(this); - n == null || n.call(this, r), this.requestUpdate(e, l, i); + return { get: r, set(o) { + const l = r == null ? void 0 : r.call(this); + n == null || n.call(this, o), this.requestUpdate(e, l, i); }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(e) { - return this.elementProperties.get(e) ?? de; + return this.elementProperties.get(e) ?? he; } static _$Ei() { if (this.hasOwnProperty(j("elementProperties"))) return; - const e = Oe(this); + const e = Te(this); e.finalize(), e.l !== void 0 && (this.l = [...e.l]), this.elementProperties = new Map(e.elementProperties); } static finalize() { if (this.hasOwnProperty(j("finalized"))) return; if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(j("properties"))) { - const t = this.properties, i = [...Ee(t), ...Ue(t)]; - for (const o of i) this.createProperty(o, t[o]); + const t = this.properties, i = [...Ue(t), ...Oe(t)]; + for (const r of i) this.createProperty(r, t[r]); } const e = this[Symbol.metadata]; if (e !== null) { const t = litPropertyMetadata.get(e); - if (t !== void 0) for (const [i, o] of t) this.elementProperties.set(i, o); + if (t !== void 0) for (const [i, r] of t) this.elementProperties.set(i, r); } this._$Eh = /* @__PURE__ */ new Map(); for (const [t, i] of this.elementProperties) { - const o = this._$Eu(t, i); - o !== void 0 && this._$Eh.set(o, t); + const r = this._$Eu(t, i); + r !== void 0 && this._$Eh.set(r, t); } this.elementStyles = this.finalizeStyles(this.styles); } @@ -129,8 +129,8 @@ let D = class extends HTMLElement { const t = []; if (Array.isArray(e)) { const i = new Set(e.flat(1 / 0).reverse()); - for (const o of i) t.unshift(le(o)); - } else e !== void 0 && t.push(le(e)); + for (const r of i) t.unshift(ce(r)); + } else e !== void 0 && t.push(ce(e)); return t; } static _$Eu(e, t) { @@ -159,7 +159,7 @@ let D = class extends HTMLElement { } createRenderRoot() { const e = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return ke(e, this.constructor.elementStyles), e; + return Ae(e, this.constructor.elementStyles), e; } connectedCallback() { var e; @@ -182,33 +182,33 @@ let D = class extends HTMLElement { } _$ET(e, t) { var n; - const i = this.constructor.elementProperties.get(e), o = this.constructor._$Eu(e, i); - if (o !== void 0 && i.reflect === !0) { - const r = (((n = i.converter) == null ? void 0 : n.toAttribute) !== void 0 ? i.converter : J).toAttribute(t, i.type); - this._$Em = e, r == null ? this.removeAttribute(o) : this.setAttribute(o, r), this._$Em = null; + const i = this.constructor.elementProperties.get(e), r = this.constructor._$Eu(e, i); + if (r !== void 0 && i.reflect === !0) { + const o = (((n = i.converter) == null ? void 0 : n.toAttribute) !== void 0 ? i.converter : Z).toAttribute(t, i.type); + this._$Em = e, o == null ? this.removeAttribute(r) : this.setAttribute(r, o), this._$Em = null; } } _$AK(e, t) { - var n, r; - const i = this.constructor, o = i._$Eh.get(e); - if (o !== void 0 && this._$Em !== o) { - const l = i.getPropertyOptions(o), a = typeof l.converter == "function" ? { fromAttribute: l.converter } : ((n = l.converter) == null ? void 0 : n.fromAttribute) !== void 0 ? l.converter : J; - this._$Em = o; + var n, o; + const i = this.constructor, r = i._$Eh.get(e); + if (r !== void 0 && this._$Em !== r) { + const l = i.getPropertyOptions(r), a = typeof l.converter == "function" ? { fromAttribute: l.converter } : ((n = l.converter) == null ? void 0 : n.fromAttribute) !== void 0 ? l.converter : Z; + this._$Em = r; const p = a.fromAttribute(t, l.type); - this[o] = p ?? ((r = this._$Ej) == null ? void 0 : r.get(o)) ?? p, this._$Em = null; + this[r] = p ?? ((o = this._$Ej) == null ? void 0 : o.get(r)) ?? p, this._$Em = null; } } - requestUpdate(e, t, i, o = !1, n) { - var r; + requestUpdate(e, t, i, r = !1, n) { + var o; if (e !== void 0) { const l = this.constructor; - if (o === !1 && (n = this[e]), i ?? (i = l.getPropertyOptions(e)), !((i.hasChanged ?? oe)(n, t) || i.useDefault && i.reflect && n === ((r = this._$Ej) == null ? void 0 : r.get(e)) && !this.hasAttribute(l._$Eu(e, i)))) return; + if (r === !1 && (n = this[e]), i ?? (i = l.getPropertyOptions(e)), !((i.hasChanged ?? oe)(n, t) || i.useDefault && i.reflect && n === ((o = this._$Ej) == null ? void 0 : o.get(e)) && !this.hasAttribute(l._$Eu(e, i)))) return; this.C(e, t, i); } this.isUpdatePending === !1 && (this._$ES = this._$EP()); } - C(e, t, { useDefault: i, reflect: o, wrapped: n }, r) { - i && !(this._$Ej ?? (this._$Ej = /* @__PURE__ */ new Map())).has(e) && (this._$Ej.set(e, r ?? t ?? this[e]), n !== !0 || r !== void 0) || (this._$AL.has(e) || (this.hasUpdated || i || (t = void 0), this._$AL.set(e, t)), o === !0 && this._$Em !== e && (this._$Eq ?? (this._$Eq = /* @__PURE__ */ new Set())).add(e)); + C(e, t, { useDefault: i, reflect: r, wrapped: n }, o) { + i && !(this._$Ej ?? (this._$Ej = /* @__PURE__ */ new Map())).has(e) && (this._$Ej.set(e, o ?? t ?? this[e]), n !== !0 || o !== void 0) || (this._$AL.has(e) || (this.hasUpdated || i || (t = void 0), this._$AL.set(e, t)), r === !0 && this._$Em !== e && (this._$Eq ?? (this._$Eq = /* @__PURE__ */ new Set())).add(e)); } async _$EP() { this.isUpdatePending = !0; @@ -228,24 +228,24 @@ let D = class extends HTMLElement { if (!this.isUpdatePending) return; if (!this.hasUpdated) { if (this.renderRoot ?? (this.renderRoot = this.createRenderRoot()), this._$Ep) { - for (const [n, r] of this._$Ep) this[n] = r; + for (const [n, o] of this._$Ep) this[n] = o; this._$Ep = void 0; } - const o = this.constructor.elementProperties; - if (o.size > 0) for (const [n, r] of o) { - const { wrapped: l } = r, a = this[n]; - l !== !0 || this._$AL.has(n) || a === void 0 || this.C(n, void 0, r, a); + const r = this.constructor.elementProperties; + if (r.size > 0) for (const [n, o] of r) { + const { wrapped: l } = o, a = this[n]; + l !== !0 || this._$AL.has(n) || a === void 0 || this.C(n, void 0, o, a); } } let e = !1; const t = this._$AL; try { - e = this.shouldUpdate(t), e ? (this.willUpdate(t), (i = this._$EO) == null || i.forEach((o) => { + e = this.shouldUpdate(t), e ? (this.willUpdate(t), (i = this._$EO) == null || i.forEach((r) => { var n; - return (n = o.hostUpdate) == null ? void 0 : n.call(o); + return (n = r.hostUpdate) == null ? void 0 : n.call(r); }), this.update(t)) : this._$EM(); - } catch (o) { - throw e = !1, this._$EM(), o; + } catch (r) { + throw e = !1, this._$EM(), r; } e && this._$AE(t); } @@ -254,8 +254,8 @@ let D = class extends HTMLElement { _$AE(e) { var t; (t = this._$EO) == null || t.forEach((i) => { - var o; - return (o = i.hostUpdated) == null ? void 0 : o.call(i); + var r; + return (r = i.hostUpdated) == null ? void 0 : r.call(i); }), this.hasUpdated || (this.hasUpdated = !0, this.firstUpdated(e)), this.updated(e); } _$EM() { @@ -278,76 +278,74 @@ let D = class extends HTMLElement { firstUpdated(e) { } }; -D.elementStyles = [], D.shadowRootOptions = { mode: "open" }, D[j("elementProperties")] = /* @__PURE__ */ new Map(), D[j("finalized")] = /* @__PURE__ */ new Map(), X == null || X({ ReactiveElement: D }), (A.reactiveElementVersions ?? (A.reactiveElementVersions = [])).push("2.1.2"); +z.elementStyles = [], z.shadowRootOptions = { mode: "open" }, z[j("elementProperties")] = /* @__PURE__ */ new Map(), z[j("finalized")] = /* @__PURE__ */ new Map(), Y == null || Y({ ReactiveElement: z }), (k.reactiveElementVersions ?? (k.reactiveElementVersions = [])).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const N = globalThis, he = (s) => s, Z = N.trustedTypes, pe = Z ? Z.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, ve = "$lit$", x = `lit$${Math.random().toFixed(9).slice(2)}$`, we = "?" + x, De = `<${we}>`, U = document, I = () => U.createComment(""), L = (s) => s === null || typeof s != "object" && typeof s != "function", re = Array.isArray, Te = (s) => re(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", Y = `[ -\f\r]`, H = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, ue = /-->/g, me = />/g, P = RegExp(`>|${Y}(?:([^\\s"'>=/]+)(${Y}*=${Y}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), fe = /'/g, ge = /"/g, _e = /^(?:script|style|textarea|title)$/i, Me = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = Me(1), T = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), be = /* @__PURE__ */ new WeakMap(), C = U.createTreeWalker(U, 129); -function xe(s, e) { - if (!re(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return pe !== void 0 ? pe.createHTML(e) : e; +const N = globalThis, pe = (s) => s, G = N.trustedTypes, ue = G ? G.createPolicy("lit-html", { createHTML: (s) => s }) : void 0, we = "$lit$", S = `lit$${Math.random().toFixed(9).slice(2)}$`, _e = "?" + S, De = `<${_e}>`, O = document, I = () => O.createComment(""), q = (s) => s === null || typeof s != "object" && typeof s != "function", ne = Array.isArray, Me = (s) => ne(s) || typeof (s == null ? void 0 : s[Symbol.iterator]) == "function", ee = "[ \\t\\n\\f\\r]", H = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, me = /-->/g, fe = />/g, C = RegExp(`>|${ee}(?:([^\\s"'>=/]+)(${ee}*=${ee}*(?:[^ \\t\\n\\f\\r"'\`<>=]|("|')|))|$)`, "g"), ge = /'/g, be = /"/g, xe = /^(?:script|style|textarea|title)$/i, Re = (s) => (e, ...t) => ({ _$litType$: s, strings: e, values: t }), c = Re(1), D = Symbol.for("lit-noChange"), d = Symbol.for("lit-nothing"), $e = /* @__PURE__ */ new WeakMap(), E = O.createTreeWalker(O, 129); +function Se(s, e) { + if (!ne(s) || !s.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return ue !== void 0 ? ue.createHTML(e) : e; } -const Re = (s, e) => { +const He = (s, e) => { const t = s.length - 1, i = []; - let o, n = e === 2 ? "" : e === 3 ? "" : "", r = H; + let r, n = e === 2 ? "" : e === 3 ? "" : "", o = H; for (let l = 0; l < t; l++) { const a = s[l]; - let p, m, h = -1, $ = 0; - for (; $ < a.length && (r.lastIndex = $, m = r.exec(a), m !== null); ) $ = r.lastIndex, r === H ? m[1] === "!--" ? r = ue : m[1] !== void 0 ? r = me : m[2] !== void 0 ? (_e.test(m[2]) && (o = RegExp("" ? (r = o ?? H, h = -1) : m[1] === void 0 ? h = -2 : (h = r.lastIndex - m[2].length, p = m[1], r = m[3] === void 0 ? P : m[3] === '"' ? ge : fe) : r === ge || r === fe ? r = P : r === ue || r === me ? r = H : (r = P, o = void 0); - const _ = r === P && s[l + 1].startsWith("/>") ? " " : ""; - n += r === H ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + ve + a.slice(h) + x + _) : a + x + (h === -2 ? l : _); + let p, m, h = -1, y = 0; + for (; y < a.length && (o.lastIndex = y, m = o.exec(a), m !== null); ) y = o.lastIndex, o === H ? m[1] === "!--" ? o = me : m[1] !== void 0 ? o = fe : m[2] !== void 0 ? (xe.test(m[2]) && (r = RegExp("" ? (o = r ?? H, h = -1) : m[1] === void 0 ? h = -2 : (h = o.lastIndex - m[2].length, p = m[1], o = m[3] === void 0 ? C : m[3] === '"' ? be : ge) : o === be || o === ge ? o = C : o === me || o === fe ? o = H : (o = C, r = void 0); + const x = o === C && s[l + 1].startsWith("/>") ? " " : ""; + n += o === H ? a + De : h >= 0 ? (i.push(p), a.slice(0, h) + we + a.slice(h) + S + x) : a + S + (h === -2 ? l : x); } - return [xe(s, n + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), i]; + return [Se(s, n + (s[t] || "") + (e === 2 ? "" : e === 3 ? "" : "")), i]; }; -class W { +class L { constructor({ strings: e, _$litType$: t }, i) { - let o; + let r; this.parts = []; - let n = 0, r = 0; - const l = e.length - 1, a = this.parts, [p, m] = Re(e, t); - if (this.el = W.createElement(p, i), C.currentNode = this.el.content, t === 2 || t === 3) { + let n = 0, o = 0; + const l = e.length - 1, a = this.parts, [p, m] = He(e, t); + if (this.el = L.createElement(p, i), E.currentNode = this.el.content, t === 2 || t === 3) { const h = this.el.content.firstChild; h.replaceWith(...h.childNodes); } - for (; (o = C.nextNode()) !== null && a.length < l; ) { - if (o.nodeType === 1) { - if (o.hasAttributes()) for (const h of o.getAttributeNames()) if (h.endsWith(ve)) { - const $ = m[r++], _ = o.getAttribute(h).split(x), V = /([.?@])?(.*)/.exec($); - a.push({ type: 1, index: n, name: V[2], strings: _, ctor: V[1] === "." ? je : V[1] === "?" ? Ne : V[1] === "@" ? Ie : G }), o.removeAttribute(h); - } else h.startsWith(x) && (a.push({ type: 6, index: n }), o.removeAttribute(h)); - if (_e.test(o.tagName)) { - const h = o.textContent.split(x), $ = h.length - 1; - if ($ > 0) { - o.textContent = Z ? Z.emptyScript : ""; - for (let _ = 0; _ < $; _++) o.append(h[_], I()), C.nextNode(), a.push({ type: 2, index: ++n }); - o.append(h[$], I()); + for (; (r = E.nextNode()) !== null && a.length < l; ) { + if (r.nodeType === 1) { + if (r.hasAttributes()) for (const h of r.getAttributeNames()) if (h.endsWith(we)) { + const y = m[o++], x = r.getAttribute(h).split(S), J = /([.?@])?(.*)/.exec(y); + a.push({ type: 1, index: n, name: J[2], strings: x, ctor: J[1] === "." ? Ne : J[1] === "?" ? Ie : J[1] === "@" ? qe : Q }), r.removeAttribute(h); + } else h.startsWith(S) && (a.push({ type: 6, index: n }), r.removeAttribute(h)); + if (xe.test(r.tagName)) { + const h = r.textContent.split(S), y = h.length - 1; + if (y > 0) { + r.textContent = G ? G.emptyScript : ""; + for (let x = 0; x < y; x++) r.append(h[x], I()), E.nextNode(), a.push({ type: 2, index: ++n }); + r.append(h[y], I()); } } - } else if (o.nodeType === 8) if (o.data === we) a.push({ type: 2, index: n }); + } else if (r.nodeType === 8) if (r.data === _e) a.push({ type: 2, index: n }); else { let h = -1; - for (; (h = o.data.indexOf(x, h + 1)) !== -1; ) a.push({ type: 7, index: n }), h += x.length - 1; + for (; (h = r.data.indexOf(S, h + 1)) !== -1; ) a.push({ type: 7, index: n }), h += S.length - 1; } n++; } } static createElement(e, t) { - const i = U.createElement("template"); + const i = O.createElement("template"); return i.innerHTML = e, i; } } function M(s, e, t = s, i) { - var r, l; - if (e === T) return e; - let o = i !== void 0 ? (r = t._$Co) == null ? void 0 : r[i] : t._$Cl; - const n = L(e) ? void 0 : e._$litDirective$; - return (o == null ? void 0 : o.constructor) !== n && ((l = o == null ? void 0 : o._$AO) == null || l.call(o, !1), n === void 0 ? o = void 0 : (o = new n(s), o._$AT(s, t, i)), i !== void 0 ? (t._$Co ?? (t._$Co = []))[i] = o : t._$Cl = o), o !== void 0 && (e = M(s, o._$AS(s, e.values), o, i)), e; + var o, l; + if (e === D) return e; + let r = i !== void 0 ? (o = t._$Co) == null ? void 0 : o[i] : t._$Cl; + const n = q(e) ? void 0 : e._$litDirective$; + return (r == null ? void 0 : r.constructor) !== n && ((l = r == null ? void 0 : r._$AO) == null || l.call(r, !1), n === void 0 ? r = void 0 : (r = new n(s), r._$AT(s, t, i)), i !== void 0 ? (t._$Co ?? (t._$Co = []))[i] = r : t._$Cl = r), r !== void 0 && (e = M(s, r._$AS(s, e.values), r, i)), e; } -class He { +class je { constructor(e, t) { this._$AV = [], this._$AN = void 0, this._$AD = e, this._$AM = t; } @@ -358,30 +356,30 @@ class He { return this._$AM._$AU; } u(e) { - const { el: { content: t }, parts: i } = this._$AD, o = ((e == null ? void 0 : e.creationScope) ?? U).importNode(t, !0); - C.currentNode = o; - let n = C.nextNode(), r = 0, l = 0, a = i[0]; + const { el: { content: t }, parts: i } = this._$AD, r = ((e == null ? void 0 : e.creationScope) ?? O).importNode(t, !0); + E.currentNode = r; + let n = E.nextNode(), o = 0, l = 0, a = i[0]; for (; a !== void 0; ) { - if (r === a.index) { + if (o === a.index) { let p; - a.type === 2 ? p = new B(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new Le(n, this, e)), this._$AV.push(p), a = i[++l]; + a.type === 2 ? p = new W(n, n.nextSibling, this, e) : a.type === 1 ? p = new a.ctor(n, a.name, a.strings, this, e) : a.type === 6 && (p = new Le(n, this, e)), this._$AV.push(p), a = i[++l]; } - r !== (a == null ? void 0 : a.index) && (n = C.nextNode(), r++); + o !== (a == null ? void 0 : a.index) && (n = E.nextNode(), o++); } - return C.currentNode = U, o; + return E.currentNode = O, r; } p(e) { let t = 0; for (const i of this._$AV) i !== void 0 && (i.strings !== void 0 ? (i._$AI(e, i, t), t += i.strings.length - 2) : i._$AI(e[t])), t++; } } -class B { +class W { get _$AU() { var e; return ((e = this._$AM) == null ? void 0 : e._$AU) ?? this._$Cv; } - constructor(e, t, i, o) { - this.type = 2, this._$AH = d, this._$AN = void 0, this._$AA = e, this._$AB = t, this._$AM = i, this.options = o, this._$Cv = (o == null ? void 0 : o.isConnected) ?? !0; + constructor(e, t, i, r) { + this.type = 2, this._$AH = d, this._$AN = void 0, this._$AA = e, this._$AB = t, this._$AM = i, this.options = r, this._$Cv = (r == null ? void 0 : r.isConnected) ?? !0; } get parentNode() { let e = this._$AA.parentNode; @@ -395,7 +393,7 @@ class B { return this._$AB; } _$AI(e, t = this) { - e = M(this, e, t), L(e) ? e === d || e == null || e === "" ? (this._$AH !== d && this._$AR(), this._$AH = d) : e !== this._$AH && e !== T && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : Te(e) ? this.k(e) : this._(e); + e = M(this, e, t), q(e) ? e === d || e == null || e === "" ? (this._$AH !== d && this._$AR(), this._$AH = d) : e !== this._$AH && e !== D && this._(e) : e._$litType$ !== void 0 ? this.$(e) : e.nodeType !== void 0 ? this.T(e) : Me(e) ? this.k(e) : this._(e); } O(e) { return this._$AA.parentNode.insertBefore(e, this._$AB); @@ -404,33 +402,33 @@ class B { this._$AH !== e && (this._$AR(), this._$AH = this.O(e)); } _(e) { - this._$AH !== d && L(this._$AH) ? this._$AA.nextSibling.data = e : this.T(U.createTextNode(e)), this._$AH = e; + this._$AH !== d && q(this._$AH) ? this._$AA.nextSibling.data = e : this.T(O.createTextNode(e)), this._$AH = e; } $(e) { var n; - const { values: t, _$litType$: i } = e, o = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = W.createElement(xe(i.h, i.h[0]), this.options)), i); - if (((n = this._$AH) == null ? void 0 : n._$AD) === o) this._$AH.p(t); + const { values: t, _$litType$: i } = e, r = typeof i == "number" ? this._$AC(e) : (i.el === void 0 && (i.el = L.createElement(Se(i.h, i.h[0]), this.options)), i); + if (((n = this._$AH) == null ? void 0 : n._$AD) === r) this._$AH.p(t); else { - const r = new He(o, this), l = r.u(this.options); - r.p(t), this.T(l), this._$AH = r; + const o = new je(r, this), l = o.u(this.options); + o.p(t), this.T(l), this._$AH = o; } } _$AC(e) { - let t = be.get(e.strings); - return t === void 0 && be.set(e.strings, t = new W(e)), t; + let t = $e.get(e.strings); + return t === void 0 && $e.set(e.strings, t = new L(e)), t; } k(e) { - re(this._$AH) || (this._$AH = [], this._$AR()); + ne(this._$AH) || (this._$AH = [], this._$AR()); const t = this._$AH; - let i, o = 0; - for (const n of e) o === t.length ? t.push(i = new B(this.O(I()), this.O(I()), this, this.options)) : i = t[o], i._$AI(n), o++; - o < t.length && (this._$AR(i && i._$AB.nextSibling, o), t.length = o); + let i, r = 0; + for (const n of e) r === t.length ? t.push(i = new W(this.O(I()), this.O(I()), this, this.options)) : i = t[r], i._$AI(n), r++; + r < t.length && (this._$AR(i && i._$AB.nextSibling, r), t.length = r); } _$AR(e = this._$AA.nextSibling, t) { var i; for ((i = this._$AP) == null ? void 0 : i.call(this, !1, !0, t); e !== this._$AB; ) { - const o = he(e).nextSibling; - he(e).remove(), e = o; + const r = pe(e).nextSibling; + pe(e).remove(), e = r; } } setConnected(e) { @@ -438,32 +436,32 @@ class B { this._$AM === void 0 && (this._$Cv = e, (t = this._$AP) == null || t.call(this, e)); } } -class G { +class Q { get tagName() { return this.element.tagName; } get _$AU() { return this._$AM._$AU; } - constructor(e, t, i, o, n) { - this.type = 1, this._$AH = d, this._$AN = void 0, this.element = e, this.name = t, this._$AM = o, this.options = n, i.length > 2 || i[0] !== "" || i[1] !== "" ? (this._$AH = Array(i.length - 1).fill(new String()), this.strings = i) : this._$AH = d; + constructor(e, t, i, r, n) { + this.type = 1, this._$AH = d, this._$AN = void 0, this.element = e, this.name = t, this._$AM = r, this.options = n, i.length > 2 || i[0] !== "" || i[1] !== "" ? (this._$AH = Array(i.length - 1).fill(new String()), this.strings = i) : this._$AH = d; } - _$AI(e, t = this, i, o) { + _$AI(e, t = this, i, r) { const n = this.strings; - let r = !1; - if (n === void 0) e = M(this, e, t, 0), r = !L(e) || e !== this._$AH && e !== T, r && (this._$AH = e); + let o = !1; + if (n === void 0) e = M(this, e, t, 0), o = !q(e) || e !== this._$AH && e !== D, o && (this._$AH = e); else { const l = e; let a, p; - for (e = n[0], a = 0; a < n.length - 1; a++) p = M(this, l[i + a], t, a), p === T && (p = this._$AH[a]), r || (r = !L(p) || p !== this._$AH[a]), p === d ? e = d : e !== d && (e += (p ?? "") + n[a + 1]), this._$AH[a] = p; + for (e = n[0], a = 0; a < n.length - 1; a++) p = M(this, l[i + a], t, a), p === D && (p = this._$AH[a]), o || (o = !q(p) || p !== this._$AH[a]), p === d ? e = d : e !== d && (e += (p ?? "") + n[a + 1]), this._$AH[a] = p; } - r && !o && this.j(e); + o && !r && this.j(e); } j(e) { e === d ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, e ?? ""); } } -class je extends G { +class Ne extends Q { constructor() { super(...arguments), this.type = 3; } @@ -471,7 +469,7 @@ class je extends G { this.element[this.name] = e === d ? void 0 : e; } } -class Ne extends G { +class Ie extends Q { constructor() { super(...arguments), this.type = 4; } @@ -479,14 +477,14 @@ class Ne extends G { this.element.toggleAttribute(this.name, !!e && e !== d); } } -class Ie extends G { - constructor(e, t, i, o, n) { - super(e, t, i, o, n), this.type = 5; +class qe extends Q { + constructor(e, t, i, r, n) { + super(e, t, i, r, n), this.type = 5; } _$AI(e, t = this) { - if ((e = M(this, e, t, 0) ?? d) === T) return; - const i = this._$AH, o = e === d && i !== d || e.capture !== i.capture || e.once !== i.once || e.passive !== i.passive, n = e !== d && (i === d || o); - o && this.element.removeEventListener(this.name, this, i), n && this.element.addEventListener(this.name, this, e), this._$AH = e; + if ((e = M(this, e, t, 0) ?? d) === D) return; + const i = this._$AH, r = e === d && i !== d || e.capture !== i.capture || e.once !== i.once || e.passive !== i.passive, n = e !== d && (i === d || r); + r && this.element.removeEventListener(this.name, this, i), n && this.element.addEventListener(this.name, this, e), this._$AH = e; } handleEvent(e) { var t; @@ -504,24 +502,24 @@ class Le { M(this, e); } } -const ee = N.litHtmlPolyfillSupport; -ee == null || ee(W, B), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2"); -const We = (s, e, t) => { +const te = N.litHtmlPolyfillSupport; +te == null || te(L, W), (N.litHtmlVersions ?? (N.litHtmlVersions = [])).push("3.3.2"); +const Be = (s, e, t) => { const i = (t == null ? void 0 : t.renderBefore) ?? e; - let o = i._$litPart$; - if (o === void 0) { + let r = i._$litPart$; + if (r === void 0) { const n = (t == null ? void 0 : t.renderBefore) ?? null; - i._$litPart$ = o = new B(e.insertBefore(I(), n), n, void 0, t ?? {}); + i._$litPart$ = r = new W(e.insertBefore(I(), n), n, void 0, t ?? {}); } - return o._$AI(s), o; + return r._$AI(s), r; }; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const E = globalThis; -class y extends D { +const U = globalThis; +class v extends z { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -532,7 +530,7 @@ class y extends D { } update(e) { const t = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = We(t, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(e), this._$Do = Be(t, this.renderRoot, this.renderOptions); } connectedCallback() { var e; @@ -543,20 +541,20 @@ class y extends D { super.disconnectedCallback(), (e = this._$Do) == null || e.setConnected(!1); } render() { - return T; + return D; } } -var $e; -y._$litElement$ = !0, y.finalized = !0, ($e = E.litElementHydrateSupport) == null || $e.call(E, { LitElement: y }); -const te = E.litElementPolyfillSupport; -te == null || te({ LitElement: y }); -(E.litElementVersions ?? (E.litElementVersions = [])).push("4.2.2"); +var ye; +v._$litElement$ = !0, v.finalized = !0, (ye = U.litElementHydrateSupport) == null || ye.call(U, { LitElement: v }); +const se = U.litElementPolyfillSupport; +se == null || se({ LitElement: v }); +(U.litElementVersions ?? (U.litElementVersions = [])).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const F = (s) => (e, t) => { +const K = (s) => (e, t) => { t !== void 0 ? t.addInitializer(() => { customElements.define(s, e); }) : customElements.define(s, e); @@ -566,31 +564,31 @@ const F = (s) => (e, t) => { * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ -const qe = { attribute: !0, type: String, converter: J, reflect: !1, hasChanged: oe }, Be = (s = qe, e, t) => { - const { kind: i, metadata: o } = t; - let n = globalThis.litPropertyMetadata.get(o); - if (n === void 0 && globalThis.litPropertyMetadata.set(o, n = /* @__PURE__ */ new Map()), i === "setter" && ((s = Object.create(s)).wrapped = !0), n.set(t.name, s), i === "accessor") { - const { name: r } = t; +const Fe = { attribute: !0, type: String, converter: Z, reflect: !1, hasChanged: oe }, We = (s = Fe, e, t) => { + const { kind: i, metadata: r } = t; + let n = globalThis.litPropertyMetadata.get(r); + if (n === void 0 && globalThis.litPropertyMetadata.set(r, n = /* @__PURE__ */ new Map()), i === "setter" && ((s = Object.create(s)).wrapped = !0), n.set(t.name, s), i === "accessor") { + const { name: o } = t; return { set(l) { const a = e.get.call(this); - e.set.call(this, l), this.requestUpdate(r, a, s, !0, l); + e.set.call(this, l), this.requestUpdate(o, a, s, !0, l); }, init(l) { - return l !== void 0 && this.C(r, void 0, s, l), l; + return l !== void 0 && this.C(o, void 0, s, l), l; } }; } if (i === "setter") { - const { name: r } = t; + const { name: o } = t; return function(l) { - const a = this[r]; - e.call(this, l), this.requestUpdate(r, a, s, !0, l); + const a = this[o]; + e.call(this, l), this.requestUpdate(o, a, s, !0, l); }; } throw Error("Unsupported decorator location: " + i); }; function f(s) { - return (e, t) => typeof t == "object" ? Be(s, e, t) : ((i, o, n) => { - const r = o.hasOwnProperty(n); - return o.constructor.createProperty(n, i), r ? Object.getOwnPropertyDescriptor(o, n) : void 0; + return (e, t) => typeof t == "object" ? We(s, e, t) : ((i, r, n) => { + const o = r.hasOwnProperty(n); + return r.constructor.createProperty(n, i), o ? Object.getOwnPropertyDescriptor(r, n) : void 0; })(s, e, t); } /** @@ -601,18 +599,18 @@ function f(s) { function u(s) { return f({ ...s, state: !0, attribute: !1 }); } -function ne(s, e) { +function ae(s, e) { const t = new WebSocket(s); return t.onmessage = (i) => { - var o, n, r, l; + var r, n, o, l; try { const a = JSON.parse(i.data); - ((n = (o = a.type) == null ? void 0 : o.startsWith) != null && n.call(o, "process.") || (l = (r = a.channel) == null ? void 0 : r.startsWith) != null && l.call(r, "process.")) && e(a); + ((n = (r = a.type) == null ? void 0 : r.startsWith) != null && n.call(r, "process.") || (l = (o = a.channel) == null ? void 0 : o.startsWith) != null && l.call(o, "process.")) && e(a); } catch { } }, t; } -class Fe { +class B { constructor(e = "") { this.baseUrl = e; } @@ -621,10 +619,10 @@ class Fe { } async request(e, t) { var n; - const o = await (await fetch(`${this.base}${e}`, t)).json(); - if (!o.success) - throw new Error(((n = o.error) == null ? void 0 : n.message) ?? "Request failed"); - return o.data; + const r = await (await fetch(`${this.base}${e}`, t)).json(); + if (!r.success) + throw new Error(((n = r.error) == null ? void 0 : n.message) ?? "Request failed"); + return r.data; } /** List all alive daemons from the registry. */ listDaemons() { @@ -644,18 +642,89 @@ class Fe { healthCheck(e, t) { return this.request(`/daemons/${e}/${t}/health`); } + /** List all managed processes. */ + listProcesses(e = !1) { + const t = e ? "?runningOnly=true" : ""; + return this.request(`/processes${t}`); + } + /** Get a single managed process by ID. */ + getProcess(e) { + return this.request(`/processes/${e}`); + } + /** Get the captured stdout/stderr for a managed process by ID. */ + getProcessOutput(e) { + return this.request(`/processes/${e}/output`); + } + /** Start a managed process asynchronously. */ + startProcess(e) { + return this.request("/processes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(e) + }); + } + /** Run a managed process synchronously and return its combined output. */ + runProcess(e) { + return this.request("/processes/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(e) + }); + } + /** Wait for a managed process to exit and return its final snapshot. */ + waitProcess(e) { + return this.request(`/processes/${e}/wait`, { + method: "POST" + }); + } + /** Write input to a managed process stdin pipe. */ + inputProcess(e, t) { + return this.request(`/processes/${e}/input`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: t }) + }); + } + /** Close a managed process stdin pipe. */ + closeProcessStdin(e) { + return this.request(`/processes/${e}/close-stdin`, { + method: "POST" + }); + } + /** Kill a managed process by ID. */ + killProcess(e) { + return this.request(`/processes/${e}/kill`, { + method: "POST" + }); + } + /** Send a signal to a managed process by ID. */ + signalProcess(e, t) { + return this.request(`/processes/${e}/signal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ signal: String(t) }) + }); + } + /** Run a process pipeline using the configured runner. */ + runPipeline(e, t) { + return this.request("/pipelines/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mode: e, specs: t }) + }); + } } -var Ve = Object.defineProperty, Ke = Object.getOwnPropertyDescriptor, k = (s, e, t, i) => { - for (var o = i > 1 ? void 0 : i ? Ke(e, t) : e, n = s.length - 1, r; n >= 0; n--) - (r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o); - return i && o && Ve(e, t, o), o; +var Ke = Object.defineProperty, Je = Object.getOwnPropertyDescriptor, A = (s, e, t, i) => { + for (var r = i > 1 ? void 0 : i ? Je(e, t) : e, n = s.length - 1, o; n >= 0; n--) + (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); + return i && r && Ke(e, t, r), r; }; -let g = class extends y { +let b = class extends v { constructor() { super(...arguments), this.apiUrl = "", this.daemons = [], this.loading = !0, this.error = "", this.stopping = /* @__PURE__ */ new Set(), this.checking = /* @__PURE__ */ new Set(), this.healthResults = /* @__PURE__ */ new Map(); } connectedCallback() { - super.connectedCallback(), this.api = new Fe(this.apiUrl), this.loadDaemons(); + super.connectedCallback(), this.api = new B(this.apiUrl), this.loadDaemons(); } async loadDaemons() { this.loading = !0, this.error = ""; @@ -770,7 +839,7 @@ let g = class extends y { `; } }; -g.styles = q` +b.styles = F` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -923,54 +992,57 @@ g.styles = q` margin-bottom: 1rem; } `; -k([ +A([ f({ attribute: "api-url" }) -], g.prototype, "apiUrl", 2); -k([ +], b.prototype, "apiUrl", 2); +A([ u() -], g.prototype, "daemons", 2); -k([ +], b.prototype, "daemons", 2); +A([ u() -], g.prototype, "loading", 2); -k([ +], b.prototype, "loading", 2); +A([ u() -], g.prototype, "error", 2); -k([ +], b.prototype, "error", 2); +A([ u() -], g.prototype, "stopping", 2); -k([ +], b.prototype, "stopping", 2); +A([ u() -], g.prototype, "checking", 2); -k([ +], b.prototype, "checking", 2); +A([ u() -], g.prototype, "healthResults", 2); -g = k([ - F("core-process-daemons") -], g); -var Je = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, S = (s, e, t, i) => { - for (var o = i > 1 ? void 0 : i ? Ze(e, t) : e, n = s.length - 1, r; n >= 0; n--) - (r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o); - return i && o && Je(e, t, o), o; +], b.prototype, "healthResults", 2); +b = A([ + K("core-process-daemons") +], b); +var Ve = Object.defineProperty, Ze = Object.getOwnPropertyDescriptor, _ = (s, e, t, i) => { + for (var r = i > 1 ? void 0 : i ? Ze(e, t) : e, n = s.length - 1, o; n >= 0; n--) + (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); + return i && r && Ve(e, t, r), r; }; -let b = class extends y { +let g = class extends v { constructor() { - super(...arguments), this.apiUrl = "", this.wsUrl = "", this.selectedId = "", this.processes = [], this.loading = !1, this.error = "", this.connected = !1, this.ws = null; + super(...arguments), this.apiUrl = "", this.wsUrl = "", this.selectedId = "", this.processes = [], this.loading = !1, this.error = "", this.connected = !1, this.killing = /* @__PURE__ */ new Set(), this.ws = null; } connectedCallback() { - super.connectedCallback(), this.loadProcesses(); + super.connectedCallback(), this.api = new B(this.apiUrl), this.loadProcesses(); } disconnectedCallback() { super.disconnectedCallback(), this.disconnect(); } updated(s) { - s.has("wsUrl") && (this.disconnect(), this.processes = [], this.loadProcesses()); + s.has("apiUrl") && (this.api = new B(this.apiUrl)), (s.has("wsUrl") || s.has("apiUrl")) && (this.disconnect(), this.loadProcesses()); } async loadProcesses() { - if (this.error = "", this.loading = !1, !this.wsUrl) { - this.processes = []; - return; + this.loading = !0, this.error = ""; + try { + this.processes = await this.api.listProcesses(), this.wsUrl && this.connect(); + } catch (s) { + this.error = s.message ?? "Failed to load processes", this.processes = []; + } finally { + this.loading = !1; } - this.connect(); } handleSelect(s) { this.dispatchEvent( @@ -981,6 +1053,70 @@ let b = class extends y { }) ); } + async handleKill(s) { + this.killing = /* @__PURE__ */ new Set([...this.killing, s.id]); + try { + await this.api.killProcess(s.id), await this.loadProcesses(); + } catch (e) { + this.error = e.message ?? "Failed to kill process"; + } finally { + const e = new Set(this.killing); + e.delete(s.id), this.killing = e; + } + } + connect() { + !this.wsUrl || this.ws || (this.ws = ae(this.wsUrl, (s) => { + this.applyEvent(s); + }), this.ws.onopen = () => { + this.connected = !0; + }, this.ws.onclose = () => { + this.connected = !1; + }); + } + disconnect() { + this.ws && (this.ws.close(), this.ws = null), this.connected = !1; + } + applyEvent(s) { + const e = s.channel ?? s.type ?? "", t = s.data ?? {}; + if (!t.id) + return; + const i = new Map(this.processes.map((n) => [n.id, n])), r = i.get(t.id); + switch (e) { + case "process.started": + i.set(t.id, this.normalizeProcess(t, r, "running")); + break; + case "process.exited": + i.set(t.id, this.normalizeProcess(t, r, t.exitCode === -1 && t.error ? "failed" : "exited")); + break; + case "process.killed": + i.set(t.id, this.normalizeProcess(t, r, "killed")); + break; + default: + return; + } + this.processes = this.sortProcesses(i); + } + normalizeProcess(s, e, t) { + const i = s.startedAt ?? (e == null ? void 0 : e.startedAt) ?? (/* @__PURE__ */ new Date()).toISOString(); + return { + id: s.id, + command: s.command ?? (e == null ? void 0 : e.command) ?? "", + args: s.args ?? (e == null ? void 0 : e.args) ?? [], + dir: s.dir ?? (e == null ? void 0 : e.dir) ?? "", + startedAt: i, + running: t === "running", + status: t, + exitCode: s.exitCode ?? (e == null ? void 0 : e.exitCode) ?? (t === "killed" ? -1 : 0), + duration: s.duration ?? (e == null ? void 0 : e.duration) ?? 0, + pid: s.pid ?? (e == null ? void 0 : e.pid) ?? 0 + }; + } + sortProcesses(s) { + return [...s.values()].sort((e, t) => { + const i = new Date(e.startedAt).getTime(), r = new Date(t.startedAt).getTime(); + return i === r ? e.id.localeCompare(t.id) : i - r; + }); + } formatUptime(s) { try { const e = Date.now() - new Date(s).getTime(), t = Math.floor(e / 1e3); @@ -991,60 +1127,12 @@ let b = class extends y { return "unknown"; } } - connect() { - this.ws = ne(this.wsUrl, (s) => { - this.applyEvent(s); - }), this.ws.onopen = () => { - this.connected = !0; - }, this.ws.onclose = () => { - this.connected = !1; - }; - } - disconnect() { - this.ws && (this.ws.close(), this.ws = null), this.connected = !1; - } - applyEvent(s) { - const e = s.channel ?? s.type ?? "", t = s.data ?? {}; - if (!t.id) - return; - const i = new Map(this.processes.map((n) => [n.id, n])), o = i.get(t.id); - if (e === "process.started") { - i.set(t.id, this.normalizeProcess(t, o, "running")), this.processes = this.sortProcesses(i); - return; - } - if (e === "process.exited") { - i.set(t.id, this.normalizeProcess(t, o, "exited")), this.processes = this.sortProcesses(i); - return; - } - if (e === "process.killed") { - i.set(t.id, this.normalizeProcess(t, o, "killed")), this.processes = this.sortProcesses(i); - return; - } - } - normalizeProcess(s, e, t) { - return { - id: s.id, - command: s.command ?? (e == null ? void 0 : e.command) ?? "", - args: s.args ?? (e == null ? void 0 : e.args) ?? [], - dir: s.dir ?? (e == null ? void 0 : e.dir) ?? "", - startedAt: s.startedAt ?? (e == null ? void 0 : e.startedAt) ?? (/* @__PURE__ */ new Date()).toISOString(), - status: t, - exitCode: s.exitCode ?? (e == null ? void 0 : e.exitCode) ?? (t === "killed" ? -1 : 0), - duration: s.duration ?? (e == null ? void 0 : e.duration) ?? 0, - pid: s.pid ?? (e == null ? void 0 : e.pid) ?? 0 - }; - } - sortProcesses(s) { - return [...s.values()].sort( - (e, t) => new Date(t.startedAt).getTime() - new Date(e.startedAt).getTime() - ); - } render() { return this.loading ? c`
Loading processes\u2026
` : c` ${this.error ? c`
${this.error}
` : d} ${this.processes.length === 0 ? c`
- ${this.wsUrl ? this.connected ? "Waiting for process events from the WebSocket feed." : "Connecting to the process event stream..." : "Set a WebSocket URL to receive live process events."} + ${this.wsUrl ? this.connected ? "Receiving live process updates." : "Connecting to the process event stream..." : "Managed processes are loaded from the process REST API."}
No managed processes.
` : c` @@ -1076,12 +1164,12 @@ let b = class extends y {
` : d} @@ -1094,7 +1182,7 @@ let b = class extends y { `; } }; -b.styles = q` +g.styles = F` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -1258,50 +1346,87 @@ b.styles = q` margin-bottom: 1rem; } `; -S([ +_([ f({ attribute: "api-url" }) -], b.prototype, "apiUrl", 2); -S([ +], g.prototype, "apiUrl", 2); +_([ f({ attribute: "ws-url" }) -], b.prototype, "wsUrl", 2); -S([ +], g.prototype, "wsUrl", 2); +_([ f({ attribute: "selected-id" }) -], b.prototype, "selectedId", 2); -S([ +], g.prototype, "selectedId", 2); +_([ u() -], b.prototype, "processes", 2); -S([ +], g.prototype, "processes", 2); +_([ u() -], b.prototype, "loading", 2); -S([ +], g.prototype, "loading", 2); +_([ u() -], b.prototype, "error", 2); -S([ +], g.prototype, "error", 2); +_([ u() -], b.prototype, "connected", 2); -b = S([ - F("core-process-list") -], b); -var Ge = Object.defineProperty, Qe = Object.getOwnPropertyDescriptor, O = (s, e, t, i) => { - for (var o = i > 1 ? void 0 : i ? Qe(e, t) : e, n = s.length - 1, r; n >= 0; n--) - (r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o); - return i && o && Ge(e, t, o), o; +], g.prototype, "connected", 2); +_([ + u() +], g.prototype, "killing", 2); +g = _([ + K("core-process-list") +], g); +var Ge = Object.defineProperty, Qe = Object.getOwnPropertyDescriptor, P = (s, e, t, i) => { + for (var r = i > 1 ? void 0 : i ? Qe(e, t) : e, n = s.length - 1, o; n >= 0; n--) + (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); + return i && r && Ge(e, t, r), r; }; -let v = class extends y { +let $ = class extends v { constructor() { - super(...arguments), this.apiUrl = "", this.wsUrl = "", this.processId = "", this.lines = [], this.autoScroll = !0, this.connected = !1, this.ws = null; + super(...arguments), this.apiUrl = "", this.wsUrl = "", this.processId = "", this.lines = [], this.autoScroll = !0, this.connected = !1, this.loadingSnapshot = !1, this.ws = null, this.api = new B(this.apiUrl), this.syncToken = 0; } connectedCallback() { - super.connectedCallback(), this.wsUrl && this.processId && this.connect(); + super.connectedCallback(), this.syncSources(); } disconnectedCallback() { super.disconnectedCallback(), this.disconnect(); } updated(s) { - (s.has("processId") || s.has("wsUrl")) && (this.disconnect(), this.lines = [], this.wsUrl && this.processId && this.connect()), this.autoScroll && this.scrollToBottom(); + s.has("apiUrl") && (this.api = new B(this.apiUrl)), (s.has("processId") || s.has("wsUrl") || s.has("apiUrl")) && this.syncSources(), this.autoScroll && this.scrollToBottom(); + } + syncSources() { + this.disconnect(), this.lines = [], this.processId && this.loadSnapshotAndConnect(); + } + async loadSnapshotAndConnect() { + const s = ++this.syncToken; + if (this.processId) { + if (this.apiUrl) { + this.loadingSnapshot = !0; + try { + const e = await this.api.getProcessOutput(this.processId); + if (s !== this.syncToken) + return; + const t = this.linesFromOutput(e); + t.length > 0 && (this.lines = t); + } catch { + } finally { + s === this.syncToken && (this.loadingSnapshot = !1); + } + } + s === this.syncToken && this.wsUrl && this.connect(); + } + } + linesFromOutput(s) { + if (!s) + return []; + const t = s.replace(/\r\n/g, ` +`).split(` +`); + return t.length > 0 && t[t.length - 1] === "" && t.pop(), t.map((i) => ({ + text: i, + stream: "stdout", + timestamp: Date.now() + })); } connect() { - this.ws = ne(this.wsUrl, (s) => { + this.ws = ae(this.wsUrl, (s) => { const e = s.data; if (!e) return; (s.channel ?? s.type ?? "") === "process.output" && e.id === this.processId && (this.lines = [ @@ -1349,7 +1474,7 @@ let v = class extends y {
- ${this.lines.length === 0 ? c`
Waiting for output\u2026
` : this.lines.map( + ${this.loadingSnapshot && this.lines.length === 0 ? c`
Loading snapshot\u2026
` : this.lines.length === 0 ? c`
Waiting for output\u2026
` : this.lines.map( (s) => c`
${s.stream}${s.text} @@ -1360,7 +1485,7 @@ let v = class extends y { ` : c`
Select a process to view its output.
`; } }; -v.styles = q` +$.styles = F` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -1464,33 +1589,36 @@ v.styles = q` font-size: 0.8125rem; } `; -O([ +P([ f({ attribute: "api-url" }) -], v.prototype, "apiUrl", 2); -O([ +], $.prototype, "apiUrl", 2); +P([ f({ attribute: "ws-url" }) -], v.prototype, "wsUrl", 2); -O([ +], $.prototype, "wsUrl", 2); +P([ f({ attribute: "process-id" }) -], v.prototype, "processId", 2); -O([ +], $.prototype, "processId", 2); +P([ u() -], v.prototype, "lines", 2); -O([ +], $.prototype, "lines", 2); +P([ u() -], v.prototype, "autoScroll", 2); -O([ +], $.prototype, "autoScroll", 2); +P([ u() -], v.prototype, "connected", 2); -v = O([ - F("core-process-output") -], v); -var Xe = Object.defineProperty, Ye = Object.getOwnPropertyDescriptor, Q = (s, e, t, i) => { - for (var o = i > 1 ? void 0 : i ? Ye(e, t) : e, n = s.length - 1, r; n >= 0; n--) - (r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o); - return i && o && Xe(e, t, o), o; +], $.prototype, "connected", 2); +P([ + u() +], $.prototype, "loadingSnapshot", 2); +$ = P([ + K("core-process-output") +], $); +var Xe = Object.defineProperty, Ye = Object.getOwnPropertyDescriptor, X = (s, e, t, i) => { + for (var r = i > 1 ? void 0 : i ? Ye(e, t) : e, n = s.length - 1, o; n >= 0; n--) + (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); + return i && r && Xe(e, t, r), r; }; -let R = class extends y { +let R = class extends v { constructor() { super(...arguments), this.apiUrl = "", this.result = null, this.expandedOutputs = /* @__PURE__ */ new Set(); } @@ -1513,13 +1641,11 @@ let R = class extends y { if (!this.result) return c`
- Pipeline runner endpoints are pending. Pass pipeline results via the - result property, or results will appear here once the REST - API for pipeline execution is available. + Pass pipeline results via the result property.
No pipeline results.
`; - const { results: s, duration: e, passed: t, failed: i, skipped: o, success: n } = this.result; + const { results: s, duration: e, passed: t, failed: i, skipped: r, success: n } = this.result; return c`
@@ -1534,7 +1660,7 @@ let R = class extends y { Failed
- ${o} + ${r} Skipped
${this.formatDuration(e)} @@ -1542,24 +1668,24 @@ let R = class extends y {
${s.map( - (r) => c` + (o) => c`
- ${r.name} - ${this.resultStatus(r)} + ${o.name} + ${this.resultStatus(o)}
- ${this.formatDuration(r.duration)} + ${this.formatDuration(o.duration)}
- ${r.exitCode !== 0 && !r.skipped ? c`exit ${r.exitCode}` : d} + ${o.exitCode !== 0 && !o.skipped ? c`exit ${o.exitCode}` : d}
- ${r.error ? c`
${r.error}
` : d} - ${r.output ? c` - - ${this.expandedOutputs.has(r.name) ? c`
${r.output}
` : d} + ${this.expandedOutputs.has(o.name) ? c`
${o.output}
` : d} ` : d}
` @@ -1568,7 +1694,7 @@ let R = class extends y { `; } }; -R.styles = q` +R.styles = F` :host { display: block; font-family: system-ui, -apple-system, sans-serif; @@ -1763,24 +1889,24 @@ R.styles = q` margin-bottom: 1rem; } `; -Q([ +X([ f({ attribute: "api-url" }) ], R.prototype, "apiUrl", 2); -Q([ +X([ f({ type: Object }) ], R.prototype, "result", 2); -Q([ +X([ u() ], R.prototype, "expandedOutputs", 2); -R = Q([ - F("core-process-runner") +R = X([ + K("core-process-runner") ], R); -var et = Object.defineProperty, tt = Object.getOwnPropertyDescriptor, z = (s, e, t, i) => { - for (var o = i > 1 ? void 0 : i ? tt(e, t) : e, n = s.length - 1, r; n >= 0; n--) - (r = s[n]) && (o = (i ? r(e, t, o) : r(o)) || o); - return i && o && et(e, t, o), o; +var et = Object.defineProperty, tt = Object.getOwnPropertyDescriptor, T = (s, e, t, i) => { + for (var r = i > 1 ? void 0 : i ? tt(e, t) : e, n = s.length - 1, o; n >= 0; n--) + (o = s[n]) && (r = (i ? o(e, t, r) : o(r)) || r); + return i && r && et(e, t, r), r; }; -let w = class extends y { +let w = class extends v { constructor() { super(...arguments), this.apiUrl = "", this.wsUrl = "", this.activeTab = "daemons", this.wsConnected = !1, this.lastEvent = "", this.selectedProcessId = "", this.ws = null, this.tabs = [ { id: "daemons", label: "Daemons" }, @@ -1795,7 +1921,7 @@ let w = class extends y { super.disconnectedCallback(), this.ws && (this.ws.close(), this.ws = null); } connectWs() { - this.ws = ne(this.wsUrl, (s) => { + this.ws = ae(this.wsUrl, (s) => { this.lastEvent = s.channel ?? s.type ?? "", this.requestUpdate(); }), this.ws.onopen = () => { this.wsConnected = !0; @@ -1873,7 +1999,7 @@ let w = class extends y { `; } }; -w.styles = q` +w.styles = F` :host { display: flex; flex-direction: column; @@ -1987,33 +2113,33 @@ w.styles = q` background: #d1d5db; } `; -z([ +T([ f({ attribute: "api-url" }) ], w.prototype, "apiUrl", 2); -z([ +T([ f({ attribute: "ws-url" }) ], w.prototype, "wsUrl", 2); -z([ +T([ u() ], w.prototype, "activeTab", 2); -z([ +T([ u() ], w.prototype, "wsConnected", 2); -z([ +T([ u() ], w.prototype, "lastEvent", 2); -z([ +T([ u() ], w.prototype, "selectedProcessId", 2); -w = z([ - F("core-process-panel") +w = T([ + K("core-process-panel") ], w); export { - Fe as ProcessApi, - g as ProcessDaemons, - b as ProcessList, - v as ProcessOutput, + B as ProcessApi, + b as ProcessDaemons, + g as ProcessList, + $ as ProcessOutput, w as ProcessPanel, R as ProcessRunner, - ne as connectProcessEvents + ae as connectProcessEvents }; diff --git a/process.go b/process.go index 01bf7aa..30c7e3d 100644 --- a/process.go +++ b/process.go @@ -2,23 +2,24 @@ package process import ( "context" - "strconv" + "fmt" + "os" + "os/exec" "sync" "syscall" "time" - "dappco.re/go/core" + coreerr "dappco.re/go/core/log" + goio "io" ) -type processStdin interface { - Write(p []byte) (n int, err error) - Close() error -} - -// ManagedProcess represents a tracked external process started by the service. +// ManagedProcess represents a managed external process. +// +// Example: +// +// proc, err := svc.Start(ctx, "echo", "hello") type ManagedProcess struct { ID string - PID int Command string Args []string Dir string @@ -28,28 +29,42 @@ type ManagedProcess struct { ExitCode int Duration time.Duration - cmd *execCmd - ctx context.Context - cancel context.CancelFunc - output *RingBuffer - stdin processStdin - done chan struct{} - mu sync.RWMutex - gracePeriod time.Duration - killGroup bool - lastSignal string - killEmitted bool + cmd *exec.Cmd + ctx context.Context + cancel context.CancelFunc + output *RingBuffer + stdin goio.WriteCloser + done chan struct{} + mu sync.RWMutex + gracePeriod time.Duration + killGroup bool + killNotified bool + killSignal string } -// Process is kept as a compatibility alias for ManagedProcess. +// Process is kept as an alias for ManagedProcess for compatibility. type Process = ManagedProcess // Info returns a snapshot of process state. -func (p *ManagedProcess) Info() ProcessInfo { +// +// Example: +// +// info := proc.Info() +func (p *ManagedProcess) Info() Info { p.mu.RLock() defer p.mu.RUnlock() - return ProcessInfo{ + pid := 0 + if p.cmd != nil && p.cmd.Process != nil { + pid = p.cmd.Process.Pid + } + + duration := p.Duration + if p.Status == StatusRunning { + duration = time.Since(p.StartedAt) + } + + return Info{ ID: p.ID, Command: p.Command, Args: append([]string(nil), p.Args...), @@ -58,12 +73,16 @@ func (p *ManagedProcess) Info() ProcessInfo { Running: p.Status == StatusRunning, Status: p.Status, ExitCode: p.ExitCode, - Duration: p.Duration, - PID: p.PID, + Duration: duration, + PID: pid, } } // Output returns the captured output as a string. +// +// Example: +// +// fmt.Println(proc.Output()) func (p *ManagedProcess) Output() string { p.mu.RLock() defer p.mu.RUnlock() @@ -74,6 +93,10 @@ func (p *ManagedProcess) Output() string { } // OutputBytes returns the captured output as bytes. +// +// Example: +// +// data := proc.OutputBytes() func (p *ManagedProcess) OutputBytes() []byte { p.mu.RLock() defer p.mu.RUnlock() @@ -85,61 +108,95 @@ func (p *ManagedProcess) OutputBytes() []byte { // IsRunning returns true if the process is still executing. func (p *ManagedProcess) IsRunning() bool { - select { - case <-p.done: - return false - default: - return true - } + p.mu.RLock() + defer p.mu.RUnlock() + return p.Status == StatusRunning } // Wait blocks until the process exits. +// +// Example: +// +// if err := proc.Wait(); err != nil { return err } func (p *ManagedProcess) Wait() error { <-p.done p.mu.RLock() defer p.mu.RUnlock() if p.Status == StatusFailed { - return core.E("process.wait", core.Concat("process failed to start: ", p.ID), nil) + return coreerr.E("Process.Wait", fmt.Sprintf("process failed to start: %s", p.ID), nil) } if p.Status == StatusKilled { - return core.E("process.wait", core.Concat("process was killed: ", p.ID), nil) + return coreerr.E("Process.Wait", fmt.Sprintf("process was killed: %s", p.ID), nil) } if p.ExitCode != 0 { - return core.E("process.wait", core.Concat("process exited with code ", strconv.Itoa(p.ExitCode)), nil) + return coreerr.E("Process.Wait", fmt.Sprintf("process exited with code %d", p.ExitCode), nil) } return nil } // Done returns a channel that closes when the process exits. +// +// Example: +// +// <-proc.Done() func (p *ManagedProcess) Done() <-chan struct{} { return p.done } // Kill forcefully terminates the process. // If KillGroup is set, kills the entire process group. +// +// Example: +// +// _ = proc.Kill() func (p *ManagedProcess) Kill() error { + _, err := p.kill() + return err +} + +// kill terminates the process and reports whether a signal was actually sent. +func (p *ManagedProcess) kill() (bool, error) { p.mu.Lock() defer p.mu.Unlock() if p.Status != StatusRunning { - return nil + return false, nil } if p.cmd == nil || p.cmd.Process == nil { - return nil + return false, nil } - p.lastSignal = "SIGKILL" if p.killGroup { // Kill entire process group (negative PID) - return syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) + return true, syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) } - return p.cmd.Process.Kill() + return true, p.cmd.Process.Kill() +} + +// killTree forcefully terminates the process group when one exists. +func (p *ManagedProcess) killTree() (bool, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.Status != StatusRunning { + return false, nil + } + + if p.cmd == nil || p.cmd.Process == nil { + return false, nil + } + + return true, syscall.Kill(-p.cmd.Process.Pid, syscall.SIGKILL) } // Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period. // If GracePeriod was not set (zero), falls back to immediate Kill(). // If KillGroup is set, signals are sent to the entire process group. +// +// Example: +// +// _ = proc.Shutdown() func (p *ManagedProcess) Shutdown() error { p.mu.RLock() grace := p.gracePeriod @@ -180,11 +237,79 @@ func (p *ManagedProcess) terminate() error { if p.killGroup { pid = -pid } - p.lastSignal = "SIGTERM" return syscall.Kill(pid, syscall.SIGTERM) } +// Signal sends a signal to the process. +// +// Example: +// +// _ = proc.Signal(os.Interrupt) +func (p *ManagedProcess) Signal(sig os.Signal) error { + p.mu.RLock() + status := p.Status + cmd := p.cmd + killGroup := p.killGroup + p.mu.RUnlock() + + if status != StatusRunning { + return ErrProcessNotRunning + } + + if cmd == nil || cmd.Process == nil { + return nil + } + + if !killGroup { + return cmd.Process.Signal(sig) + } + + sysSig, ok := sig.(syscall.Signal) + if !ok { + return cmd.Process.Signal(sig) + } + + if sysSig == 0 { + return syscall.Kill(-cmd.Process.Pid, 0) + } + + if err := syscall.Kill(-cmd.Process.Pid, sysSig); err != nil { + return err + } + + // Some shells briefly ignore or defer the signal while they are still + // initialising child jobs. Retry a few times after short delays so the + // whole process group is more reliably terminated. If the requested signal + // still does not stop the group, escalate to SIGKILL so callers do not hang. + go func(pid int, sig syscall.Signal, done <-chan struct{}) { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for i := 0; i < 5; i++ { + select { + case <-done: + return + case <-ticker.C: + _ = syscall.Kill(-pid, sig) + } + } + + select { + case <-done: + return + default: + _ = syscall.Kill(-pid, syscall.SIGKILL) + } + }(cmd.Process.Pid, sysSig, p.done) + + return nil +} + // SendInput writes to the process stdin. +// +// Example: +// +// _ = proc.SendInput("hello\n") func (p *ManagedProcess) SendInput(input string) error { p.mu.RLock() defer p.mu.RUnlock() @@ -202,6 +327,10 @@ func (p *ManagedProcess) SendInput(input string) error { } // CloseStdin closes the process stdin pipe. +// +// Example: +// +// _ = proc.CloseStdin() func (p *ManagedProcess) CloseStdin() error { p.mu.Lock() defer p.mu.Unlock() @@ -214,20 +343,3 @@ func (p *ManagedProcess) CloseStdin() error { p.stdin = nil return err } - -func (p *ManagedProcess) requestedSignal() string { - p.mu.RLock() - defer p.mu.RUnlock() - return p.lastSignal -} - -func (p *ManagedProcess) markKillEmitted() bool { - p.mu.Lock() - defer p.mu.Unlock() - - if p.killEmitted { - return false - } - p.killEmitted = true - return true -} diff --git a/process_global.go b/process_global.go new file mode 100644 index 0000000..79862b3 --- /dev/null +++ b/process_global.go @@ -0,0 +1,306 @@ +package process + +import ( + "context" + "os" + "sync" + "sync/atomic" + + "dappco.re/go/core" + coreerr "dappco.re/go/core/log" +) + +// Global default service used by package-level helpers. +var ( + defaultService atomic.Pointer[Service] + defaultOnce sync.Once + defaultErr error +) + +// Default returns the global process service. +// Returns nil if not initialised. +// +// Example: +// +// svc := process.Default() +func Default() *Service { + return defaultService.Load() +} + +// SetDefault sets the global process service. +// Thread-safe: can be called concurrently with Default(). +// +// Example: +// +// _ = process.SetDefault(svc) +func SetDefault(s *Service) error { + if s == nil { + return ErrSetDefaultNil + } + defaultService.Store(s) + return nil +} + +// Init initializes the default global service with a Core instance. +// This is typically called during application startup. +// +// Example: +// +// _ = process.Init(coreInstance) +func Init(c *core.Core) error { + defaultOnce.Do(func() { + factory := NewService(Options{}) + svc, err := factory(c) + if err != nil { + defaultErr = err + return + } + defaultService.Store(svc.(*Service)) + }) + return defaultErr +} + +// Register creates a process service for Core registration. +// +// Example: +// +// result := process.Register(coreInstance) +func Register(c *core.Core) core.Result { + factory := NewService(Options{}) + svc, err := factory(c) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: svc, OK: true} +} + +// --- Global convenience functions --- + +// Start spawns a new process using the default service. +// +// Example: +// +// proc, err := process.Start(ctx, "echo", "hello") +func Start(ctx context.Context, command string, args ...string) (*Process, error) { + svc := Default() + if svc == nil { + return nil, ErrServiceNotInitialized + } + return svc.Start(ctx, command, args...) +} + +// Run executes a command and waits for completion using the default service. +// +// Example: +// +// out, err := process.Run(ctx, "echo", "hello") +func Run(ctx context.Context, command string, args ...string) (string, error) { + svc := Default() + if svc == nil { + return "", ErrServiceNotInitialized + } + return svc.Run(ctx, command, args...) +} + +// Get returns a process by ID from the default service. +// +// Example: +// +// proc, err := process.Get("proc-1") +func Get(id string) (*Process, error) { + svc := Default() + if svc == nil { + return nil, ErrServiceNotInitialized + } + return svc.Get(id) +} + +// Output returns the captured output for a process from the default service. +// +// Example: +// +// out, err := process.Output("proc-1") +func Output(id string) (string, error) { + svc := Default() + if svc == nil { + return "", ErrServiceNotInitialized + } + return svc.Output(id) +} + +// Input writes data to the stdin of a managed process using the default service. +// +// Example: +// +// _ = process.Input("proc-1", "hello\n") +func Input(id string, input string) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.Input(id, input) +} + +// CloseStdin closes the stdin pipe of a managed process using the default service. +// +// Example: +// +// _ = process.CloseStdin("proc-1") +func CloseStdin(id string) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.CloseStdin(id) +} + +// Wait blocks until a managed process exits and returns its final snapshot. +// +// Example: +// +// info, err := process.Wait("proc-1") +func Wait(id string) (Info, error) { + svc := Default() + if svc == nil { + return Info{}, ErrServiceNotInitialized + } + return svc.Wait(id) +} + +// List returns all processes from the default service. +// +// Example: +// +// procs := process.List() +func List() []*Process { + svc := Default() + if svc == nil { + return nil + } + return svc.List() +} + +// Kill terminates a process by ID using the default service. +// +// Example: +// +// _ = process.Kill("proc-1") +func Kill(id string) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.Kill(id) +} + +// KillPID terminates a process by operating-system PID using the default service. +// +// Example: +// +// _ = process.KillPID(1234) +func KillPID(pid int) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.KillPID(pid) +} + +// Signal sends a signal to a process by ID using the default service. +// +// Example: +// +// _ = process.Signal("proc-1", syscall.SIGTERM) +func Signal(id string, sig os.Signal) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.Signal(id, sig) +} + +// SignalPID sends a signal to a process by operating-system PID using the default service. +// +// Example: +// +// _ = process.SignalPID(1234, syscall.SIGTERM) +func SignalPID(pid int, sig os.Signal) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.SignalPID(pid, sig) +} + +// StartWithOptions spawns a process with full configuration using the default service. +// +// Example: +// +// proc, err := process.StartWithOptions(ctx, process.RunOptions{Command: "pwd", Dir: "/tmp"}) +func StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { + svc := Default() + if svc == nil { + return nil, ErrServiceNotInitialized + } + return svc.StartWithOptions(ctx, opts) +} + +// RunWithOptions executes a command with options and waits using the default service. +// +// Example: +// +// out, err := process.RunWithOptions(ctx, process.RunOptions{Command: "echo", Args: []string{"hello"}}) +func RunWithOptions(ctx context.Context, opts RunOptions) (string, error) { + svc := Default() + if svc == nil { + return "", ErrServiceNotInitialized + } + return svc.RunWithOptions(ctx, opts) +} + +// Running returns all currently running processes from the default service. +// +// Example: +// +// running := process.Running() +func Running() []*Process { + svc := Default() + if svc == nil { + return nil + } + return svc.Running() +} + +// Remove removes a completed process from the default service. +// +// Example: +// +// _ = process.Remove("proc-1") +func Remove(id string) error { + svc := Default() + if svc == nil { + return ErrServiceNotInitialized + } + return svc.Remove(id) +} + +// Clear removes all completed processes from the default service. +// +// Example: +// +// process.Clear() +func Clear() { + svc := Default() + if svc == nil { + return + } + svc.Clear() +} + +// Errors +var ( + // ErrServiceNotInitialized is returned when the service is not initialised. + ErrServiceNotInitialized = coreerr.E("", "process: service not initialized; call process.Init(core) first", nil) + // ErrSetDefaultNil is returned when SetDefault is called with nil. + ErrSetDefaultNil = coreerr.E("", "process: SetDefault called with nil service", nil) +) diff --git a/process_test.go b/process_test.go index 5f8a9b5..596bc31 100644 --- a/process_test.go +++ b/process_test.go @@ -3,6 +3,7 @@ package process import ( "context" "os" + "syscall" "testing" "time" @@ -10,10 +11,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestProcess_Info_Good(t *testing.T) { +var _ *Process = (*ManagedProcess)(nil) + +func TestProcess_Info(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "hello") + proc, err := svc.Start(context.Background(), "echo", "hello") + require.NoError(t, err) <-proc.Done() @@ -21,13 +25,14 @@ func TestProcess_Info_Good(t *testing.T) { assert.Equal(t, proc.ID, info.ID) assert.Equal(t, "echo", info.Command) assert.Equal(t, []string{"hello"}, info.Args) + assert.False(t, info.Running) assert.Equal(t, StatusExited, info.Status) assert.Equal(t, 0, info.ExitCode) assert.Greater(t, info.Duration, time.Duration(0)) } -func TestProcess_Info_Pending_Good(t *testing.T) { - proc := &ManagedProcess{ +func TestProcess_Info_Pending(t *testing.T) { + proc := &Process{ ID: "pending", Status: StatusPending, done: make(chan struct{}), @@ -38,163 +43,307 @@ func TestProcess_Info_Pending_Good(t *testing.T) { assert.False(t, info.Running) } -func TestProcess_Output_Good(t *testing.T) { +func TestProcess_Info_RunningDuration(t *testing.T) { + svc, _ := newTestService(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + proc, err := svc.Start(ctx, "sleep", "10") + require.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + info := proc.Info() + assert.True(t, info.Running) + assert.Equal(t, StatusRunning, info.Status) + assert.Greater(t, info.Duration, time.Duration(0)) + + cancel() + <-proc.Done() +} + +func TestProcess_InfoSnapshot(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "echo", "snapshot") + require.NoError(t, err) + + <-proc.Done() + + info := proc.Info() + require.NotEmpty(t, info.Args) + + info.Args[0] = "mutated" + + assert.Equal(t, "snapshot", proc.Args[0]) + assert.Equal(t, "mutated", info.Args[0]) +} + +func TestProcess_Output(t *testing.T) { t.Run("captures stdout", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "hello world") + + proc, err := svc.Start(context.Background(), "echo", "hello world") + require.NoError(t, err) + <-proc.Done() - assert.Contains(t, proc.Output(), "hello world") + + output := proc.Output() + assert.Contains(t, output, "hello world") }) t.Run("OutputBytes returns copy", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "test") + + proc, err := svc.Start(context.Background(), "echo", "test") + require.NoError(t, err) + <-proc.Done() + bytes := proc.OutputBytes() assert.NotNil(t, bytes) assert.Contains(t, string(bytes), "test") }) } -func TestProcess_IsRunning_Good(t *testing.T) { +func TestProcess_IsRunning(t *testing.T) { t.Run("true while running", func(t *testing.T) { svc, _ := newTestService(t) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc := startProc(t, svc, ctx, "sleep", "10") + proc, err := svc.Start(ctx, "sleep", "10") + require.NoError(t, err) + assert.True(t, proc.IsRunning()) + assert.True(t, proc.Info().Running) cancel() <-proc.Done() + assert.False(t, proc.IsRunning()) + assert.False(t, proc.Info().Running) }) t.Run("false after completion", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "done") + + proc, err := svc.Start(context.Background(), "echo", "done") + require.NoError(t, err) + <-proc.Done() + assert.False(t, proc.IsRunning()) }) } -func TestProcess_Wait_Good(t *testing.T) { +func TestProcess_Wait(t *testing.T) { t.Run("returns nil on success", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "ok") - err := proc.Wait() + + proc, err := svc.Start(context.Background(), "echo", "ok") + require.NoError(t, err) + + err = proc.Wait() assert.NoError(t, err) }) t.Run("returns error on failure", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 1") - err := proc.Wait() + + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 1") + require.NoError(t, err) + + err = proc.Wait() assert.Error(t, err) }) } -func TestProcess_Done_Good(t *testing.T) { +func TestProcess_Done(t *testing.T) { t.Run("channel closes on completion", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "test") + + proc, err := svc.Start(context.Background(), "echo", "test") + require.NoError(t, err) select { case <-proc.Done(): + // Success - channel closed case <-time.After(5 * time.Second): t.Fatal("Done channel should have closed") } }) } -func TestProcess_Kill_Good(t *testing.T) { +func TestProcess_Kill(t *testing.T) { t.Run("terminates running process", func(t *testing.T) { svc, _ := newTestService(t) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc := startProc(t, svc, ctx, "sleep", "60") + proc, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + assert.True(t, proc.IsRunning()) - err := proc.Kill() + err = proc.Kill() assert.NoError(t, err) select { case <-proc.Done(): + // Good - process terminated case <-time.After(2 * time.Second): t.Fatal("process should have been killed") } + assert.Equal(t, StatusKilled, proc.Status) - assert.Equal(t, -1, proc.ExitCode) }) t.Run("noop on completed process", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "done") + + proc, err := svc.Start(context.Background(), "echo", "done") + require.NoError(t, err) + <-proc.Done() - err := proc.Kill() + + err = proc.Kill() assert.NoError(t, err) }) } -func TestProcess_SendInput_Good(t *testing.T) { +func TestProcess_SendInput(t *testing.T) { t.Run("writes to stdin", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "cat") - err := proc.SendInput("hello\n") + // Use cat to echo back stdin + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + err = proc.SendInput("hello\n") assert.NoError(t, err) + err = proc.CloseStdin() assert.NoError(t, err) + <-proc.Done() + assert.Contains(t, proc.Output(), "hello") }) t.Run("error on completed process", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "done") + + proc, err := svc.Start(context.Background(), "echo", "done") + require.NoError(t, err) + <-proc.Done() - err := proc.SendInput("test") + + err = proc.SendInput("test") assert.ErrorIs(t, err, ErrProcessNotRunning) }) } -func TestProcess_Signal_Good(t *testing.T) { +func TestProcess_Signal(t *testing.T) { t.Run("sends signal to running process", func(t *testing.T) { svc, _ := newTestService(t) + ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc := startProc(t, svc, ctx, "sleep", "60") - err := proc.Signal(os.Interrupt) + proc, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + + err = proc.Signal(os.Interrupt) assert.NoError(t, err) select { case <-proc.Done(): + // Process terminated by signal case <-time.After(2 * time.Second): t.Fatal("process should have been terminated by signal") } + assert.Equal(t, StatusKilled, proc.Status) }) t.Run("error on completed process", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "done") + + proc, err := svc.Start(context.Background(), "echo", "done") + require.NoError(t, err) <-proc.Done() - err := proc.Signal(os.Interrupt) + + err = proc.Signal(os.Interrupt) assert.ErrorIs(t, err, ErrProcessNotRunning) }) -} -func TestProcess_CloseStdin_Good(t *testing.T) { - t.Run("closes stdin pipe", func(t *testing.T) { + t.Run("signals process group when kill group is enabled", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "cat") - err := proc.CloseStdin() + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sh", + Args: []string{"-c", "trap '' INT; sh -c 'trap - INT; sleep 60' & wait"}, + Detach: true, + KillGroup: true, + }) + require.NoError(t, err) + + err = proc.Signal(os.Interrupt) assert.NoError(t, err) select { case <-proc.Done(): + // Good - the whole process group responded to the signal. + case <-time.After(5 * time.Second): + t.Fatal("process group should have been terminated by signal") + } + }) + + t.Run("signal zero only probes process group liveness", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sh", + Args: []string{"-c", "sleep 60 & wait"}, + Detach: true, + KillGroup: true, + }) + require.NoError(t, err) + + err = proc.Signal(syscall.Signal(0)) + assert.NoError(t, err) + + time.Sleep(300 * time.Millisecond) + assert.True(t, proc.IsRunning()) + + err = proc.Kill() + assert.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(5 * time.Second): + t.Fatal("process group should have been killed for cleanup") + } + }) +} + +func TestProcess_CloseStdin(t *testing.T) { + t.Run("closes stdin pipe", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + err = proc.CloseStdin() + assert.NoError(t, err) + + // Process should exit now that stdin is closed + select { + case <-proc.Done(): + // Good case <-time.After(2 * time.Second): t.Fatal("cat should exit when stdin is closed") } @@ -202,132 +351,156 @@ func TestProcess_CloseStdin_Good(t *testing.T) { t.Run("double close is safe", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "cat") - err := proc.CloseStdin() + + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + // First close + err = proc.CloseStdin() assert.NoError(t, err) + <-proc.Done() + + // Second close should be safe (stdin already nil) err = proc.CloseStdin() assert.NoError(t, err) }) } -func TestProcess_Timeout_Good(t *testing.T) { +func TestProcess_Timeout(t *testing.T) { t.Run("kills process after timeout", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.StartWithOptions(context.Background(), RunOptions{ + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sleep", Args: []string{"60"}, Timeout: 200 * time.Millisecond, }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) select { case <-proc.Done(): + // Good — process was killed by timeout case <-time.After(5 * time.Second): t.Fatal("process should have been killed by timeout") } + assert.False(t, proc.IsRunning()) assert.Equal(t, StatusKilled, proc.Status) }) t.Run("no timeout when zero", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.StartWithOptions(context.Background(), RunOptions{ + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "echo", Args: []string{"fast"}, Timeout: 0, }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) + <-proc.Done() assert.Equal(t, 0, proc.ExitCode) }) } -func TestProcess_Shutdown_Good(t *testing.T) { +func TestProcess_Shutdown(t *testing.T) { t.Run("graceful with grace period", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.StartWithOptions(context.Background(), RunOptions{ + + // Use a process that traps SIGTERM + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sleep", Args: []string{"60"}, GracePeriod: 100 * time.Millisecond, }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) assert.True(t, proc.IsRunning()) - err := proc.Shutdown() + + err = proc.Shutdown() assert.NoError(t, err) select { case <-proc.Done(): + // Good case <-time.After(5 * time.Second): t.Fatal("shutdown should have completed") } + + assert.Equal(t, StatusKilled, proc.Status) }) t.Run("immediate kill without grace period", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.StartWithOptions(context.Background(), RunOptions{ + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sleep", Args: []string{"60"}, }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) - err := proc.Shutdown() + err = proc.Shutdown() assert.NoError(t, err) select { case <-proc.Done(): + // Good case <-time.After(2 * time.Second): t.Fatal("kill should be immediate") } }) } -func TestProcess_KillGroup_Good(t *testing.T) { +func TestProcess_KillGroup(t *testing.T) { t.Run("kills child processes", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.StartWithOptions(context.Background(), RunOptions{ + + // Spawn a parent that spawns a child — KillGroup should kill both + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sh", Args: []string{"-c", "sleep 60 & wait"}, Detach: true, KillGroup: true, }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) + // Give child time to spawn time.Sleep(100 * time.Millisecond) - err := proc.Kill() + + err = proc.Kill() assert.NoError(t, err) select { case <-proc.Done(): + // Good — whole group killed case <-time.After(5 * time.Second): t.Fatal("process group should have been killed") } + + assert.Equal(t, StatusKilled, proc.Status) }) } -func TestProcess_TimeoutWithGrace_Good(t *testing.T) { +func TestProcess_TimeoutWithGrace(t *testing.T) { t.Run("timeout triggers graceful shutdown", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.StartWithOptions(context.Background(), RunOptions{ + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sleep", Args: []string{"60"}, Timeout: 200 * time.Millisecond, GracePeriod: 100 * time.Millisecond, }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) select { case <-proc.Done(): + // Good — timeout + grace triggered case <-time.After(5 * time.Second): t.Fatal("process should have been killed by timeout") } + assert.Equal(t, StatusKilled, proc.Status) }) } diff --git a/program.go b/program.go index 39ef2e3..f7b0822 100644 --- a/program.go +++ b/program.go @@ -3,24 +3,36 @@ package process import ( "bytes" "context" - "path/filepath" - "strconv" + "os/exec" + "strings" + "unicode" - "dappco.re/go/core" + core "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) // ErrProgramNotFound is returned when Find cannot locate the binary on PATH. -// Callers may use core.Is to detect this condition. -var ErrProgramNotFound = core.E("", "program: binary not found in PATH", nil) +// Callers may use errors.Is to detect this condition. +var ErrProgramNotFound = coreerr.E("", "program: binary not found in PATH", nil) + +// ErrProgramContextRequired is returned when Run or RunDir is called without a context. +var ErrProgramContextRequired = coreerr.E("", "program: command context is required", nil) + +// ErrProgramNameRequired is returned when Run or RunDir is called without a program name. +var ErrProgramNameRequired = coreerr.E("", "program: program name is empty", nil) // Program represents a named executable located on the system PATH. -// Create one with a Name, call Find to resolve its path, then Run or RunDir. // -// p := &process.Program{Name: "go"} +// Example: +// +// git := &process.Program{Name: "git"} +// if err := git.Find(); err != nil { return err } +// out, err := git.Run(ctx, "status") type Program struct { // Name is the binary name (e.g. "go", "node", "git"). Name string // Path is the absolute path resolved by Find. + // Example: "/usr/bin/git" // If empty, Run and RunDir fall back to Name for OS PATH resolution. Path string } @@ -28,20 +40,20 @@ type Program struct { // Find resolves the program's absolute path using exec.LookPath. // Returns ErrProgramNotFound (wrapped) if the binary is not on PATH. // -// err := p.Find() +// Example: +// +// if err := p.Find(); err != nil { return err } func (p *Program) Find() error { - if p.Name == "" { - return core.E("program.find", "program name is empty", nil) + target := p.Path + if target == "" { + target = p.Name } - path, err := execLookPath(p.Name) + if target == "" { + return coreerr.E("Program.Find", "program name is empty", nil) + } + path, err := exec.LookPath(target) if err != nil { - return core.E("program.find", core.Concat(strconv.Quote(p.Name), ": not found in PATH"), ErrProgramNotFound) - } - if !filepath.IsAbs(path) { - path, err = filepath.Abs(path) - if err != nil { - return core.E("program.find", core.Concat(strconv.Quote(p.Name), ": failed to resolve absolute path"), err) - } + return coreerr.E("Program.Find", core.Sprintf("%q: not found in PATH", target), ErrProgramNotFound) } p.Path = path return nil @@ -50,7 +62,9 @@ func (p *Program) Find() error { // Run executes the program with args in the current working directory. // Returns trimmed combined stdout+stderr output and any error. // -// out, err := p.Run(ctx, "version") +// Example: +// +// out, err := p.Run(ctx, "hello") func (p *Program) Run(ctx context.Context, args ...string) (string, error) { return p.RunDir(ctx, "", args...) } @@ -59,18 +73,25 @@ func (p *Program) Run(ctx context.Context, args ...string) (string, error) { // Returns trimmed combined stdout+stderr output and any error. // If dir is empty, the process inherits the caller's working directory. // -// out, err := p.RunDir(ctx, "/workspace", "test", "./...") +// Example: +// +// out, err := p.RunDir(ctx, "/tmp", "pwd") func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) { + if ctx == nil { + return "", coreerr.E("Program.RunDir", "program: command context is required", ErrProgramContextRequired) + } + binary := p.Path if binary == "" { binary = p.Name } - if ctx == nil { - ctx = context.Background() + + if binary == "" { + return "", coreerr.E("Program.RunDir", "program name is empty", ErrProgramNameRequired) } var out bytes.Buffer - cmd := execCommandContext(ctx, binary, args...) + cmd := exec.CommandContext(ctx, binary, args...) cmd.Stdout = &out cmd.Stderr = &out if dir != "" { @@ -78,7 +99,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin } if err := cmd.Run(); err != nil { - return string(bytes.TrimSpace(out.Bytes())), core.E("program.run", core.Concat(strconv.Quote(p.Name), ": command failed"), err) + return strings.TrimRightFunc(out.String(), unicode.IsSpace), coreerr.E("Program.RunDir", core.Sprintf("%q: command failed", p.Name), err) } - return string(bytes.TrimSpace(out.Bytes())), nil + return strings.TrimRightFunc(out.String(), unicode.IsSpace), nil } diff --git a/program_test.go b/program_test.go index fcadef7..7d38a76 100644 --- a/program_test.go +++ b/program_test.go @@ -2,12 +2,11 @@ package process_test import ( "context" - "os" + "os/exec" "path/filepath" "testing" "time" - "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,26 +20,47 @@ func testCtx(t *testing.T) context.Context { return ctx } -func TestProgram_Find_Good(t *testing.T) { +func TestProgram_Find_KnownBinary(t *testing.T) { p := &process.Program{Name: "echo"} require.NoError(t, p.Find()) assert.NotEmpty(t, p.Path) - assert.True(t, filepath.IsAbs(p.Path)) } -func TestProgram_FindUnknown_Bad(t *testing.T) { +func TestProgram_Find_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_FindEmpty_Bad(t *testing.T) { +func TestProgram_Find_UsesExistingPath(t *testing.T) { + path, err := exec.LookPath("echo") + require.NoError(t, err) + + p := &process.Program{Path: path} + require.NoError(t, p.Find()) + assert.Equal(t, path, p.Path) +} + +func TestProgram_Find_PrefersExistingPathOverName(t *testing.T) { + path, err := exec.LookPath("echo") + require.NoError(t, err) + + p := &process.Program{ + Name: "no-such-binary-xyzzy-42", + Path: path, + } + + require.NoError(t, p.Find()) + assert.Equal(t, path, p.Path) +} + +func TestProgram_Find_EmptyName(t *testing.T) { p := &process.Program{} require.Error(t, p.Find()) } -func TestProgram_Run_Good(t *testing.T) { +func TestProgram_Run_ReturnsOutput(t *testing.T) { p := &process.Program{Name: "echo"} require.NoError(t, p.Find()) @@ -49,7 +69,16 @@ func TestProgram_Run_Good(t *testing.T) { assert.Equal(t, "hello", out) } -func TestProgram_RunFallback_Good(t *testing.T) { +func TestProgram_Run_PreservesLeadingWhitespace(t *testing.T) { + p := &process.Program{Name: "sh"} + require.NoError(t, p.Find()) + + out, err := p.Run(testCtx(t), "-c", "printf ' hello \n'") + require.NoError(t, err) + assert.Equal(t, " hello", out) +} + +func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) { // Path is empty; RunDir should fall back to Name for OS PATH resolution. p := &process.Program{Name: "echo"} @@ -58,15 +87,7 @@ func TestProgram_RunFallback_Good(t *testing.T) { assert.Equal(t, "fallback", out) } -func TestProgram_RunNilContext_Good(t *testing.T) { - p := &process.Program{Name: "echo"} - - out, err := p.Run(nil, "nil-context") - require.NoError(t, err) - assert.Equal(t, "nil-context", out) -} - -func TestProgram_RunDir_Good(t *testing.T) { +func TestProgram_RunDir_UsesDirectory(t *testing.T) { p := &process.Program{Name: "pwd"} require.NoError(t, p.Find()) @@ -74,17 +95,34 @@ func TestProgram_RunDir_Good(t *testing.T) { out, err := p.RunDir(testCtx(t), dir) require.NoError(t, err) - dirInfo, err := os.Stat(dir) + // Resolve symlinks on both sides for portability (macOS uses /private/ prefix). + canonicalDir, err := filepath.EvalSymlinks(dir) require.NoError(t, err) - outInfo, err := os.Stat(core.Trim(out)) + canonicalOut, err := filepath.EvalSymlinks(out) require.NoError(t, err) - assert.True(t, os.SameFile(dirInfo, outInfo)) + assert.Equal(t, canonicalDir, canonicalOut) } -func TestProgram_RunFailure_Bad(t *testing.T) { +func TestProgram_Run_FailingCommand(t *testing.T) { p := &process.Program{Name: "false"} require.NoError(t, p.Find()) _, err := p.Run(testCtx(t)) require.Error(t, err) } + +func TestProgram_Run_NilContextRejected(t *testing.T) { + p := &process.Program{Name: "echo"} + + _, err := p.Run(nil, "test") + require.Error(t, err) + assert.ErrorIs(t, err, process.ErrProgramContextRequired) +} + +func TestProgram_RunDir_EmptyNameRejected(t *testing.T) { + p := &process.Program{} + + _, err := p.RunDir(testCtx(t), "", "test") + require.Error(t, err) + assert.ErrorIs(t, err, process.ErrProgramNameRequired) +} diff --git a/registry.go b/registry.go index e5f96e0..0c091c4 100644 --- a/registry.go +++ b/registry.go @@ -1,18 +1,23 @@ package process import ( - "path" - "strconv" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" "syscall" "time" - "dappco.re/go/core" coreio "dappco.re/go/core/io" + coreerr "dappco.re/go/core/log" ) // DaemonEntry records a running daemon in the registry. // -// entry := process.DaemonEntry{Code: "myapp", Daemon: "serve", PID: 1234} +// Example: +// +// entry := process.DaemonEntry{Code: "app", Daemon: "serve", PID: os.Getpid()} type DaemonEntry struct { Code string `json:"code"` Daemon string `json:"daemon"` @@ -24,63 +29,80 @@ type DaemonEntry struct { } // Registry tracks running daemons via JSON files in a directory. -// -// reg := process.NewRegistry("/tmp/process-daemons") type Registry struct { dir string } // NewRegistry creates a registry backed by the given directory. // -// reg := process.NewRegistry("/tmp/process-daemons") +// Example: +// +// reg := process.NewRegistry("/tmp/daemons") func NewRegistry(dir string) *Registry { return &Registry{dir: dir} } // DefaultRegistry returns a registry using ~/.core/daemons/. // +// Example: +// // reg := process.DefaultRegistry() func DefaultRegistry() *Registry { - home, err := userHomeDir() + home, err := os.UserHomeDir() if err != nil { - home = tempDir() + home = os.TempDir() } - return NewRegistry(path.Join(home, ".core", "daemons")) + return NewRegistry(filepath.Join(home, ".core", "daemons")) } // Register writes a daemon entry to the registry directory. // If Started is zero, it is set to the current time. // The directory is created if it does not exist. +// +// Example: +// +// _ = reg.Register(entry) func (r *Registry) Register(entry DaemonEntry) error { if entry.Started.IsZero() { entry.Started = time.Now() } if err := coreio.Local.EnsureDir(r.dir); err != nil { - return core.E("registry.register", "failed to create registry directory", err) + return coreerr.E("Registry.Register", "failed to create registry directory", err) } - data, err := marshalDaemonEntry(entry) + data, err := json.MarshalIndent(entry, "", " ") if err != nil { - return core.E("registry.register", "failed to marshal entry", err) + return coreerr.E("Registry.Register", "failed to marshal entry", err) } - if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), data); err != nil { - return core.E("registry.register", "failed to write entry file", err) + if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), string(data)); err != nil { + return coreerr.E("Registry.Register", "failed to write entry file", err) } return nil } // Unregister removes a daemon entry from the registry. +// +// Example: +// +// _ = reg.Unregister("app", "serve") func (r *Registry) Unregister(code, daemon string) error { if err := coreio.Local.Delete(r.entryPath(code, daemon)); err != nil { - return core.E("registry.unregister", "failed to delete entry file", err) + if os.IsNotExist(err) { + return nil + } + return coreerr.E("Registry.Unregister", "failed to delete entry file", err) } return nil } // Get reads a single daemon entry and checks whether its process is alive. // If the process is dead, the stale file is removed and (nil, false) is returned. +// +// Example: +// +// entry, ok := reg.Get("app", "serve") func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) { path := r.entryPath(code, daemon) @@ -89,8 +111,8 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) { return nil, false } - entry, err := unmarshalDaemonEntry(data) - if err != nil { + var entry DaemonEntry + if err := json.Unmarshal([]byte(data), &entry); err != nil { _ = coreio.Local.Delete(path) return nil, false } @@ -104,29 +126,25 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) { } // List returns all alive daemon entries, pruning any with dead PIDs. +// +// Example: +// +// entries, err := reg.List() func (r *Registry) List() ([]DaemonEntry, error) { - if !coreio.Local.Exists(r.dir) { - return nil, nil - } - - entries, err := coreio.Local.List(r.dir) + matches, err := filepath.Glob(filepath.Join(r.dir, "*.json")) if err != nil { - return nil, core.E("registry.list", "failed to list registry directory", err) + return nil, err } var alive []DaemonEntry - for _, entryFile := range entries { - if entryFile.IsDir() || !core.HasSuffix(entryFile.Name(), ".json") { - continue - } - path := path.Join(r.dir, entryFile.Name()) + for _, path := range matches { data, err := coreio.Local.Read(path) if err != nil { continue } - entry, err := unmarshalDaemonEntry(data) - if err != nil { + var entry DaemonEntry + if err := json.Unmarshal([]byte(data), &entry); err != nil { _ = coreio.Local.Delete(path) continue } @@ -139,13 +157,23 @@ func (r *Registry) List() ([]DaemonEntry, error) { alive = append(alive, entry) } + sort.Slice(alive, func(i, j int) bool { + if alive[i].Started.Equal(alive[j].Started) { + if alive[i].Code == alive[j].Code { + return alive[i].Daemon < alive[j].Daemon + } + return alive[i].Code < alive[j].Code + } + return alive[i].Started.Before(alive[j].Started) + }) + return alive, nil } // entryPath returns the filesystem path for a daemon entry. func (r *Registry) entryPath(code, daemon string) string { - name := sanitizeRegistryComponent(code) + "-" + sanitizeRegistryComponent(daemon) + ".json" - return path.Join(r.dir, name) + name := strings.ReplaceAll(code, "/", "-") + "-" + strings.ReplaceAll(daemon, "/", "-") + ".json" + return filepath.Join(r.dir, name) } // isAlive checks whether a process with the given PID is running. @@ -153,263 +181,9 @@ func isAlive(pid int) bool { if pid <= 0 { return false } - proc, err := processHandle(pid) + proc, err := os.FindProcess(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) -} - -func marshalDaemonEntry(entry DaemonEntry) (string, error) { - fields := []struct { - key string - value string - }{ - {key: "code", value: quoteJSONString(entry.Code)}, - {key: "daemon", value: quoteJSONString(entry.Daemon)}, - {key: "pid", value: strconv.Itoa(entry.PID)}, - } - - if entry.Health != "" { - fields = append(fields, struct { - key string - value string - }{key: "health", value: quoteJSONString(entry.Health)}) - } - if entry.Project != "" { - fields = append(fields, struct { - key string - value string - }{key: "project", value: quoteJSONString(entry.Project)}) - } - if entry.Binary != "" { - fields = append(fields, struct { - key string - value string - }{key: "binary", value: quoteJSONString(entry.Binary)}) - } - - fields = append(fields, struct { - key string - value string - }{ - key: "started", - value: quoteJSONString(entry.Started.Format(time.RFC3339Nano)), - }) - - builder := core.NewBuilder() - builder.WriteString("{\n") - for i, field := range fields { - builder.WriteString(core.Concat(" ", quoteJSONString(field.key), ": ", field.value)) - if i < len(fields)-1 { - builder.WriteString(",") - } - builder.WriteString("\n") - } - builder.WriteString("}") - return builder.String(), nil -} - -func unmarshalDaemonEntry(data string) (DaemonEntry, error) { - values, err := parseJSONObject(data) - if err != nil { - return DaemonEntry{}, err - } - - entry := DaemonEntry{ - Code: values["code"], - Daemon: values["daemon"], - Health: values["health"], - Project: values["project"], - Binary: values["binary"], - } - - pidValue, ok := values["pid"] - if !ok { - return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing pid", nil) - } - entry.PID, err = strconv.Atoi(pidValue) - if err != nil { - return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid pid", err) - } - - startedValue, ok := values["started"] - if !ok { - return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing started", nil) - } - entry.Started, err = time.Parse(time.RFC3339Nano, startedValue) - if err != nil { - return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid started timestamp", err) - } - - return entry, nil -} - -func parseJSONObject(data string) (map[string]string, error) { - trimmed := core.Trim(data) - if trimmed == "" { - return nil, core.E("Registry.parseJSONObject", "empty JSON object", nil) - } - if trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' { - return nil, core.E("Registry.parseJSONObject", "invalid JSON object", nil) - } - - values := make(map[string]string) - index := skipJSONSpace(trimmed, 1) - for index < len(trimmed) { - if trimmed[index] == '}' { - return values, nil - } - - key, next, err := parseJSONString(trimmed, index) - if err != nil { - return nil, err - } - - index = skipJSONSpace(trimmed, next) - if index >= len(trimmed) || trimmed[index] != ':' { - return nil, core.E("Registry.parseJSONObject", "missing key separator", nil) - } - - index = skipJSONSpace(trimmed, index+1) - if index >= len(trimmed) { - return nil, core.E("Registry.parseJSONObject", "missing value", nil) - } - - var value string - if trimmed[index] == '"' { - value, index, err = parseJSONString(trimmed, index) - if err != nil { - return nil, err - } - } else { - start := index - for index < len(trimmed) && trimmed[index] != ',' && trimmed[index] != '}' { - index++ - } - value = core.Trim(trimmed[start:index]) - } - values[key] = value - - index = skipJSONSpace(trimmed, index) - if index >= len(trimmed) { - break - } - if trimmed[index] == ',' { - index = skipJSONSpace(trimmed, index+1) - continue - } - if trimmed[index] == '}' { - return values, nil - } - return nil, core.E("Registry.parseJSONObject", "invalid object separator", nil) - } - - return nil, core.E("Registry.parseJSONObject", "unterminated JSON object", nil) -} - -func parseJSONString(data string, start int) (string, int, error) { - if start >= len(data) || data[start] != '"' { - return "", 0, core.E("Registry.parseJSONString", "expected quoted string", nil) - } - - builder := core.NewBuilder() - for index := start + 1; index < len(data); index++ { - ch := data[index] - if ch == '"' { - return builder.String(), index + 1, nil - } - if ch != '\\' { - builder.WriteByte(ch) - continue - } - - index++ - if index >= len(data) { - return "", 0, core.E("Registry.parseJSONString", "unterminated escape sequence", nil) - } - - switch data[index] { - case '"', '\\', '/': - builder.WriteByte(data[index]) - case 'b': - builder.WriteByte('\b') - case 'f': - builder.WriteByte('\f') - case 'n': - builder.WriteByte('\n') - case 'r': - builder.WriteByte('\r') - case 't': - builder.WriteByte('\t') - case 'u': - if index+4 >= len(data) { - return "", 0, core.E("Registry.parseJSONString", "short unicode escape", nil) - } - r, err := strconv.ParseInt(data[index+1:index+5], 16, 32) - if err != nil { - return "", 0, core.E("Registry.parseJSONString", "invalid unicode escape", err) - } - builder.WriteRune(rune(r)) - index += 4 - default: - return "", 0, core.E("Registry.parseJSONString", "invalid escape sequence", nil) - } - } - - return "", 0, core.E("Registry.parseJSONString", "unterminated string", nil) -} - -func skipJSONSpace(data string, index int) int { - for index < len(data) { - switch data[index] { - case ' ', '\n', '\r', '\t': - index++ - default: - return index - } - } - return index -} - -func quoteJSONString(value string) string { - builder := core.NewBuilder() - builder.WriteByte('"') - for i := 0; i < len(value); i++ { - switch value[i] { - case '\\', '"': - builder.WriteByte('\\') - builder.WriteByte(value[i]) - case '\b': - builder.WriteString(`\b`) - case '\f': - builder.WriteString(`\f`) - case '\n': - builder.WriteString(`\n`) - case '\r': - builder.WriteString(`\r`) - case '\t': - builder.WriteString(`\t`) - default: - if value[i] < 0x20 { - builder.WriteString(core.Sprintf("\\u%04x", value[i])) - continue - } - builder.WriteByte(value[i]) - } - } - builder.WriteByte('"') - return builder.String() -} diff --git a/registry_test.go b/registry_test.go index bf0883e..a3f1c41 100644 --- a/registry_test.go +++ b/registry_test.go @@ -2,15 +2,15 @@ package process import ( "os" + "path/filepath" "testing" "time" - "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestRegistry_Register_Good(t *testing.T) { +func TestRegistry_RegisterAndGet(t *testing.T) { dir := t.TempDir() reg := NewRegistry(dir) @@ -39,7 +39,7 @@ func TestRegistry_Register_Good(t *testing.T) { assert.Equal(t, started, got.Started) } -func TestRegistry_Unregister_Good(t *testing.T) { +func TestRegistry_Unregister(t *testing.T) { dir := t.TempDir() reg := NewRegistry(dir) @@ -53,7 +53,7 @@ func TestRegistry_Unregister_Good(t *testing.T) { require.NoError(t, err) // File should exist - path := core.JoinPath(dir, "myapp-server.json") + path := filepath.Join(dir, "myapp-server.json") _, err = os.Stat(path) require.NoError(t, err) @@ -65,7 +65,15 @@ func TestRegistry_Unregister_Good(t *testing.T) { assert.True(t, os.IsNotExist(err)) } -func TestRegistry_List_Good(t *testing.T) { +func TestRegistry_UnregisterMissingIsNoop(t *testing.T) { + dir := t.TempDir() + reg := NewRegistry(dir) + + err := reg.Unregister("missing", "entry") + require.NoError(t, err) +} + +func TestRegistry_List(t *testing.T) { dir := t.TempDir() reg := NewRegistry(dir) @@ -76,10 +84,12 @@ func TestRegistry_List_Good(t *testing.T) { entries, err := reg.List() require.NoError(t, err) - assert.Len(t, entries, 2) + require.Len(t, entries, 2) + assert.Equal(t, "app1", entries[0].Code) + assert.Equal(t, "app2", entries[1].Code) } -func TestRegistry_PruneStale_Good(t *testing.T) { +func TestRegistry_List_PrunesStale(t *testing.T) { dir := t.TempDir() reg := NewRegistry(dir) @@ -87,7 +97,7 @@ func TestRegistry_PruneStale_Good(t *testing.T) { require.NoError(t, err) // File should exist before listing - path := core.JoinPath(dir, "dead-proc.json") + path := filepath.Join(dir, "dead-proc.json") _, err = os.Stat(path) require.NoError(t, err) @@ -100,7 +110,7 @@ func TestRegistry_PruneStale_Good(t *testing.T) { assert.True(t, os.IsNotExist(err)) } -func TestRegistry_GetMissing_Bad(t *testing.T) { +func TestRegistry_Get_NotFound(t *testing.T) { dir := t.TempDir() reg := NewRegistry(dir) @@ -109,8 +119,8 @@ func TestRegistry_GetMissing_Bad(t *testing.T) { assert.False(t, ok) } -func TestRegistry_CreateDirectory_Good(t *testing.T) { - dir := core.JoinPath(t.TempDir(), "nested", "deep", "daemons") +func TestRegistry_CreatesDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons") reg := NewRegistry(dir) err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()}) @@ -121,7 +131,7 @@ func TestRegistry_CreateDirectory_Good(t *testing.T) { assert.True(t, info.IsDir()) } -func TestRegistry_Default_Good(t *testing.T) { +func TestDefaultRegistry(t *testing.T) { reg := DefaultRegistry() assert.NotNil(t, reg) } diff --git a/runner.go b/runner.go index 4fa91f8..9ff5835 100644 --- a/runner.go +++ b/runner.go @@ -5,7 +5,7 @@ import ( "sync" "time" - "dappco.re/go/core" + coreerr "dappco.re/go/core/log" ) // Runner orchestrates multiple processes with dependencies. @@ -14,14 +14,31 @@ type Runner struct { } // ErrRunnerNoService is returned when a runner was created without a service. -var ErrRunnerNoService = core.E("", "runner service is nil", nil) +var ErrRunnerNoService = coreerr.E("", "runner service is nil", nil) + +// ErrRunnerInvalidSpecName is returned when a RunSpec name is empty or duplicated. +var ErrRunnerInvalidSpecName = coreerr.E("", "runner spec names must be non-empty and unique", nil) + +// ErrRunnerInvalidDependencyName is returned when a RunSpec dependency name is empty, duplicated, or self-referential. +var ErrRunnerInvalidDependencyName = coreerr.E("", "runner dependency names must be non-empty, unique, and not self-referential", nil) + +// ErrRunnerContextRequired is returned when a runner method is called without a context. +var ErrRunnerContextRequired = coreerr.E("", "runner context is required", nil) // NewRunner creates a runner for the given service. +// +// Example: +// +// runner := process.NewRunner(svc) func NewRunner(svc *Service) *Runner { return &Runner{service: svc} } // RunSpec defines a process to run with optional dependencies. +// +// Example: +// +// spec := process.RunSpec{Name: "test", Command: "go", Args: []string{"test", "./..."}} type RunSpec struct { // Name is a friendly identifier (e.g., "lint", "test"). Name string @@ -46,11 +63,17 @@ type RunResult struct { ExitCode int Duration time.Duration Output string - Error error - Skipped bool + // Error only reports start-time or orchestration failures. A started process + // that exits non-zero uses ExitCode to report failure and leaves Error nil. + Error error + Skipped bool } // Passed returns true if the process succeeded. +// +// Example: +// +// if result.Passed() { ... } func (r RunResult) Passed() bool { return !r.Skipped && r.Error == nil && r.ExitCode == 0 } @@ -64,24 +87,38 @@ type RunAllResult struct { Skipped int } -// Success returns true if all non-skipped specs passed. +// Success returns true when no spec failed. +// +// Example: +// +// if result.Success() { ... } func (r RunAllResult) Success() bool { return r.Failed == 0 } // RunAll executes specs respecting dependencies, parallelising where possible. +// +// Example: +// +// result, err := runner.RunAll(ctx, specs) func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { if err := r.ensureService(); err != nil { return nil, err } + if err := ensureRunnerContext(ctx); err != nil { + return nil, err + } + if err := validateSpecs(specs); err != nil { + return nil, err + } start := time.Now() // Build dependency graph specMap := make(map[string]RunSpec) - indexMap := make(map[string]int) - for i, spec := range specs { + indexMap := make(map[string]int, len(specs)) + for _, spec := range specs { specMap[spec.Name] = spec - indexMap[spec.Name] = i + indexMap[spec.Name] = len(indexMap) } // Track completion @@ -97,6 +134,13 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er } for len(remaining) > 0 { + if err := ctx.Err(); err != nil { + for name := range remaining { + results[indexMap[name]] = cancelledRunResult("Runner.RunAll", remaining[name], err) + } + break + } + // Find specs ready to run (all dependencies satisfied) ready := make([]RunSpec, 0) for _, spec := range remaining { @@ -106,13 +150,14 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er } if len(ready) == 0 && len(remaining) > 0 { - // Deadlock — circular dependency or missing specs. Mark as failed, not skipped. - for name, spec := range remaining { + // Deadlock - circular dependency or missing specs. + // Keep the output aligned with the input order. + for name := range remaining { results[indexMap[name]] = RunResult{ - Name: name, - Spec: spec, - ExitCode: 1, - Error: core.E("runner.run_all", "circular dependency or missing dependency", nil), + Name: name, + Spec: remaining[name], + Skipped: true, + Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil), } } break @@ -144,7 +189,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er Name: spec.Name, Spec: spec, Skipped: true, - Error: core.E("runner.run_all", "skipped due to dependency failure", nil), + Error: coreerr.E("Runner.RunAll", "skipped due to dependency failure", nil), } } else { result = r.runSpec(ctx, spec) @@ -184,6 +229,13 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er return aggResult, nil } +func (r *Runner) ensureService() error { + if r == nil || r.service == nil { + return ErrRunnerNoService + } + return nil +} + // canRun checks if all dependencies are completed. func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool { for _, dep := range spec.After { @@ -198,17 +250,13 @@ func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool { func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult { start := time.Now() - sr := r.service.StartWithOptions(ctx, RunOptions{ + proc, err := r.service.StartWithOptions(ctx, RunOptions{ Command: spec.Command, Args: spec.Args, Dir: spec.Dir, Env: spec.Env, }) - if !sr.OK { - err, _ := sr.Value.(error) - if err == nil { - err = core.E("runner.run_spec", core.Concat("failed to start: ", spec.Name), nil) - } + if err != nil { return RunResult{ Name: spec.Name, Spec: spec, @@ -217,39 +265,60 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult { } } - proc := sr.Value.(*Process) <-proc.Done() + var runErr error + switch proc.Status { + case StatusKilled: + runErr = coreerr.E("Runner.runSpec", "process was killed", nil) + case StatusExited: + // Non-zero exits are surfaced through ExitCode; Error remains nil so + // callers can distinguish execution failure from orchestration failure. + case StatusFailed: + runErr = coreerr.E("Runner.runSpec", "process failed to start", nil) + } + return RunResult{ Name: spec.Name, Spec: spec, ExitCode: proc.ExitCode, Duration: proc.Duration, Output: proc.Output(), - Error: nil, + Error: runErr, } } // RunSequential executes specs one after another, stopping on first failure. +// +// Example: +// +// result, err := runner.RunSequential(ctx, specs) func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { if err := r.ensureService(); err != nil { return nil, err } + if err := ensureRunnerContext(ctx); err != nil { + return nil, err + } + if err := validateSpecs(specs); err != nil { + return nil, err + } start := time.Now() results := make([]RunResult, 0, len(specs)) for _, spec := range specs { + if err := ctx.Err(); err != nil { + results = append(results, cancelledRunResult("Runner.RunSequential", spec, err)) + continue + } + result := r.runSpec(ctx, spec) results = append(results, result) if !result.Passed() && !spec.AllowFailure { // Mark remaining as skipped for i := len(results); i < len(specs); i++ { - results = append(results, RunResult{ - Name: specs[i].Name, - Spec: specs[i], - Skipped: true, - }) + results = append(results, skippedRunResult("Runner.RunSequential", specs[i], nil)) } break } @@ -274,10 +343,20 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes } // RunParallel executes all specs concurrently, regardless of dependencies. +// +// Example: +// +// result, err := runner.RunParallel(ctx, specs) func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error) { if err := r.ensureService(); err != nil { return nil, err } + if err := ensureRunnerContext(ctx); err != nil { + return nil, err + } + if err := validateSpecs(specs); err != nil { + return nil, err + } start := time.Now() results := make([]RunResult, len(specs)) @@ -286,6 +365,10 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul wg.Add(1) go func(i int, spec RunSpec) { defer wg.Done() + if err := ctx.Err(); err != nil { + results[i] = cancelledRunResult("Runner.RunParallel", spec, err) + return + } results[i] = r.runSpec(ctx, spec) }(i, spec) } @@ -309,9 +392,59 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul return aggResult, nil } -func (r *Runner) ensureService() error { - if r == nil || r.service == nil { - return ErrRunnerNoService +func validateSpecs(specs []RunSpec) error { + seen := make(map[string]struct{}, len(specs)) + for _, spec := range specs { + if spec.Name == "" { + return coreerr.E("Runner.validateSpecs", "runner spec name is required", ErrRunnerInvalidSpecName) + } + if _, ok := seen[spec.Name]; ok { + return coreerr.E("Runner.validateSpecs", "runner spec name is duplicated", ErrRunnerInvalidSpecName) + } + seen[spec.Name] = struct{}{} + + deps := make(map[string]struct{}, len(spec.After)) + for _, dep := range spec.After { + if dep == "" { + return coreerr.E("Runner.validateSpecs", "runner dependency name is required", ErrRunnerInvalidDependencyName) + } + if dep == spec.Name { + return coreerr.E("Runner.validateSpecs", "runner dependency cannot reference itself", ErrRunnerInvalidDependencyName) + } + if _, ok := deps[dep]; ok { + return coreerr.E("Runner.validateSpecs", "runner dependency name is duplicated", ErrRunnerInvalidDependencyName) + } + deps[dep] = struct{}{} + } } return nil } + +func ensureRunnerContext(ctx context.Context) error { + if ctx == nil { + return coreerr.E("Runner.ensureRunnerContext", "runner context is required", ErrRunnerContextRequired) + } + return nil +} + +func skippedRunResult(op string, spec RunSpec, err error) RunResult { + result := RunResult{ + Name: spec.Name, + Spec: spec, + Skipped: true, + } + if err != nil { + result.ExitCode = 1 + result.Error = coreerr.E(op, "skipped", err) + } + return result +} + +func cancelledRunResult(op string, spec RunSpec, err error) RunResult { + result := skippedRunResult(op, spec, err) + if result.Error == nil { + result.ExitCode = 1 + result.Error = coreerr.E(op, "context cancelled", err) + } + return result +} diff --git a/runner_test.go b/runner_test.go index 0e1ea42..f27e078 100644 --- a/runner_test.go +++ b/runner_test.go @@ -13,12 +13,14 @@ func newTestRunner(t *testing.T) *Runner { t.Helper() c := framework.New() - r := Register(c) - require.True(t, r.OK) - return NewRunner(r.Value.(*Service)) + factory := NewService(Options{}) + raw, err := factory(c) + require.NoError(t, err) + + return NewRunner(raw.(*Service)) } -func TestRunner_RunSequential_Good(t *testing.T) { +func TestRunner_RunSequential(t *testing.T) { t.Run("all pass", func(t *testing.T) { runner := newTestRunner(t) @@ -49,6 +51,12 @@ func TestRunner_RunSequential_Good(t *testing.T) { assert.Equal(t, 1, result.Passed) assert.Equal(t, 1, result.Failed) assert.Equal(t, 1, result.Skipped) + require.Len(t, result.Results, 3) + assert.Equal(t, 0, result.Results[0].ExitCode) + assert.NoError(t, result.Results[0].Error) + assert.Equal(t, 1, result.Results[1].ExitCode) + assert.NoError(t, result.Results[1].Error) + assert.True(t, result.Results[2].Skipped) }) t.Run("allow failure continues", func(t *testing.T) { @@ -68,7 +76,7 @@ func TestRunner_RunSequential_Good(t *testing.T) { }) } -func TestRunner_RunParallel_Good(t *testing.T) { +func TestRunner_RunParallel(t *testing.T) { t.Run("all run concurrently", func(t *testing.T) { runner := newTestRunner(t) @@ -100,7 +108,7 @@ func TestRunner_RunParallel_Good(t *testing.T) { }) } -func TestRunner_RunAll_Good(t *testing.T) { +func TestRunner_RunAll(t *testing.T) { t.Run("respects dependencies", func(t *testing.T) { runner := newTestRunner(t) @@ -166,8 +174,8 @@ func TestRunner_RunAll_Good(t *testing.T) { }) } -func TestRunner_CircularDeps_Bad(t *testing.T) { - t.Run("circular dependency counts as failed", func(t *testing.T) { +func TestRunner_RunAll_CircularDeps(t *testing.T) { + t.Run("circular dependency is skipped with error", func(t *testing.T) { runner := newTestRunner(t) result, err := runner.RunAll(context.Background(), []RunSpec{ @@ -176,13 +184,85 @@ func TestRunner_CircularDeps_Bad(t *testing.T) { }) require.NoError(t, err) - assert.False(t, result.Success()) - assert.Equal(t, 2, result.Failed) - assert.Equal(t, 0, result.Skipped) + assert.True(t, result.Success()) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) + for _, res := range result.Results { + assert.True(t, res.Skipped) + assert.Equal(t, 0, res.ExitCode) + assert.Error(t, res.Error) + } + }) + + t.Run("missing dependency is skipped with error", func(t *testing.T) { + runner := newTestRunner(t) + + result, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "a", Command: "echo", Args: []string{"a"}, After: []string{"missing"}}, + }) + require.NoError(t, err) + + assert.True(t, result.Success()) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 1, result.Skipped) + require.Len(t, result.Results, 1) + assert.True(t, result.Results[0].Skipped) + assert.Equal(t, 0, result.Results[0].ExitCode) + assert.Error(t, result.Results[0].Error) }) } -func TestRunResult_Passed_Good(t *testing.T) { +func TestRunner_ContextCancellation(t *testing.T) { + t.Run("run sequential skips pending specs", func(t *testing.T) { + runner := newTestRunner(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := runner.RunSequential(ctx, []RunSpec{ + {Name: "first", Command: "echo", Args: []string{"1"}}, + {Name: "second", Command: "echo", Args: []string{"2"}}, + }) + require.NoError(t, err) + + assert.Equal(t, 0, result.Passed) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) + require.Len(t, result.Results, 2) + for _, res := range result.Results { + assert.True(t, res.Skipped) + assert.Equal(t, 1, res.ExitCode) + assert.Error(t, res.Error) + assert.Contains(t, res.Error.Error(), "context canceled") + } + }) + + t.Run("run all skips pending specs", func(t *testing.T) { + runner := newTestRunner(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + result, err := runner.RunAll(ctx, []RunSpec{ + {Name: "first", Command: "echo", Args: []string{"1"}}, + {Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}}, + }) + require.NoError(t, err) + + assert.Equal(t, 0, result.Passed) + assert.Equal(t, 0, result.Failed) + assert.Equal(t, 2, result.Skipped) + require.Len(t, result.Results, 2) + for _, res := range result.Results { + assert.True(t, res.Skipped) + assert.Equal(t, 1, res.ExitCode) + assert.Error(t, res.Error) + assert.Contains(t, res.Error.Error(), "context canceled") + } + }) +} + +func TestRunResult_Passed(t *testing.T) { t.Run("success", func(t *testing.T) { r := RunResult{ExitCode: 0} assert.True(t, r.Passed()) @@ -204,7 +284,7 @@ func TestRunResult_Passed_Good(t *testing.T) { }) } -func TestRunner_NilService_Bad(t *testing.T) { +func TestRunner_NilService(t *testing.T) { runner := NewRunner(nil) _, err := runner.RunAll(context.Background(), nil) @@ -219,3 +299,73 @@ func TestRunner_NilService_Bad(t *testing.T) { require.Error(t, err) assert.ErrorIs(t, err, ErrRunnerNoService) } + +func TestRunner_NilContext(t *testing.T) { + runner := newTestRunner(t) + + _, err := runner.RunAll(nil, nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerContextRequired) + + _, err = runner.RunSequential(nil, nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerContextRequired) + + _, err = runner.RunParallel(nil, nil) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerContextRequired) +} + +func TestRunner_InvalidSpecNames(t *testing.T) { + runner := newTestRunner(t) + + t.Run("rejects empty names", func(t *testing.T) { + _, err := runner.RunSequential(context.Background(), []RunSpec{ + {Name: "", Command: "echo", Args: []string{"a"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidSpecName) + }) + + t.Run("rejects empty dependency names", func(t *testing.T) { + _, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "one", Command: "echo", Args: []string{"a"}, After: []string{""}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidDependencyName) + }) + + t.Run("rejects duplicated dependency names", func(t *testing.T) { + _, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "one", Command: "echo", Args: []string{"a"}, After: []string{"two", "two"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidDependencyName) + }) + + t.Run("rejects self dependency", func(t *testing.T) { + _, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "one", Command: "echo", Args: []string{"a"}, After: []string{"one"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidDependencyName) + }) + + t.Run("rejects duplicate names", func(t *testing.T) { + _, err := runner.RunAll(context.Background(), []RunSpec{ + {Name: "same", Command: "echo", Args: []string{"a"}}, + {Name: "same", Command: "echo", Args: []string{"b"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidSpecName) + }) + + t.Run("rejects duplicate names in parallel mode", func(t *testing.T) { + _, err := runner.RunParallel(context.Background(), []RunSpec{ + {Name: "one", Command: "echo", Args: []string{"a"}}, + {Name: "one", Command: "echo", Args: []string{"b"}}, + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRunnerInvalidSpecName) + }) +} diff --git a/service.go b/service.go index 086e04c..28979f1 100644 --- a/service.go +++ b/service.go @@ -3,86 +3,128 @@ package process import ( "bufio" "context" + "errors" + "fmt" "os" "os/exec" + "sort" "sync" + "sync/atomic" "syscall" "time" "dappco.re/go/core" + coreerr "dappco.re/go/core/log" + goio "io" ) -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 // Errors var ( - ErrProcessNotFound = core.E("", "process not found", nil) - ErrProcessNotRunning = core.E("", "process is not running", nil) - ErrStdinNotAvailable = core.E("", "stdin not available", nil) + ErrProcessNotFound = coreerr.E("", "process not found", nil) + ErrProcessNotRunning = coreerr.E("", "process is not running", nil) + ErrStdinNotAvailable = coreerr.E("", "stdin not available", nil) + ErrContextRequired = coreerr.E("", "context is required", nil) ) // Service manages process execution with Core IPC integration. type Service struct { *core.ServiceRuntime[Options] - managed *core.Registry[*ManagedProcess] - bufSize int + processes map[string]*Process + mu sync.RWMutex + bufSize int + idCounter atomic.Uint64 + registrations sync.Once +} + +// coreApp returns the attached Core runtime, if one exists. +func (s *Service) coreApp() *core.Core { + if s == nil || s.ServiceRuntime == nil { + return nil + } + return s.ServiceRuntime.Core() } // Options configures the process service. +// +// Example: +// +// svc := process.NewService(process.Options{BufferSize: 2 * 1024 * 1024}) type Options struct { // BufferSize is the ring buffer size for output capture. // Default: 1MB (1024 * 1024 bytes). BufferSize int } -// Register constructs a Service bound to the provided Core instance. +// NewService creates a process service factory for Core registration. // -// c := core.New() -// svc := process.Register(c).Value.(*process.Service) -func Register(c *core.Core) core.Result { - opts := Options{BufferSize: DefaultBufferSize} - svc := &Service{ - ServiceRuntime: core.NewServiceRuntime(c, opts), - managed: core.NewRegistry[*ManagedProcess](), - bufSize: opts.BufferSize, +// core, _ := core.New( +// core.WithName("process", process.NewService(process.Options{})), +// ) +// +// Example: +// +// factory := process.NewService(process.Options{}) +func NewService(opts Options) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + if opts.BufferSize == 0 { + opts.BufferSize = DefaultBufferSize + } + svc := &Service{ + ServiceRuntime: core.NewServiceRuntime(c, opts), + processes: make(map[string]*Process), + bufSize: opts.BufferSize, + } + return svc, nil } - return core.Result{Value: svc, OK: true} } // 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} +// +// Example: +// +// _ = svc.OnStartup(ctx) +func (s *Service) OnStartup(ctx context.Context) error { + s.registrations.Do(func() { + if c := s.coreApp(); c != nil { + c.RegisterTask(s.handleTask) + } + }) + return nil } -// OnShutdown implements core.Stoppable — kills all managed processes. +// OnShutdown implements core.Stoppable. +// Immediately kills all running processes to avoid shutdown stalls. // -// c.ServiceShutdown(ctx) // calls OnShutdown on all Stoppable services -func (s *Service) OnShutdown(ctx context.Context) core.Result { - s.managed.Each(func(_ string, proc *ManagedProcess) { - _ = proc.Kill() - }) - return core.Result{OK: true} +// Example: +// +// _ = svc.OnShutdown(ctx) +func (s *Service) OnShutdown(ctx context.Context) error { + s.mu.RLock() + procs := make([]*Process, 0, len(s.processes)) + for _, p := range s.processes { + if p.IsRunning() { + procs = append(procs, p) + } + } + s.mu.RUnlock() + + for _, p := range procs { + _, _ = p.killTree() + } + + return nil } // Start spawns a new process with the given command and args. // -// r := svc.Start(ctx, "echo", "hello") -// if r.OK { proc := r.Value.(*Process) } -func (s *Service) Start(ctx context.Context, command string, args ...string) core.Result { +// Example: +// +// proc, err := svc.Start(ctx, "echo", "hello") +func (s *Service) Start(ctx context.Context, command string, args ...string) (*Process, error) { return s.StartWithOptions(ctx, RunOptions{ Command: command, Args: args, @@ -91,17 +133,23 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) cor // StartWithOptions spawns a process with full configuration. // -// 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 { +// Example: +// +// proc, err := svc.StartWithOptions(ctx, process.RunOptions{Command: "pwd", Dir: "/tmp"}) +func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) { if opts.Command == "" { - return core.Result{Value: core.E("process.start", "command is required", nil), OK: false} + return nil, ServiceError("command is required", nil) } if ctx == nil { - ctx = context.Background() + return nil, ServiceError("context is required", ErrContextRequired) } - id := core.ID() + id := fmt.Sprintf("proc-%d", s.idCounter.Add(1)) + startedAt := time.Now() + + if opts.KillGroup && !opts.Detach { + return nil, coreerr.E("Service.StartWithOptions", "KillGroup requires Detach", nil) + } // Detached processes use Background context so they survive parent death parentCtx := ctx @@ -109,7 +157,7 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re parentCtx = context.Background() } procCtx, cancel := context.WithCancel(parentCtx) - cmd := execCommandContext(procCtx, opts.Command, opts.Args...) + cmd := exec.CommandContext(procCtx, opts.Command, opts.Args...) if opts.Dir != "" { cmd.Dir = opts.Dir @@ -118,28 +166,27 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re cmd.Env = append(cmd.Environ(), opts.Env...) } - // Detached processes get their own process group - if opts.Detach { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - } + // Put every subprocess in its own process group so shutdown can terminate + // the full tree without affecting the parent process. + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // Set up pipes stdout, err := cmd.StdoutPipe() if err != nil { cancel() - return core.Result{Value: core.E("process.start", core.Concat("stdout pipe failed: ", opts.Command), err), OK: false} + return nil, coreerr.E("Service.StartWithOptions", "failed to create stdout pipe", err) } stderr, err := cmd.StderrPipe() if err != nil { cancel() - return core.Result{Value: core.E("process.start", core.Concat("stderr pipe failed: ", opts.Command), err), OK: false} + return nil, coreerr.E("Service.StartWithOptions", "failed to create stderr pipe", err) } stdin, err := cmd.StdinPipe() if err != nil { cancel() - return core.Result{Value: core.E("process.start", core.Concat("stdin pipe failed: ", opts.Command), err), OK: false} + return nil, coreerr.E("Service.StartWithOptions", "failed to create stdin pipe", err) } // Create output buffer (enabled by default) @@ -148,13 +195,13 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re output = NewRingBuffer(s.bufSize) } - proc := &ManagedProcess{ + proc := &Process{ ID: id, Command: opts.Command, Args: append([]string(nil), opts.Args...), Dir: opts.Dir, Env: append([]string(nil), opts.Env...), - StartedAt: time.Now(), + StartedAt: startedAt, Status: StatusPending, cmd: cmd, ctx: procCtx, @@ -168,29 +215,45 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re // Start the process if err := cmd.Start(); err != nil { + startErr := coreerr.E("Service.StartWithOptions", "failed to start process", err) proc.mu.Lock() proc.Status = StatusFailed + proc.ExitCode = -1 + proc.Duration = time.Since(startedAt) proc.mu.Unlock() + + s.mu.Lock() + s.processes[id] = proc + s.mu.Unlock() + + close(proc.done) cancel() - return core.Result{Value: core.E("process.start", core.Concat("command failed: ", opts.Command), err), OK: false} + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessExited{ + ID: id, + ExitCode: -1, + Duration: proc.Duration, + Error: startErr, + }) + } + return proc, startErr } - proc.PID = cmd.Process.Pid + proc.mu.Lock() proc.Status = StatusRunning proc.mu.Unlock() // Store process - if r := s.managed.Set(id, proc); !r.OK { - cancel() - _ = cmd.Process.Kill() - return r - } + s.mu.Lock() + s.processes[id] = proc + s.mu.Unlock() // Start timeout watchdog if configured if opts.Timeout > 0 { go func() { select { case <-proc.done: + // Process exited before timeout case <-time.After(opts.Timeout): proc.Shutdown() } @@ -198,13 +261,15 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re } // Broadcast start - s.Core().ACTION(ActionProcessStarted{ - ID: id, - Command: opts.Command, - Args: opts.Args, - Dir: opts.Dir, - PID: cmd.Process.Pid, - }) + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessStarted{ + ID: id, + Command: opts.Command, + Args: opts.Args, + Dir: opts.Dir, + PID: cmd.Process.Pid, + }) + } // Stream output in goroutines var wg sync.WaitGroup @@ -220,14 +285,16 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re // Wait for process completion go func() { + // Wait for output streaming to complete wg.Wait() - waitErr := cmd.Wait() + + // Wait for process exit + err := cmd.Wait() duration := time.Since(proc.StartedAt) - status, exitCode, _, killedSignal := classifyProcessExit(proc, waitErr) + status, exitCode, exitErr, signalName := classifyProcessExit(err) proc.mu.Lock() - proc.PID = cmd.Process.Pid proc.Duration = duration proc.ExitCode = exitCode proc.Status = status @@ -236,21 +303,26 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Re close(proc.done) if status == StatusKilled { - s.emitKilledAction(proc, killedSignal) + s.emitKilledAction(proc, signalName) } - s.Core().ACTION(ActionProcessExited{ + + exitAction := ActionProcessExited{ ID: id, ExitCode: exitCode, Duration: duration, - Error: nil, - }) + Error: exitErr, + } + + if c := s.coreApp(); c != nil { + _ = c.ACTION(exitAction) + } }() - return core.Result{Value: proc, OK: true} + return proc, nil } // streamOutput reads from a pipe and broadcasts lines via ACTION. -func (s *Service) streamOutput(proc *ManagedProcess, r streamReader, stream Stream) { +func (s *Service) streamOutput(proc *Process, r goio.Reader, stream Stream) { scanner := bufio.NewScanner(r) // Increase buffer for long lines scanner.Buffer(make([]byte, 64*1024), 1024*1024) @@ -264,87 +336,199 @@ func (s *Service) streamOutput(proc *ManagedProcess, r streamReader, stream Stre } // Broadcast output - _ = s.Core().ACTION(ActionProcessOutput{ - ID: proc.ID, - Line: line, - Stream: stream, - }) + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessOutput{ + ID: proc.ID, + Line: line, + Stream: stream, + }) + } } } // Get returns a process by ID. -func (s *Service) Get(id string) (*ManagedProcess, error) { - r := s.managed.Get(id) - if !r.OK { +// +// Example: +// +// proc, err := svc.Get("proc-1") +func (s *Service) Get(id string) (*Process, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + proc, ok := s.processes[id] + if !ok { return nil, ErrProcessNotFound } - return r.Value.(*ManagedProcess), nil + return proc, nil } // List returns all processes. -func (s *Service) List() []*ManagedProcess { - result := make([]*ManagedProcess, 0, s.managed.Len()) - s.managed.Each(func(_ string, proc *ManagedProcess) { - result = append(result, proc) - }) +// +// Example: +// +// for _, proc := range svc.List() { _ = proc } +func (s *Service) List() []*Process { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]*Process, 0, len(s.processes)) + for _, p := range s.processes { + result = append(result, p) + } + sortProcesses(result) return result } // Running returns all currently running processes. -func (s *Service) Running() []*ManagedProcess { - result := make([]*ManagedProcess, 0, s.managed.Len()) - s.managed.Each(func(_ string, proc *ManagedProcess) { - if proc.IsRunning() { - result = append(result, proc) +// +// Example: +// +// running := svc.Running() +func (s *Service) Running() []*Process { + s.mu.RLock() + defer s.mu.RUnlock() + + var result []*Process + for _, p := range s.processes { + if p.IsRunning() { + result = append(result, p) } - }) + } + sortProcesses(result) return result } // Kill terminates a process by ID. +// +// Example: +// +// _ = svc.Kill("proc-1") func (s *Service) Kill(id string) error { proc, err := s.Get(id) if err != nil { return err } - if err := proc.Kill(); err != nil { + sent, err := proc.kill() + if err != nil { return err } - s.emitKilledAction(proc, proc.requestedSignal()) + if sent { + s.emitKilledAction(proc, "SIGKILL") + } return nil } -// Remove removes a completed process from the list. -func (s *Service) Remove(id string) error { +// KillPID terminates a process by operating-system PID. +// +// Example: +// +// _ = svc.KillPID(1234) +func (s *Service) KillPID(pid int) error { + if pid <= 0 { + return ServiceError("pid must be positive", nil) + } + + if proc := s.findByPID(pid); proc != nil { + sent, err := proc.kill() + if err != nil { + return err + } + if sent { + s.emitKilledAction(proc, "SIGKILL") + } + return nil + } + + if err := syscall.Kill(pid, syscall.SIGKILL); err != nil { + return coreerr.E("Service.KillPID", fmt.Sprintf("failed to signal pid %d", pid), err) + } + + return nil +} + +// Signal sends a signal to a process by ID. +// +// Example: +// +// _ = svc.Signal("proc-1", syscall.SIGTERM) +func (s *Service) Signal(id string, sig os.Signal) error { proc, err := s.Get(id) if err != nil { return err } - if proc.IsRunning() { - return core.E("process.remove", core.Concat("cannot remove running process: ", id), nil) + return proc.Signal(sig) +} + +// SignalPID sends a signal to a process by operating-system PID. +// +// Example: +// +// _ = svc.SignalPID(1234, syscall.SIGTERM) +func (s *Service) SignalPID(pid int, sig os.Signal) error { + if pid <= 0 { + return ServiceError("pid must be positive", nil) } - r := s.managed.Delete(id) - if !r.OK { + + if proc := s.findByPID(pid); proc != nil { + return proc.Signal(sig) + } + + target, err := os.FindProcess(pid) + if err != nil { + return coreerr.E("Service.SignalPID", fmt.Sprintf("failed to find pid %d", pid), err) + } + + if err := target.Signal(sig); err != nil { + return coreerr.E("Service.SignalPID", fmt.Sprintf("failed to signal pid %d", pid), err) + } + + return nil +} + +// Remove removes a completed process from the list. +// +// Example: +// +// _ = svc.Remove("proc-1") +func (s *Service) Remove(id string) error { + s.mu.Lock() + defer s.mu.Unlock() + + proc, ok := s.processes[id] + if !ok { return ErrProcessNotFound } + + if proc.IsRunning() { + return coreerr.E("Service.Remove", "cannot remove running process", nil) + } + + delete(s.processes, id) return nil } // Clear removes all completed processes. +// +// Example: +// +// svc.Clear() func (s *Service) Clear() { - ids := make([]string, 0) - s.managed.Each(func(id string, proc *ManagedProcess) { - if !proc.IsRunning() { - ids = append(ids, id) + s.mu.Lock() + defer s.mu.Unlock() + + for id, p := range s.processes { + if !p.IsRunning() { + delete(s.processes, id) } - }) - for _, id := range ids { - s.managed.Delete(id) } } // Output returns the captured output of a process. +// +// Example: +// +// out, err := svc.Output("proc-1") func (s *Service) Output(id string) (string, error) { proc, err := s.Get(id) if err != nil { @@ -353,157 +537,333 @@ func (s *Service) Output(id string) (string, error) { return proc.Output(), nil } -// Run executes a command and waits for completion. -// Value is always the output string. OK is true if exit code is 0. +// Input writes data to the stdin of a managed process. // -// r := svc.Run(ctx, "go", "test", "./...") -// output := r.Value.(string) -func (s *Service) Run(ctx context.Context, command string, args ...string) core.Result { - return s.RunWithOptions(ctx, RunOptions{ - Command: command, - Args: args, - }) +// Example: +// +// _ = svc.Input("proc-1", "hello\n") +func (s *Service) Input(id string, input string) error { + proc, err := s.Get(id) + if err != nil { + return err + } + return proc.SendInput(input) +} + +// CloseStdin closes the stdin pipe of a managed process. +// +// Example: +// +// _ = svc.CloseStdin("proc-1") +func (s *Service) CloseStdin(id string) error { + proc, err := s.Get(id) + if err != nil { + return err + } + return proc.CloseStdin() +} + +// Wait blocks until a managed process exits and returns its final snapshot. +// +// Example: +// +// info, err := svc.Wait("proc-1") +func (s *Service) Wait(id string) (Info, error) { + proc, err := s.Get(id) + if err != nil { + return Info{}, err + } + + if err := proc.Wait(); err != nil { + return proc.Info(), err + } + + return proc.Info(), nil +} + +// findByPID locates a managed process by operating-system PID. +func (s *Service) findByPID(pid int) *Process { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, proc := range s.processes { + proc.mu.RLock() + matches := proc.cmd != nil && proc.cmd.Process != nil && proc.cmd.Process.Pid == pid + proc.mu.RUnlock() + if matches { + return proc + } + } + return nil +} + +// Run executes a command and waits for completion. +// Returns the combined output and any error. +// +// Example: +// +// out, err := svc.Run(ctx, "echo", "hello") +func (s *Service) Run(ctx context.Context, command string, args ...string) (string, error) { + proc, err := s.Start(ctx, command, args...) + if err != nil { + return "", err + } + + <-proc.Done() + + output := proc.Output() + if proc.Status == StatusKilled { + return output, coreerr.E("Service.Run", "process was killed", nil) + } + if proc.ExitCode != 0 { + return output, coreerr.E("Service.Run", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil) + } + return output, nil } // RunWithOptions executes a command with options and waits for completion. -// Value is always the output string. OK is true if exit code is 0. // -// r := svc.RunWithOptions(ctx, process.RunOptions{Command: "go", Args: []string{"test"}}) -// output := r.Value.(string) -func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Result { - return s.runCommand(ctx, opts) -} - -func (s *Service) runCommand(ctx context.Context, opts RunOptions) core.Result { - if opts.Command == "" { - return core.Result{Value: core.E("process.run", "command is required", nil), OK: false} - } - if ctx == nil { - ctx = context.Background() - } - - cmd := execCommandContext(ctx, opts.Command, opts.Args...) - if opts.Dir != "" { - cmd.Dir = opts.Dir - } - if len(opts.Env) > 0 { - cmd.Env = append(cmd.Environ(), opts.Env...) - } - - output, err := cmd.CombinedOutput() +// Example: +// +// out, err := svc.RunWithOptions(ctx, process.RunOptions{Command: "echo", Args: []string{"hello"}}) +func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string, error) { + proc, err := s.StartWithOptions(ctx, opts) if err != nil { - return core.Result{Value: core.E("process.run", core.Concat("command failed: ", opts.Command), err), OK: false} - } - return core.Result{Value: string(output), OK: true} -} - -// Signal sends a signal to the process. -func (p *ManagedProcess) Signal(sig os.Signal) error { - p.mu.Lock() - defer p.mu.Unlock() - - if p.Status != StatusRunning { - return ErrProcessNotRunning + return "", err } - if p.cmd == nil || p.cmd.Process == nil { - return nil + <-proc.Done() + + output := proc.Output() + if proc.Status == StatusKilled { + return output, coreerr.E("Service.RunWithOptions", "process was killed", nil) } - - if signal, ok := sig.(syscall.Signal); ok { - p.lastSignal = normalizeSignalName(signal) + if proc.ExitCode != 0 { + return output, coreerr.E("Service.RunWithOptions", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil) } - return p.cmd.Process.Signal(sig) + return output, nil } -func execCommandContext(ctx context.Context, name string, args ...string) *exec.Cmd { - return exec.CommandContext(ctx, name, args...) +// handleTask dispatches Core.PERFORM messages for the process service. +func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { + switch m := task.(type) { + case TaskProcessStart: + proc, err := s.StartWithOptions(c.Context(), RunOptions{ + Command: m.Command, + Args: m.Args, + Dir: m.Dir, + Env: m.Env, + DisableCapture: m.DisableCapture, + Detach: m.Detach, + Timeout: m.Timeout, + GracePeriod: m.GracePeriod, + KillGroup: m.KillGroup, + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: proc.Info(), OK: true} + case TaskProcessRun: + output, err := s.RunWithOptions(c.Context(), RunOptions{ + Command: m.Command, + Args: m.Args, + Dir: m.Dir, + Env: m.Env, + DisableCapture: m.DisableCapture, + Detach: m.Detach, + Timeout: m.Timeout, + GracePeriod: m.GracePeriod, + KillGroup: m.KillGroup, + }) + if err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{Value: output, OK: true} + case TaskProcessKill: + switch { + case m.ID != "": + if err := s.Kill(m.ID); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + case m.PID > 0: + if err := s.KillPID(m.PID); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + default: + return core.Result{Value: coreerr.E("Service.handleTask", "task process kill requires an id or pid", nil), OK: false} + } + case TaskProcessSignal: + switch { + case m.ID != "": + if err := s.Signal(m.ID, m.Signal); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + case m.PID > 0: + if err := s.SignalPID(m.PID, m.Signal); err != nil { + return core.Result{Value: err, OK: false} + } + return core.Result{OK: true} + default: + return core.Result{Value: coreerr.E("Service.handleTask", "task process signal requires an id or pid", nil), OK: false} + } + case TaskProcessGet: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process get requires an id", nil), OK: false} + } + + proc, err := s.Get(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{Value: proc.Info(), OK: true} + case TaskProcessWait: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process wait requires an id", nil), OK: false} + } + + info, err := s.Wait(m.ID) + if err != nil { + return core.Result{ + Value: &TaskProcessWaitError{ + Info: info, + Err: err, + }, + OK: true, + } + } + + return core.Result{Value: info, OK: true} + case TaskProcessOutput: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process output requires an id", nil), OK: false} + } + + output, err := s.Output(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{Value: output, OK: true} + case TaskProcessInput: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process input requires an id", nil), OK: false} + } + + proc, err := s.Get(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + if err := proc.SendInput(m.Input); err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{OK: true} + case TaskProcessCloseStdin: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process close stdin requires an id", nil), OK: false} + } + + proc, err := s.Get(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + if err := proc.CloseStdin(); err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{OK: true} + case TaskProcessList: + procs := s.List() + if m.RunningOnly { + procs = s.Running() + } + + infos := make([]Info, 0, len(procs)) + for _, proc := range procs { + infos = append(infos, proc.Info()) + } + + return core.Result{Value: infos, OK: true} + case TaskProcessRemove: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process remove requires an id", nil), OK: false} + } + + if err := s.Remove(m.ID); err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{OK: true} + case TaskProcessClear: + s.Clear() + return core.Result{OK: true} + default: + return core.Result{} + } } -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 classifyProcessExit(proc *ManagedProcess, err error) (Status, int, error, string) { +// classifyProcessExit maps a command completion error to lifecycle state. +func classifyProcessExit(err error) (Status, int, error, string) { if err == nil { return StatusExited, 0, nil, "" } - if sig, ok := processExitSignal(err); ok { - return StatusKilled, -1, err, normalizeSignalName(sig) - } - - if ctxErr := proc.ctx.Err(); ctxErr != nil { - signal := proc.requestedSignal() - if signal == "" { - signal = "SIGKILL" + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok && ws.Signaled() { + signalName := ws.Signal().String() + if signalName == "" { + signalName = "signal" + } + return StatusKilled, -1, coreerr.E("Service.StartWithOptions", "process was killed", nil), signalName } - return StatusKilled, -1, ctxErr, signal + exitCode := exitErr.ExitCode() + return StatusExited, exitCode, coreerr.E("Service.StartWithOptions", fmt.Sprintf("process exited with code %d", exitCode), nil), "" } - var exitErr *exec.ExitError - if core.As(err, &exitErr) { - return StatusExited, exitErr.ExitCode(), err, "" - } - - return StatusFailed, -1, err, "" + return StatusFailed, 0, err, "" } -func processExitSignal(err error) (syscall.Signal, bool) { - var exitErr *exec.ExitError - if !core.As(err, &exitErr) || exitErr.ProcessState == nil { - return 0, false - } - - waitStatus, ok := exitErr.ProcessState.Sys().(syscall.WaitStatus) - if !ok || !waitStatus.Signaled() { - return 0, false - } - return waitStatus.Signal(), true -} - -func normalizeSignalName(sig syscall.Signal) string { - switch sig { - case syscall.SIGINT: - return "SIGINT" - case syscall.SIGKILL: - return "SIGKILL" - case syscall.SIGTERM: - return "SIGTERM" - default: - return sig.String() - } -} - -func (s *Service) emitKilledAction(proc *ManagedProcess, signal string) { - if proc == nil || !proc.markKillEmitted() { +// emitKilledAction broadcasts a kill event once for the given process. +func (s *Service) emitKilledAction(proc *Process, signalName string) { + if proc == nil { return } - if signal == "" { - signal = "SIGKILL" + + proc.mu.Lock() + if proc.killNotified { + proc.mu.Unlock() + return } - _ = s.Core().ACTION(ActionProcessKilled{ - ID: proc.ID, - Signal: signal, + proc.killNotified = true + if signalName != "" { + proc.killSignal = signalName + } else if proc.killSignal == "" { + proc.killSignal = "SIGKILL" + } + signal := proc.killSignal + proc.mu.Unlock() + + if c := s.coreApp(); c != nil { + _ = c.ACTION(ActionProcessKilled{ + ID: proc.ID, + Signal: signal, + }) + } +} + +// sortProcesses orders processes by start time, then ID for stable output. +func sortProcesses(procs []*Process) { + sort.Slice(procs, func(i, j int) bool { + if procs[i].StartedAt.Equal(procs[j].StartedAt) { + return procs[i].ID < procs[j].ID + } + return procs[i].StartedAt.Before(procs[j].StartedAt) }) } diff --git a/service_test.go b/service_test.go index a81af56..0d98959 100644 --- a/service_test.go +++ b/service_test.go @@ -2,7 +2,10 @@ package process import ( "context" + "os/exec" + "strings" "sync" + "syscall" "testing" "time" @@ -15,289 +18,27 @@ func newTestService(t *testing.T) (*Service, *framework.Core) { t.Helper() c := framework.New() - r := Register(c) - require.True(t, r.OK) - return r.Value.(*Service), c -} + factory := NewService(Options{BufferSize: 1024}) + raw, err := factory(c) + require.NoError(t, err) -func newStartedTestService(t *testing.T) (*Service, *framework.Core) { - t.Helper() - - svc, c := newTestService(t) - r := svc.OnStartup(context.Background()) - require.True(t, r.OK) + svc := raw.(*Service) return svc, c } -func TestService_Register_Good(t *testing.T) { - c := framework.New(framework.WithService(Register)) - - svc, ok := framework.ServiceFor[*Service](c, "process") - require.True(t, ok) - assert.NotNil(t, svc) -} - -func TestService_OnStartup_Good(t *testing.T) { - svc, c := newTestService(t) - - r := svc.OnStartup(context.Background()) - require.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_HandleRun_Good(t *testing.T) { - _, c := newStartedTestService(t) - - r := c.Action("process.run").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "command", Value: "echo"}, - framework.Option{Key: "args", Value: []string{"hello"}}, - )) - require.True(t, r.OK) - assert.Contains(t, r.Value.(string), "hello") -} - -func TestService_HandleRun_Bad(t *testing.T) { - _, c := newStartedTestService(t) - - r := c.Action("process.run").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "command", Value: "nonexistent_command_xyz"}, - )) - assert.False(t, r.OK) -} - -func TestService_HandleRun_Ugly(t *testing.T) { - _, c := newStartedTestService(t) - - ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - defer cancel() - - r := c.Action("process.run").Run(ctx, framework.NewOptions( - framework.Option{Key: "command", Value: "sleep"}, - framework.Option{Key: "args", Value: []string{"1"}}, - )) - assert.False(t, r.OK) -} - -func TestService_HandleStart_Good(t *testing.T) { - svc, c := newStartedTestService(t) - - r := c.Action("process.start").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "command", Value: "sleep"}, - framework.Option{Key: "args", Value: []string{"60"}}, - )) - require.True(t, r.OK) - - id := r.Value.(string) - proc, err := svc.Get(id) - require.NoError(t, err) - assert.True(t, proc.IsRunning()) - - kill := c.Action("process.kill").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "id", Value: id}, - )) - require.True(t, kill.OK) - <-proc.Done() - - t.Run("respects detach=false", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - start := c.Action("process.start").Run(ctx, framework.NewOptions( - framework.Option{Key: "command", Value: "sleep"}, - framework.Option{Key: "args", Value: []string{"60"}}, - framework.Option{Key: "detach", Value: false}, - )) - require.True(t, start.OK) - - id := start.Value.(string) - proc, err := svc.Get(id) - require.NoError(t, err) - - cancel() - - select { - case <-proc.Done(): - case <-time.After(2 * time.Second): - t.Fatal("process should honor detached=false context cancellation") - } - }) - - t.Run("defaults to non-detached", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - - start := c.Action("process.start").Run(ctx, framework.NewOptions( - framework.Option{Key: "command", Value: "sleep"}, - framework.Option{Key: "args", Value: []string{"60"}}, - )) - require.True(t, start.OK) - - id := start.Value.(string) - proc, err := svc.Get(id) - require.NoError(t, err) - - cancel() - - select { - case <-proc.Done(): - case <-time.After(2 * time.Second): - t.Fatal("process should honor context cancellation by default") - } - }) -} - -func TestService_HandleStart_Bad(t *testing.T) { - _, c := newStartedTestService(t) - - r := c.Action("process.start").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "command", Value: "nonexistent_command_xyz"}, - )) - assert.False(t, r.OK) -} - -func TestService_HandleKill_Good(t *testing.T) { - svc, c := newStartedTestService(t) - - start := c.Action("process.start").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "command", Value: "sleep"}, - framework.Option{Key: "args", Value: []string{"60"}}, - )) - require.True(t, start.OK) - - id := start.Value.(string) - proc, err := svc.Get(id) - require.NoError(t, err) - - kill := c.Action("process.kill").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "id", Value: id}, - )) - require.True(t, kill.OK) - - select { - case <-proc.Done(): - case <-time.After(2 * time.Second): - t.Fatal("process should have been killed") - } -} - -func TestService_HandleKill_Bad(t *testing.T) { - _, c := newStartedTestService(t) - - r := c.Action("process.kill").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "id", Value: "missing"}, - )) - assert.False(t, r.OK) -} - -func TestService_HandleList_Good(t *testing.T) { - svc, c := newStartedTestService(t) - - startOne := c.Action("process.start").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "command", Value: "sleep"}, - framework.Option{Key: "args", Value: []string{"60"}}, - )) - require.True(t, startOne.OK) - startTwo := c.Action("process.start").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "command", Value: "sleep"}, - framework.Option{Key: "args", Value: []string{"60"}}, - )) - require.True(t, startTwo.OK) - - r := c.Action("process.list").Run(context.Background(), framework.NewOptions()) - require.True(t, r.OK) - - ids := r.Value.([]string) - assert.Len(t, ids, 2) - - for _, id := range ids { - proc, err := svc.Get(id) - require.NoError(t, err) - _ = proc.Kill() - <-proc.Done() - } -} - -func TestService_HandleGet_Good(t *testing.T) { - svc, c := newStartedTestService(t) - - start := c.Action("process.start").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "command", Value: "sleep"}, - framework.Option{Key: "args", Value: []string{"60"}}, - )) - require.True(t, start.OK) - - id := start.Value.(string) - r := c.Action("process.get").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "id", Value: id}, - )) - require.True(t, r.OK) - - info := r.Value.(ProcessInfo) - assert.Equal(t, id, info.ID) - assert.Equal(t, "sleep", info.Command) - assert.True(t, info.Running) - assert.Equal(t, StatusRunning, info.Status) - assert.Positive(t, info.PID) - - proc, err := svc.Get(id) - require.NoError(t, err) - _ = proc.Kill() - <-proc.Done() -} - -func TestService_HandleGet_Bad(t *testing.T) { - _, c := newStartedTestService(t) - - missingID := c.Action("process.get").Run(context.Background(), framework.NewOptions()) - assert.False(t, missingID.OK) - - missingProc := c.Action("process.get").Run(context.Background(), framework.NewOptions( - framework.Option{Key: "id", Value: "missing"}, - )) - assert.False(t, missingProc.OK) -} - -func TestService_Ugly_PermissionModel(t *testing.T) { - c := framework.New() - - r := c.Process().Run(context.Background(), "echo", "blocked") - assert.False(t, r.OK) - - c = framework.New(framework.WithService(Register)) - startup := c.ServiceStartup(context.Background(), nil) - require.True(t, startup.OK) - defer func() { - shutdown := c.ServiceShutdown(context.Background()) - assert.True(t, shutdown.OK) - }() - - r = c.Process().Run(context.Background(), "echo", "allowed") - require.True(t, r.OK) - assert.Contains(t, r.Value.(string), "allowed") -} - -func startProc(t *testing.T, svc *Service, ctx context.Context, command string, args ...string) *Process { - t.Helper() - r := svc.Start(ctx, command, args...) - require.True(t, r.OK) - return r.Value.(*Process) -} - -func TestService_Start_Good(t *testing.T) { +func TestService_Start(t *testing.T) { t.Run("echo command", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "hello") + proc, err := svc.Start(context.Background(), "echo", "hello") + require.NoError(t, err) + require.NotNil(t, proc) assert.NotEmpty(t, proc.ID) - assert.Positive(t, proc.PID) assert.Equal(t, "echo", proc.Command) assert.Equal(t, []string{"hello"}, proc.Args) + // Wait for completion <-proc.Done() assert.Equal(t, StatusExited, proc.Status) @@ -305,10 +46,28 @@ func TestService_Start_Good(t *testing.T) { assert.Contains(t, proc.Output(), "hello") }) + t.Run("works without core runtime", func(t *testing.T) { + svc := &Service{ + processes: make(map[string]*Process), + bufSize: 1024, + } + + proc, err := svc.Start(context.Background(), "echo", "standalone") + require.NoError(t, err) + require.NotNil(t, proc) + + <-proc.Done() + + assert.Equal(t, StatusExited, proc.Status) + assert.Equal(t, 0, proc.ExitCode) + assert.Contains(t, proc.Output(), "standalone") + }) + t.Run("failing command", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 42") + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 42") + require.NoError(t, err) <-proc.Done() @@ -319,23 +78,51 @@ func TestService_Start_Good(t *testing.T) { t.Run("non-existent command", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.Start(context.Background(), "nonexistent_command_xyz") - assert.False(t, r.OK) + proc, err := svc.Start(context.Background(), "nonexistent_command_xyz") + assert.Error(t, err) + require.NotNil(t, proc) + assert.Equal(t, StatusFailed, proc.Status) + assert.Equal(t, -1, proc.ExitCode) + assert.NotNil(t, proc.Done()) + <-proc.Done() + + got, getErr := svc.Get(proc.ID) + require.NoError(t, getErr) + assert.Equal(t, proc.ID, got.ID) + assert.Equal(t, StatusFailed, got.Status) + }) + + t.Run("empty command is rejected", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.StartWithOptions(context.Background(), RunOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "command is required") + }) + + t.Run("nil context is rejected", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.StartWithOptions(nil, RunOptions{ + Command: "echo", + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrContextRequired) }) t.Run("with working directory", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.StartWithOptions(context.Background(), RunOptions{ + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "pwd", Dir: "/tmp", }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) <-proc.Done() - output := framework.Trim(proc.Output()) + // On macOS /tmp is a symlink to /private/tmp + output := strings.TrimSpace(proc.Output()) assert.True(t, output == "/tmp" || output == "/private/tmp", "got: %s", output) }) @@ -343,12 +130,15 @@ func TestService_Start_Good(t *testing.T) { svc, _ := newTestService(t) ctx, cancel := context.WithCancel(context.Background()) - proc := startProc(t, svc, ctx, "sleep", "10") + proc, err := svc.Start(ctx, "sleep", "10") + require.NoError(t, err) + // Cancel immediately cancel() select { case <-proc.Done(): + // Good - process was killed case <-time.After(2 * time.Second): t.Fatal("process should have been killed") } @@ -357,13 +147,12 @@ func TestService_Start_Good(t *testing.T) { t.Run("disable capture", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.StartWithOptions(context.Background(), RunOptions{ + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "echo", Args: []string{"no-capture"}, DisableCapture: true, }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) <-proc.Done() assert.Equal(t, StatusExited, proc.Status) @@ -373,13 +162,12 @@ func TestService_Start_Good(t *testing.T) { t.Run("with environment variables", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.StartWithOptions(context.Background(), RunOptions{ + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ Command: "sh", Args: []string{"-c", "echo $MY_TEST_VAR"}, Env: []string{"MY_TEST_VAR=hello_env"}, }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) <-proc.Done() assert.Contains(t, proc.Output(), "hello_env") @@ -390,16 +178,17 @@ func TestService_Start_Good(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) - r := svc.StartWithOptions(ctx, RunOptions{ + proc, err := svc.StartWithOptions(ctx, RunOptions{ Command: "echo", Args: []string{"detached"}, Detach: true, }) - require.True(t, r.OK) - proc := r.Value.(*Process) + require.NoError(t, err) + // Cancel the parent context cancel() + // Detached process should still complete normally select { case <-proc.Done(): assert.Equal(t, StatusExited, proc.Status) @@ -408,28 +197,47 @@ func TestService_Start_Good(t *testing.T) { t.Fatal("detached process should have completed") } }) + + t.Run("kill group requires detach", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sleep", + Args: []string{"1"}, + KillGroup: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "KillGroup requires Detach") + }) } -func TestService_Run_Good(t *testing.T) { +func TestService_Run(t *testing.T) { t.Run("returns output", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.Run(context.Background(), "echo", "hello world") - assert.True(t, r.OK) - assert.Contains(t, r.Value.(string), "hello world") + output, err := svc.Run(context.Background(), "echo", "hello world") + require.NoError(t, err) + assert.Contains(t, output, "hello world") }) - t.Run("returns !OK on failure", func(t *testing.T) { + t.Run("returns error on failure", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.Run(context.Background(), "sh", "-c", "exit 1") - assert.False(t, r.OK) + _, err := svc.Run(context.Background(), "sh", "-c", "exit 1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "exited with code 1") }) } -func TestService_Actions_Good(t *testing.T) { +func TestService_Actions(t *testing.T) { t.Run("broadcasts events", func(t *testing.T) { - svc, c := newTestService(t) + c := framework.New() + + // Register process service on Core + factory := NewService(Options{}) + raw, err := factory(c) + require.NoError(t, err) + svc := raw.(*Service) var started []ActionProcessStarted var outputs []ActionProcessOutput @@ -449,10 +257,12 @@ func TestService_Actions_Good(t *testing.T) { } return framework.Result{OK: true} }) - proc := startProc(t, svc, context.Background(), "echo", "test") + proc, err := svc.Start(context.Background(), "echo", "test") + require.NoError(t, err) <-proc.Done() + // Give time for events to propagate time.Sleep(10 * time.Millisecond) mu.Lock() @@ -465,7 +275,7 @@ func TestService_Actions_Good(t *testing.T) { assert.NotEmpty(t, outputs) foundTest := false for _, o := range outputs { - if framework.Contains(o.Line, "test") { + if strings.Contains(o.Line, "test") { foundTest = true break } @@ -477,10 +287,16 @@ func TestService_Actions_Good(t *testing.T) { assert.Nil(t, exited[0].Error) }) - t.Run("broadcasts killed event", func(t *testing.T) { - svc, c := newTestService(t) + t.Run("broadcasts killed events", func(t *testing.T) { + c := framework.New() + + factory := NewService(Options{}) + raw, err := factory(c) + require.NoError(t, err) + svc := raw.(*Service) var killed []ActionProcessKilled + var exited []ActionProcessExited var mu sync.Mutex c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result { @@ -489,43 +305,131 @@ func TestService_Actions_Good(t *testing.T) { if m, ok := msg.(ActionProcessKilled); ok { killed = append(killed, m) } + if m, ok := msg.(ActionProcessExited); ok { + exited = append(exited, m) + } return framework.Result{OK: true} }) - proc := startProc(t, svc, context.Background(), "sleep", "60") - err := svc.Kill(proc.ID) + proc, err := svc.Start(context.Background(), "sleep", "60") require.NoError(t, err) - <-proc.Done() + + err = svc.Kill(proc.ID) + require.NoError(t, err) + + time.Sleep(10 * time.Millisecond) + + mu.Lock() + require.Len(t, killed, 1) + assert.Equal(t, proc.ID, killed[0].ID) + assert.NotEmpty(t, killed[0].Signal) + mu.Unlock() + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been killed") + } time.Sleep(10 * time.Millisecond) mu.Lock() defer mu.Unlock() + assert.Len(t, exited, 1) + assert.Equal(t, proc.ID, exited[0].ID) + require.Error(t, exited[0].Error) + assert.Contains(t, exited[0].Error.Error(), "process was killed") + assert.Equal(t, StatusKilled, proc.Status) + }) - require.Len(t, killed, 1) - assert.Equal(t, proc.ID, killed[0].ID) - assert.Equal(t, "SIGKILL", killed[0].Signal) + t.Run("broadcasts exited event on start failure", func(t *testing.T) { + c := framework.New() + + factory := NewService(Options{}) + raw, err := factory(c) + require.NoError(t, err) + svc := raw.(*Service) + + var exited []ActionProcessExited + var mu sync.Mutex + + c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result { + mu.Lock() + defer mu.Unlock() + if m, ok := msg.(ActionProcessExited); ok { + exited = append(exited, m) + } + return framework.Result{OK: true} + }) + + _, err = svc.Start(context.Background(), "definitely-not-a-real-binary-xyz") + require.Error(t, err) + + time.Sleep(10 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + require.Len(t, exited, 1) + assert.Equal(t, -1, exited[0].ExitCode) + require.Error(t, exited[0].Error) + assert.Contains(t, exited[0].Error.Error(), "failed to start process") + }) + + t.Run("broadcasts exited error on non-zero exit", func(t *testing.T) { + c := framework.New() + + factory := NewService(Options{}) + raw, err := factory(c) + require.NoError(t, err) + svc := raw.(*Service) + + var exited []ActionProcessExited + var mu sync.Mutex + + c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result { + mu.Lock() + defer mu.Unlock() + if m, ok := msg.(ActionProcessExited); ok { + exited = append(exited, m) + } + return framework.Result{OK: true} + }) + + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + <-proc.Done() + time.Sleep(10 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + require.Len(t, exited, 1) + assert.Equal(t, 7, exited[0].ExitCode) + require.Error(t, exited[0].Error) + assert.Contains(t, exited[0].Error.Error(), "process exited with code 7") }) } -func TestService_List_Good(t *testing.T) { +func TestService_List(t *testing.T) { t.Run("tracks processes", func(t *testing.T) { svc, _ := newTestService(t) - proc1 := startProc(t, svc, context.Background(), "echo", "1") - proc2 := startProc(t, svc, context.Background(), "echo", "2") + proc1, _ := svc.Start(context.Background(), "echo", "1") + proc2, _ := svc.Start(context.Background(), "echo", "2") <-proc1.Done() <-proc2.Done() list := svc.List() assert.Len(t, list, 2) + assert.Equal(t, proc1.ID, list[0].ID) + assert.Equal(t, proc2.ID, list[1].ID) }) t.Run("get by id", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "test") + proc, _ := svc.Start(context.Background(), "echo", "test") <-proc.Done() got, err := svc.Get(proc.ID) @@ -541,11 +445,11 @@ func TestService_List_Good(t *testing.T) { }) } -func TestService_Remove_Good(t *testing.T) { +func TestService_Remove(t *testing.T) { t.Run("removes completed process", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "test") + proc, _ := svc.Start(context.Background(), "echo", "test") <-proc.Done() err := svc.Remove(proc.ID) @@ -561,7 +465,7 @@ func TestService_Remove_Good(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc := startProc(t, svc, ctx, "sleep", "10") + proc, _ := svc.Start(ctx, "sleep", "10") err := svc.Remove(proc.ID) assert.Error(t, err) @@ -571,12 +475,12 @@ func TestService_Remove_Good(t *testing.T) { }) } -func TestService_Clear_Good(t *testing.T) { +func TestService_Clear(t *testing.T) { t.Run("clears completed processes", func(t *testing.T) { svc, _ := newTestService(t) - proc1 := startProc(t, svc, context.Background(), "echo", "1") - proc2 := startProc(t, svc, context.Background(), "echo", "2") + proc1, _ := svc.Start(context.Background(), "echo", "1") + proc2, _ := svc.Start(context.Background(), "echo", "2") <-proc1.Done() <-proc2.Done() @@ -589,23 +493,27 @@ func TestService_Clear_Good(t *testing.T) { }) } -func TestService_Kill_Good(t *testing.T) { +func TestService_Kill(t *testing.T) { t.Run("kills running process", func(t *testing.T) { svc, _ := newTestService(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc := startProc(t, svc, ctx, "sleep", "60") + proc, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) - err := svc.Kill(proc.ID) + err = svc.Kill(proc.ID) assert.NoError(t, err) select { case <-proc.Done(): + // Process killed successfully case <-time.After(2 * time.Second): t.Fatal("process should have been killed") } + + assert.Equal(t, StatusKilled, proc.Status) }) t.Run("error on unknown id", func(t *testing.T) { @@ -616,11 +524,111 @@ func TestService_Kill_Good(t *testing.T) { }) } -func TestService_Output_Good(t *testing.T) { +func TestService_KillPID(t *testing.T) { + t.Run("terminates unmanaged process with SIGKILL", func(t *testing.T) { + svc, _ := newTestService(t) + + // Ignore SIGTERM so the test proves KillPID uses a forceful signal. + cmd := exec.Command("sh", "-c", "trap '' TERM; while :; do :; done") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + err := svc.KillPID(cmd.Process.Pid) + require.NoError(t, err) + + select { + case err := <-waitCh: + require.Error(t, err) + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + ws, ok := exitErr.Sys().(syscall.WaitStatus) + require.True(t, ok) + assert.True(t, ws.Signaled()) + assert.Equal(t, syscall.SIGKILL, ws.Signal()) + case <-time.After(2 * time.Second): + t.Fatal("unmanaged process should have been killed") + } + }) +} + +func TestService_Signal(t *testing.T) { + t.Run("signals running process by id", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + err = svc.Signal(proc.ID, syscall.SIGTERM) + assert.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been signalled") + } + + assert.Equal(t, StatusKilled, proc.Status) + }) + + t.Run("signals unmanaged process by pid", func(t *testing.T) { + svc, _ := newTestService(t) + + cmd := exec.Command("sleep", "60") + require.NoError(t, cmd.Start()) + + waitCh := make(chan error, 1) + go func() { + waitCh <- cmd.Wait() + }() + + t.Cleanup(func() { + if cmd.ProcessState == nil && cmd.Process != nil { + _ = cmd.Process.Kill() + } + select { + case <-waitCh: + case <-time.After(2 * time.Second): + } + }) + + err := svc.SignalPID(cmd.Process.Pid, syscall.SIGTERM) + require.NoError(t, err) + + select { + case err := <-waitCh: + require.Error(t, err) + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr) + ws, ok := exitErr.Sys().(syscall.WaitStatus) + require.True(t, ok) + assert.True(t, ws.Signaled()) + assert.Equal(t, syscall.SIGTERM, ws.Signal()) + case <-time.After(2 * time.Second): + t.Fatal("unmanaged process should have been signalled") + } + }) +} + +func TestService_Output(t *testing.T) { t.Run("returns captured output", func(t *testing.T) { svc, _ := newTestService(t) - proc := startProc(t, svc, context.Background(), "echo", "captured") + proc, err := svc.Start(context.Background(), "echo", "captured") + require.NoError(t, err) <-proc.Done() output, err := svc.Output(proc.ID) @@ -636,21 +644,109 @@ func TestService_Output_Good(t *testing.T) { }) } -func TestService_OnShutdown_Good(t *testing.T) { +func TestService_Input(t *testing.T) { + t.Run("writes to stdin", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + err = svc.Input(proc.ID, "service-input\n") + require.NoError(t, err) + + err = svc.CloseStdin(proc.ID) + require.NoError(t, err) + + <-proc.Done() + + assert.Contains(t, proc.Output(), "service-input") + }) + + t.Run("error on unknown id", func(t *testing.T) { + svc, _ := newTestService(t) + + err := svc.Input("nonexistent", "test") + assert.ErrorIs(t, err, ErrProcessNotFound) + }) +} + +func TestService_CloseStdin(t *testing.T) { + t.Run("closes stdin pipe", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + err = svc.CloseStdin(proc.ID) + require.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("cat should exit when stdin is closed") + } + }) + + t.Run("error on unknown id", func(t *testing.T) { + svc, _ := newTestService(t) + + err := svc.CloseStdin("nonexistent") + assert.ErrorIs(t, err, ErrProcessNotFound) + }) +} + +func TestService_Wait(t *testing.T) { + t.Run("returns final info on success", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "echo", "waited") + require.NoError(t, err) + + info, err := svc.Wait(proc.ID) + require.NoError(t, err) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 0, info.ExitCode) + }) + + t.Run("returns error on unknown id", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.Wait("nonexistent") + assert.ErrorIs(t, err, ErrProcessNotFound) + }) + + t.Run("returns info alongside failure", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + info, err := svc.Wait(proc.ID) + require.Error(t, err) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 7, info.ExitCode) + }) +} + +func TestService_OnShutdown(t *testing.T) { t.Run("kills all running processes", func(t *testing.T) { svc, _ := newTestService(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc1 := startProc(t, svc, ctx, "sleep", "60") - proc2 := startProc(t, svc, ctx, "sleep", "60") + proc1, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + proc2, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) assert.True(t, proc1.IsRunning()) assert.True(t, proc2.IsRunning()) - r := svc.OnShutdown(context.Background()) - assert.True(t, r.OK) + err = svc.OnShutdown(context.Background()) + assert.NoError(t, err) select { case <-proc1.Done(): @@ -663,47 +759,459 @@ func TestService_OnShutdown_Good(t *testing.T) { t.Fatal("proc2 should have been killed") } }) + + t.Run("does not wait for process grace period", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sh", + Args: []string{"-c", "trap '' TERM; sleep 60"}, + GracePeriod: 5 * time.Second, + }) + require.NoError(t, err) + require.True(t, proc.IsRunning()) + + start := time.Now() + err = svc.OnShutdown(context.Background()) + require.NoError(t, err) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been killed immediately on shutdown") + } + + assert.Less(t, time.Since(start), 2*time.Second) + assert.Equal(t, StatusKilled, proc.Status) + }) } -func TestService_RunWithOptions_Good(t *testing.T) { +func TestService_OnStartup(t *testing.T) { + t.Run("registers process.start task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + result := c.PERFORM(TaskProcessStart{ + Command: "sleep", + Args: []string{"1"}, + }) + + require.True(t, result.OK) + + info, ok := result.Value.(Info) + require.True(t, ok) + assert.NotEmpty(t, info.ID) + assert.Equal(t, StatusRunning, info.Status) + assert.True(t, info.Running) + + proc, err := svc.Get(info.ID) + require.NoError(t, err) + assert.True(t, proc.IsRunning()) + + <-proc.Done() + assert.Equal(t, StatusExited, proc.Status) + }) + + t.Run("registers process.run task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + result := c.PERFORM(TaskProcessRun{ + Command: "echo", + Args: []string{"action-run"}, + }) + + require.True(t, result.OK) + assert.Contains(t, result.Value.(string), "action-run") + }) + + t.Run("forwards task execution options", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + result := c.PERFORM(TaskProcessRun{ + Command: "sleep", + Args: []string{"60"}, + Timeout: 100 * time.Millisecond, + GracePeriod: 50 * time.Millisecond, + }) + + require.False(t, result.OK) + assert.Nil(t, result.Value) + }) + + t.Run("registers process.kill task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + require.True(t, proc.IsRunning()) + + var killed []ActionProcessKilled + c.RegisterAction(func(cc *framework.Core, msg framework.Message) framework.Result { + if m, ok := msg.(ActionProcessKilled); ok { + killed = append(killed, m) + } + return framework.Result{OK: true} + }) + + result := c.PERFORM(TaskProcessKill{PID: proc.Info().PID}) + require.True(t, result.OK) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been killed by pid") + } + + assert.Equal(t, StatusKilled, proc.Status) + require.Len(t, killed, 1) + assert.Equal(t, proc.ID, killed[0].ID) + assert.NotEmpty(t, killed[0].Signal) + }) + + t.Run("registers process.signal task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "sleep", "60") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessSignal{ + ID: proc.ID, + Signal: syscall.SIGTERM, + }) + require.True(t, result.OK) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have been signalled through core") + } + + assert.Equal(t, StatusKilled, proc.Status) + }) + + t.Run("allows signal zero liveness checks", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + proc, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessSignal{ + ID: proc.ID, + Signal: syscall.Signal(0), + }) + require.True(t, result.OK) + + assert.True(t, proc.IsRunning()) + + cancel() + <-proc.Done() + }) + + t.Run("signal zero does not kill process groups", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.StartWithOptions(context.Background(), RunOptions{ + Command: "sh", + Args: []string{"-c", "sleep 60 & wait"}, + Detach: true, + KillGroup: true, + }) + require.NoError(t, err) + + result := c.PERFORM(TaskProcessSignal{ + ID: proc.ID, + Signal: syscall.Signal(0), + }) + require.True(t, result.OK) + + time.Sleep(300 * time.Millisecond) + assert.True(t, proc.IsRunning()) + + err = proc.Kill() + require.NoError(t, err) + <-proc.Done() + }) + + t.Run("registers process.wait task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "echo", "action-wait") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessWait{ID: proc.ID}) + require.True(t, result.OK) + + info, ok := result.Value.(Info) + require.True(t, ok) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 0, info.ExitCode) + }) + + t.Run("preserves final snapshot when process.wait task fails", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessWait{ID: proc.ID}) + require.True(t, result.OK) + + errValue, ok := result.Value.(error) + require.True(t, ok) + var waitErr *TaskProcessWaitError + require.ErrorAs(t, errValue, &waitErr) + assert.Contains(t, waitErr.Error(), "process exited with code 7") + assert.Equal(t, proc.ID, waitErr.Info.ID) + assert.Equal(t, StatusExited, waitErr.Info.Status) + assert.Equal(t, 7, waitErr.Info.ExitCode) + }) + + t.Run("registers process.list task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + proc, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessList{RunningOnly: true}) + require.True(t, result.OK) + + infos, ok := result.Value.([]Info) + require.True(t, ok) + require.Len(t, infos, 1) + assert.Equal(t, proc.ID, infos[0].ID) + assert.True(t, infos[0].Running) + + cancel() + <-proc.Done() + }) + + t.Run("registers process.get task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "echo", "snapshot") + require.NoError(t, err) + <-proc.Done() + + result := c.PERFORM(TaskProcessGet{ID: proc.ID}) + require.True(t, result.OK) + + info, ok := result.Value.(Info) + require.True(t, ok) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, proc.Command, info.Command) + assert.Equal(t, proc.Args, info.Args) + assert.Equal(t, proc.Status, info.Status) + assert.Equal(t, proc.ExitCode, info.ExitCode) + assert.Equal(t, proc.Info().PID, info.PID) + }) + + t.Run("registers process.remove task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "echo", "remove-through-core") + require.NoError(t, err) + <-proc.Done() + + result := c.PERFORM(TaskProcessRemove{ID: proc.ID}) + require.True(t, result.OK) + + _, err = svc.Get(proc.ID) + assert.ErrorIs(t, err, ErrProcessNotFound) + }) + + t.Run("registers process.clear task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + first, err := svc.Start(context.Background(), "echo", "clear-through-core-1") + require.NoError(t, err) + second, err := svc.Start(context.Background(), "echo", "clear-through-core-2") + require.NoError(t, err) + <-first.Done() + <-second.Done() + + require.Len(t, svc.List(), 2) + + result := c.PERFORM(TaskProcessClear{}) + require.True(t, result.OK) + assert.Len(t, svc.List(), 0) + }) + + t.Run("registers process.output task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "echo", "snapshot-output") + require.NoError(t, err) + <-proc.Done() + + result := c.PERFORM(TaskProcessOutput{ID: proc.ID}) + require.True(t, result.OK) + + output, ok := result.Value.(string) + require.True(t, ok) + assert.Contains(t, output, "snapshot-output") + }) + + t.Run("registers process.input task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessInput{ + ID: proc.ID, + Input: "typed-through-core\n", + }) + require.True(t, result.OK) + + err = proc.CloseStdin() + require.NoError(t, err) + + <-proc.Done() + + assert.Contains(t, proc.Output(), "typed-through-core") + }) + + t.Run("registers process.close_stdin task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "cat") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessInput{ + ID: proc.ID, + Input: "close-through-core\n", + }) + require.True(t, result.OK) + + result = c.PERFORM(TaskProcessCloseStdin{ID: proc.ID}) + require.True(t, result.OK) + + select { + case <-proc.Done(): + case <-time.After(2 * time.Second): + t.Fatal("process should have exited after stdin was closed") + } + + assert.Contains(t, proc.Output(), "close-through-core") + }) +} + +func TestService_RunWithOptions(t *testing.T) { t.Run("returns output on success", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.RunWithOptions(context.Background(), RunOptions{ + output, err := svc.RunWithOptions(context.Background(), RunOptions{ Command: "echo", Args: []string{"opts-test"}, }) - assert.True(t, r.OK) - assert.Contains(t, r.Value.(string), "opts-test") + require.NoError(t, err) + assert.Contains(t, output, "opts-test") }) - t.Run("returns !OK on failure", func(t *testing.T) { + t.Run("returns error on failure", func(t *testing.T) { svc, _ := newTestService(t) - r := svc.RunWithOptions(context.Background(), RunOptions{ + _, err := svc.RunWithOptions(context.Background(), RunOptions{ Command: "sh", Args: []string{"-c", "exit 2"}, }) - assert.False(t, r.OK) + assert.Error(t, err) + assert.Contains(t, err.Error(), "exited with code 2") + }) + + t.Run("rejects nil context", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.RunWithOptions(nil, RunOptions{ + Command: "echo", + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrContextRequired) }) } -func TestService_Running_Good(t *testing.T) { +func TestService_Running(t *testing.T) { t.Run("returns only running processes", func(t *testing.T) { svc, _ := newTestService(t) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - proc1 := startProc(t, svc, ctx, "sleep", "60") - proc2 := startProc(t, svc, context.Background(), "echo", "done") - <-proc2.Done() + proc1, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + + doneProc, err := svc.Start(context.Background(), "echo", "done") + require.NoError(t, err) + <-doneProc.Done() running := svc.Running() assert.Len(t, running, 1) assert.Equal(t, proc1.ID, running[0].ID) + proc2, err := svc.Start(ctx, "sleep", "60") + require.NoError(t, err) + + running = svc.Running() + assert.Len(t, running, 2) + assert.Equal(t, proc1.ID, running[0].ID) + assert.Equal(t, proc2.ID, running[1].ID) + cancel() <-proc1.Done() + <-proc2.Done() }) } diff --git a/types.go b/types.go index 822d858..a88547f 100644 --- a/types.go +++ b/types.go @@ -1,34 +1,49 @@ // Package process provides process management with Core IPC integration. // +// Example: +// +// svc := process.NewService(process.Options{}) +// proc, err := svc.Start(ctx, "echo", "hello") +// // The process package enables spawning, monitoring, and controlling external // processes with output streaming via the Core ACTION system. // // # Getting Started // -// c := core.New(core.WithService(process.Register)) -// _ = c.ServiceStartup(ctx, nil) +// // Register with Core +// core, _ := framework.New( +// framework.WithName("process", process.NewService(process.Options{})), +// ) // -// r := c.Process().Run(ctx, "go", "test", "./...") -// output := r.Value.(string) +// // Get service and run a process +// svc, err := framework.ServiceFor[*process.Service](core, "process") +// if err != nil { +// return err +// } +// proc, err := svc.Start(ctx, "go", "test", "./...") // // # Listening for Events // // Process events are broadcast via Core.ACTION: // -// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result { +// core.RegisterAction(func(c *framework.Core, msg framework.Message) error { // switch m := msg.(type) { // case process.ActionProcessOutput: // fmt.Print(m.Line) // case process.ActionProcessExited: // fmt.Printf("Exit code: %d\n", m.ExitCode) // } -// return core.Result{OK: true} +// return nil // }) package process import "time" // Status represents the process lifecycle state. +// +// Example: +// +// if proc.Status == process.StatusKilled { return } type Status string const ( @@ -45,6 +60,10 @@ const ( ) // Stream identifies the output source. +// +// Example: +// +// if event.Stream == process.StreamStdout { ... } type Stream string const ( @@ -55,6 +74,13 @@ const ( ) // RunOptions configures process execution. +// +// Example: +// +// opts := process.RunOptions{ +// Command: "go", +// Args: []string{"test", "./..."}, +// } type RunOptions struct { // Command is the executable to run. Command string @@ -85,8 +111,13 @@ type RunOptions struct { KillGroup bool } -// ProcessInfo provides a snapshot of process state without internal fields. -type ProcessInfo struct { +// Info provides a snapshot of process state without internal fields. +// +// Example: +// +// info := proc.Info() +// fmt.Println(info.PID) +type Info struct { ID string `json:"id"` Command string `json:"command"` Args []string `json:"args"` @@ -98,6 +129,3 @@ type ProcessInfo struct { Duration time.Duration `json:"duration"` PID int `json:"pid"` } - -// Info is kept as a compatibility alias for ProcessInfo. -type Info = ProcessInfo diff --git a/ui/src/process-list.ts b/ui/src/process-list.ts index b5528b7..3803a7b 100644 --- a/ui/src/process-list.ts +++ b/ui/src/process-list.ts @@ -3,7 +3,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { connectProcessEvents, type ProcessEvent } from './shared/events.js'; -import type { ProcessInfo } from './shared/api.js'; +import { ProcessApi, type ProcessInfo } from './shared/api.js'; /** * — Running processes with status and actions. @@ -14,9 +14,8 @@ import type { ProcessInfo } from './shared/api.js'; * Emits `process-selected` event when a process row is clicked, carrying * the process ID for the output viewer. * - * Note: Requires process-level REST endpoints (GET /processes, POST /processes/:id/kill) - * that are not yet in the provider. The element renders from WS events and local state - * until those endpoints are available. + * The list is seeded from the REST API and then kept in sync with the live + * process event stream when a WebSocket URL is configured. */ @customElement('core-process-list') export class ProcessList extends LitElement { @@ -193,11 +192,14 @@ export class ProcessList extends LitElement { @state() private loading = false; @state() private error = ''; @state() private connected = false; + @state() private killing = new Set(); + private api!: ProcessApi; private ws: WebSocket | null = null; connectedCallback() { super.connectedCallback(); + this.api = new ProcessApi(this.apiUrl); this.loadProcesses(); } @@ -207,24 +209,30 @@ export class ProcessList extends LitElement { } updated(changed: Map) { - if (changed.has('wsUrl')) { + if (changed.has('apiUrl')) { + this.api = new ProcessApi(this.apiUrl); + } + + if (changed.has('wsUrl') || changed.has('apiUrl')) { this.disconnect(); - this.processes = []; - this.loadProcesses(); + void this.loadProcesses(); } } async loadProcesses() { - // The process list is built from the shared process event stream. + this.loading = true; this.error = ''; - this.loading = false; - - if (!this.wsUrl) { + try { + this.processes = await this.api.listProcesses(); + if (this.wsUrl) { + this.connect(); + } + } catch (e: any) { + this.error = e.message ?? 'Failed to load processes'; this.processes = []; - return; + } finally { + this.loading = false; } - - this.connect(); } private handleSelect(proc: ProcessInfo) { @@ -237,21 +245,25 @@ export class ProcessList extends LitElement { ); } - private formatUptime(started: string): string { + private async handleKill(proc: ProcessInfo) { + this.killing = new Set([...this.killing, proc.id]); try { - const ms = Date.now() - new Date(started).getTime(); - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m ${seconds % 60}s`; - const hours = Math.floor(minutes / 60); - return `${hours}h ${minutes % 60}m`; - } catch { - return 'unknown'; + await this.api.killProcess(proc.id); + await this.loadProcesses(); + } catch (e: any) { + this.error = e.message ?? 'Failed to kill process'; + } finally { + const next = new Set(this.killing); + next.delete(proc.id); + this.killing = next; } } private connect() { + if (!this.wsUrl || this.ws) { + return; + } + this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => { this.applyEvent(event); }); @@ -274,10 +286,7 @@ export class ProcessList extends LitElement { private applyEvent(event: ProcessEvent) { const channel = event.channel ?? event.type ?? ''; - const data = (event.data ?? {}) as Partial & { - id?: string; - signal?: string; - }; + const data = (event.data ?? {}) as Partial & { id?: string }; if (!data.id) { return; @@ -286,36 +295,36 @@ export class ProcessList extends LitElement { const next = new Map(this.processes.map((proc) => [proc.id, proc] as const)); const current = next.get(data.id); - if (channel === 'process.started') { - next.set(data.id, this.normalizeProcess(data, current, 'running')); - this.processes = this.sortProcesses(next); - return; + switch (channel) { + case 'process.started': + next.set(data.id, this.normalizeProcess(data, current, 'running')); + break; + case 'process.exited': + next.set(data.id, this.normalizeProcess(data, current, data.exitCode === -1 && data.error ? 'failed' : 'exited')); + break; + case 'process.killed': + next.set(data.id, this.normalizeProcess(data, current, 'killed')); + break; + default: + return; } - if (channel === 'process.exited') { - next.set(data.id, this.normalizeProcess(data, current, 'exited')); - this.processes = this.sortProcesses(next); - return; - } - - if (channel === 'process.killed') { - next.set(data.id, this.normalizeProcess(data, current, 'killed')); - this.processes = this.sortProcesses(next); - return; - } + this.processes = this.sortProcesses(next); } private normalizeProcess( - data: Partial & { id: string; signal?: string }, + data: Partial & { id: string; error?: unknown }, current: ProcessInfo | undefined, status: ProcessInfo['status'], ): ProcessInfo { + const startedAt = data.startedAt ?? current?.startedAt ?? new Date().toISOString(); return { id: data.id, command: data.command ?? current?.command ?? '', args: data.args ?? current?.args ?? [], dir: data.dir ?? current?.dir ?? '', - startedAt: data.startedAt ?? current?.startedAt ?? new Date().toISOString(), + startedAt, + running: status === 'running', status, exitCode: data.exitCode ?? current?.exitCode ?? (status === 'killed' ? -1 : 0), duration: data.duration ?? current?.duration ?? 0, @@ -324,9 +333,28 @@ export class ProcessList extends LitElement { } private sortProcesses(processes: Map): ProcessInfo[] { - return [...processes.values()].sort( - (a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(), - ); + return [...processes.values()].sort((a, b) => { + const aStarted = new Date(a.startedAt).getTime(); + const bStarted = new Date(b.startedAt).getTime(); + if (aStarted === bStarted) { + return a.id.localeCompare(b.id); + } + return aStarted - bStarted; + }); + } + + private formatUptime(started: string): string { + try { + const ms = Date.now() - new Date(started).getTime(); + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ${seconds % 60}s`; + const hours = Math.floor(minutes / 60); + return `${hours}h ${minutes % 60}m`; + } catch { + return 'unknown'; + } } render() { @@ -341,9 +369,9 @@ export class ProcessList extends LitElement {
${this.wsUrl ? this.connected - ? 'Waiting for process events from the WebSocket feed.' + ? 'Receiving live process updates.' : 'Connecting to the process event stream...' - : 'Set a WebSocket URL to receive live process events.'} + : 'Managed processes are loaded from the process REST API.'}
No managed processes.
` @@ -379,12 +407,13 @@ export class ProcessList extends LitElement {
` diff --git a/ui/src/process-output.ts b/ui/src/process-output.ts index e16fcbc..91948bb 100644 --- a/ui/src/process-output.ts +++ b/ui/src/process-output.ts @@ -3,6 +3,7 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { connectProcessEvents, type ProcessEvent } from './shared/events.js'; +import { ProcessApi } from './shared/api.js'; interface OutputLine { text: string; @@ -131,14 +132,15 @@ export class ProcessOutput extends LitElement { @state() private lines: OutputLine[] = []; @state() private autoScroll = true; @state() private connected = false; + @state() private loadingSnapshot = false; private ws: WebSocket | null = null; + private api = new ProcessApi(this.apiUrl); + private syncToken = 0; connectedCallback() { super.connectedCallback(); - if (this.wsUrl && this.processId) { - this.connect(); - } + this.syncSources(); } disconnectedCallback() { @@ -147,12 +149,12 @@ export class ProcessOutput extends LitElement { } updated(changed: Map) { - if (changed.has('processId') || changed.has('wsUrl')) { - this.disconnect(); - this.lines = []; - if (this.wsUrl && this.processId) { - this.connect(); - } + if (changed.has('apiUrl')) { + this.api = new ProcessApi(this.apiUrl); + } + + if (changed.has('processId') || changed.has('wsUrl') || changed.has('apiUrl')) { + this.syncSources(); } if (this.autoScroll) { @@ -160,6 +162,66 @@ export class ProcessOutput extends LitElement { } } + private syncSources() { + this.disconnect(); + this.lines = []; + if (!this.processId) { + return; + } + + void this.loadSnapshotAndConnect(); + } + + private async loadSnapshotAndConnect() { + const token = ++this.syncToken; + + if (!this.processId) { + return; + } + + if (this.apiUrl) { + this.loadingSnapshot = true; + try { + const output = await this.api.getProcessOutput(this.processId); + if (token !== this.syncToken) { + return; + } + const snapshot = this.linesFromOutput(output); + if (snapshot.length > 0) { + this.lines = snapshot; + } + } catch { + // Ignore missing snapshot data and continue with live streaming. + } finally { + if (token === this.syncToken) { + this.loadingSnapshot = false; + } + } + } + + if (token === this.syncToken && this.wsUrl) { + this.connect(); + } + } + + private linesFromOutput(output: string): OutputLine[] { + if (!output) { + return []; + } + + const normalized = output.replace(/\r\n/g, '\n'); + const parts = normalized.split('\n'); + if (parts.length > 0 && parts[parts.length - 1] === '') { + parts.pop(); + } + + return parts.map((text) => ({ + text, + stream: 'stdout' as const, + timestamp: Date.now(), + })); + } + private connect() { this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => { const data = event.data; @@ -231,7 +293,9 @@ export class ProcessOutput extends LitElement {
- ${this.lines.length === 0 + ${this.loadingSnapshot && this.lines.length === 0 + ? html`
Loading snapshot\u2026
` + : this.lines.length === 0 ? html`
Waiting for output\u2026
` : this.lines.map( (line) => html` diff --git a/ui/src/process-runner.ts b/ui/src/process-runner.ts index e824eef..bc3fa71 100644 --- a/ui/src/process-runner.ts +++ b/ui/src/process-runner.ts @@ -9,10 +9,6 @@ import type { RunResult, RunAllResult } from './shared/api.js'; * * Shows RunSpec execution results with pass/fail/skip badges, duration, * dependency chains, and aggregate summary. - * - * Note: Pipeline runner REST endpoints are not yet in the provider. - * This element renders from WS events and accepts data via properties - * until those endpoints are available. */ @customElement('core-process-runner') export class ProcessRunner extends LitElement { @@ -223,8 +219,9 @@ export class ProcessRunner extends LitElement { } async loadResults() { - // Pipeline runner REST endpoints are not yet available. - // Results can be passed in via the `result` property. + // Results are supplied via the `result` property. The REST API can be + // used by the surrounding application to execute a pipeline and then + // assign the returned data here. } private toggleOutput(name: string) { @@ -253,9 +250,7 @@ export class ProcessRunner extends LitElement { if (!this.result) { return html`
- Pipeline runner endpoints are pending. Pass pipeline results via the - result property, or results will appear here once the REST - API for pipeline execution is available. + Pass pipeline results via the result property.
No pipeline results.
`; diff --git a/ui/src/shared/api.ts b/ui/src/shared/api.ts index bd74a09..08021c3 100644 --- a/ui/src/shared/api.ts +++ b/ui/src/shared/api.ts @@ -31,12 +31,26 @@ export interface ProcessInfo { args: string[]; dir: string; startedAt: string; + running: boolean; status: 'pending' | 'running' | 'exited' | 'failed' | 'killed'; exitCode: number; duration: number; pid: number; } +/** + * RunSpec payload for pipeline execution. + */ +export interface RunSpec { + name: string; + command: string; + args?: string[]; + dir?: string; + env?: string[]; + after?: string[]; + allowFailure?: boolean; +} + /** * Pipeline run result for a single spec. */ @@ -62,6 +76,21 @@ export interface RunAllResult { success: boolean; } +/** + * Process start and run payload shared by the control endpoints. + */ +export interface ProcessControlRequest { + command: string; + args?: string[]; + dir?: string; + env?: string[]; + disableCapture?: boolean; + detach?: boolean; + timeout?: number; + gracePeriod?: number; + killGroup?: boolean; +} + /** * ProcessApi provides a typed fetch wrapper for the /api/process/* endpoints. */ @@ -102,4 +131,86 @@ export class ProcessApi { healthCheck(code: string, daemon: string): Promise { return this.request(`/daemons/${code}/${daemon}/health`); } + + /** List all managed processes. */ + listProcesses(runningOnly = false): Promise { + const query = runningOnly ? '?runningOnly=true' : ''; + return this.request(`/processes${query}`); + } + + /** Get a single managed process by ID. */ + getProcess(id: string): Promise { + return this.request(`/processes/${id}`); + } + + /** Get the captured stdout/stderr for a managed process by ID. */ + getProcessOutput(id: string): Promise { + return this.request(`/processes/${id}/output`); + } + + /** Start a managed process asynchronously. */ + startProcess(opts: ProcessControlRequest): Promise { + return this.request('/processes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(opts), + }); + } + + /** Run a managed process synchronously and return its combined output. */ + runProcess(opts: ProcessControlRequest): Promise { + return this.request('/processes/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(opts), + }); + } + + /** Wait for a managed process to exit and return its final snapshot. */ + waitProcess(id: string): Promise { + return this.request(`/processes/${id}/wait`, { + method: 'POST', + }); + } + + /** Write input to a managed process stdin pipe. */ + inputProcess(id: string, input: string): Promise<{ written: boolean }> { + return this.request<{ written: boolean }>(`/processes/${id}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input }), + }); + } + + /** Close a managed process stdin pipe. */ + closeProcessStdin(id: string): Promise<{ closed: boolean }> { + return this.request<{ closed: boolean }>(`/processes/${id}/close-stdin`, { + method: 'POST', + }); + } + + /** Kill a managed process by ID. */ + killProcess(id: string): Promise<{ killed: boolean }> { + return this.request<{ killed: boolean }>(`/processes/${id}/kill`, { + method: 'POST', + }); + } + + /** Send a signal to a managed process by ID. */ + signalProcess(id: string, signal: string | number): Promise<{ signalled: boolean }> { + return this.request<{ signalled: boolean }>(`/processes/${id}/signal`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ signal: String(signal) }), + }); + } + + /** Run a process pipeline using the configured runner. */ + runPipeline(mode: 'all' | 'sequential' | 'parallel', specs: RunSpec[]): Promise { + return this.request('/pipelines/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode, specs }), + }); + } }