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 ? "" : "")), 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 {
${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 {
`
@@ -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.'}
`
: 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 }),
+ });
+ }
}