Compare commits
98 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e536f1a7c | ||
|
|
3dd65af0a5 | ||
|
|
a7cde26b9b | ||
|
|
56bc171add | ||
|
|
f9537fb24d | ||
|
|
cf9291d095 | ||
|
|
720104babc | ||
|
|
bc2cb6ae9d | ||
|
|
f4da274ce6 | ||
|
|
b74ee080a2 | ||
|
|
429675ca29 | ||
|
|
588f4e173b | ||
|
|
3ac213a058 | ||
|
|
e1f5b0ff40 | ||
|
|
ac5a938b70 | ||
|
|
1398c4b8ea | ||
|
|
2461466f55 | ||
|
|
208dac3c82 | ||
|
|
8d8267543d | ||
|
|
9b3dd1ec49 | ||
|
|
040500f3e1 | ||
|
|
c7542939c7 | ||
|
|
dcf20c78c8 | ||
|
|
f717fc66c3 | ||
|
|
dec0231938 | ||
|
|
3930aed49a | ||
|
|
8d1a0d0655 | ||
|
|
85cd6dd7c8 | ||
|
|
79e2ffa6ed | ||
|
|
04543700bc | ||
|
|
86f5fadff7 | ||
|
|
c31f3faa2b | ||
|
|
e85abe1ee6 | ||
|
|
4974b0fd08 | ||
|
|
c9deb8fdfd | ||
|
|
f43e8a6e38 | ||
|
|
02e2b3611c | ||
|
|
d34ab22ad3 | ||
|
|
a8c193d07c | ||
|
|
155f216a7c | ||
|
|
227739638b | ||
|
|
ceea10fc7a | ||
|
|
6c1d53a237 | ||
|
|
cffe06631b | ||
|
|
ec2a6838b8 | ||
|
|
98fe626d8e | ||
|
|
87da81ffeb | ||
|
|
26af69d87b | ||
|
|
38a9f034a7 | ||
|
|
73b0ffecc0 | ||
|
|
6f35954ac2 | ||
|
|
66d5b0a15e | ||
|
|
945e760542 | ||
|
|
b097e0ef0e | ||
|
|
911abb6ee8 | ||
|
|
c5adc8066e | ||
|
|
4b1013a023 | ||
|
|
1028e31ae5 | ||
|
|
ba4b0f1166 | ||
|
|
2e5ac4208b | ||
|
|
ab02432543 | ||
|
|
498137fa8e | ||
|
|
16e5c57fd4 | ||
|
|
82e85a99fd | ||
|
|
31be7280a6 | ||
|
|
1ccc61848b | ||
|
|
90ce26a1b7 | ||
|
|
eb6a7819e7 | ||
|
|
dfa97f2112 | ||
|
|
0e299e5349 | ||
|
|
d565e3539e | ||
|
|
686f1053b3 | ||
|
|
2255ade57e | ||
|
|
cdc8bfe502 | ||
|
|
eeca66240a | ||
|
|
24f853631d | ||
|
|
ce2a4db6cb | ||
|
|
f98bbad5ac | ||
|
|
fa79e4eee7 | ||
|
|
214cf4cfa8 | ||
|
|
5142114e89 | ||
|
|
84d07daf19 | ||
|
|
2bc6eb70d7 | ||
|
|
f5a940facd | ||
|
|
9b536f08c6 | ||
|
|
e58f376e4c | ||
|
|
252f68db64 | ||
|
|
1b7431e3a0 | ||
|
|
6fda03d64d | ||
|
|
9457694e46 | ||
|
|
dcf058047e | ||
|
|
f70e301631 | ||
|
|
87bebd7fa6 | ||
|
|
62e7bd7814 | ||
|
|
b6530cf85d | ||
|
|
0546b42ce3 | ||
| 206b9a1f52 | |||
|
|
61867e56bb |
36 changed files with 5736 additions and 457 deletions
201
actions.go
201
actions.go
|
|
@ -1,10 +1,195 @@
|
|||
package process
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- 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
|
||||
|
|
@ -15,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
|
||||
|
|
@ -23,14 +212,22 @@ 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
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ type RingBuffer struct {
|
|||
|
||||
// NewRingBuffer creates a ring buffer with the given capacity.
|
||||
func NewRingBuffer(size int) *RingBuffer {
|
||||
if size < 0 {
|
||||
size = 0
|
||||
}
|
||||
return &RingBuffer{
|
||||
data: make([]byte, size),
|
||||
size: size,
|
||||
|
|
@ -26,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
|
||||
|
|
|
|||
|
|
@ -69,4 +69,18 @@ func TestRingBuffer(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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
86
daemon.go
86
daemon.go
|
|
@ -11,6 +11,13 @@ import (
|
|||
)
|
||||
|
||||
// DaemonOptions configures daemon mode execution.
|
||||
//
|
||||
// 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,7 +39,7 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +53,10 @@ type Daemon struct {
|
|||
}
|
||||
|
||||
// NewDaemon creates a daemon runner with the given options.
|
||||
//
|
||||
// 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
|
||||
|
|
@ -68,6 +79,10 @@ 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()
|
||||
|
|
@ -91,8 +106,6 @@ func (d *Daemon) Start() error {
|
|||
}
|
||||
}
|
||||
|
||||
d.running = true
|
||||
|
||||
// Auto-register if registry is set
|
||||
if d.opts.Registry != nil {
|
||||
entry := d.opts.RegistryEntry
|
||||
|
|
@ -100,16 +113,41 @@ func (d *Daemon) Start() error {
|
|||
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 {
|
||||
_ = d.health.Stop(context.Background())
|
||||
}
|
||||
if d.pid != nil {
|
||||
_ = d.pid.Release()
|
||||
}
|
||||
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()
|
||||
|
|
@ -123,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()
|
||||
|
|
@ -136,8 +178,12 @@ 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, coreerr.E("Daemon.Stop", "health server", err))
|
||||
}
|
||||
|
|
@ -149,9 +195,12 @@ func (d *Daemon) Stop() error {
|
|||
}
|
||||
}
|
||||
|
||||
// Auto-unregister
|
||||
// Auto-unregister after the daemon has stopped serving traffic and
|
||||
// relinquished its PID file.
|
||||
if d.opts.Registry != nil {
|
||||
_ = d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon)
|
||||
if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil {
|
||||
errs = append(errs, coreerr.E("Daemon.Stop", "registry", err))
|
||||
}
|
||||
}
|
||||
|
||||
d.running = false
|
||||
|
|
@ -162,17 +211,42 @@ func (d *Daemon) Stop() error {
|
|||
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)
|
||||
|
|
|
|||
220
daemon_test.go
220
daemon_test.go
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -36,6 +37,165 @@ func TestDaemon_StartAndStop(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
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",
|
||||
|
|
@ -61,6 +221,14 @@ func TestDaemon_RunWithoutStartFails(t *testing.T) {
|
|||
assert.Contains(t, err.Error(), "not started")
|
||||
}
|
||||
|
||||
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,14 +243,21 @@ func TestDaemon_SetReady(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_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())
|
||||
|
|
@ -137,6 +312,10 @@ func TestDaemon_StopIdempotent(t *testing.T) {
|
|||
func TestDaemon_AutoRegisters(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
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_AutoRegisters(t *testing.T) {
|
|||
},
|
||||
})
|
||||
|
||||
err := d.Start()
|
||||
err = d.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be registered
|
||||
|
|
@ -155,6 +334,8 @@ func TestDaemon_AutoRegisters(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_AutoRegisters(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())
|
||||
}
|
||||
|
|
|
|||
12
errors.go
Normal file
12
errors.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
15
errors_test.go
Normal file
15
errors_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
148
exec/exec.go
148
exec/exec.go
|
|
@ -4,26 +4,33 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
goio "io"
|
||||
)
|
||||
|
||||
// Options configuration for command execution
|
||||
// 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.
|
||||
type Options struct {
|
||||
Dir string
|
||||
Env []string
|
||||
Stdin io.Reader
|
||||
Stdout io.Writer
|
||||
Stderr io.Writer
|
||||
// If true, command will run in background (not implemented in this wrapper yet)
|
||||
// Background bool
|
||||
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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// cmd := exec.Command(ctx, "go", "test", "./...")
|
||||
func Command(ctx context.Context, name string, args ...string) *Cmd {
|
||||
return &Cmd{
|
||||
name: name,
|
||||
|
|
@ -32,7 +39,7 @@ func Command(ctx context.Context, name string, args ...string) *Cmd {
|
|||
}
|
||||
}
|
||||
|
||||
// Cmd represents a wrapped command
|
||||
// Cmd represents a wrapped command.
|
||||
type Cmd struct {
|
||||
name string
|
||||
args []string
|
||||
|
|
@ -42,32 +49,52 @@ type Cmd struct {
|
|||
logger Logger
|
||||
}
|
||||
|
||||
// WithDir sets the working directory
|
||||
// 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
|
||||
// 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 {
|
||||
// WithStdin sets stdin.
|
||||
//
|
||||
// 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 {
|
||||
// WithStdout sets stdout.
|
||||
//
|
||||
// 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 {
|
||||
// WithStderr sets stderr.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// cmd.WithStderr(os.Stderr)
|
||||
func (c *Cmd) WithStderr(w goio.Writer) *Cmd {
|
||||
c.opts.Stderr = w
|
||||
return c
|
||||
}
|
||||
|
|
@ -79,10 +106,52 @@ 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 {
|
||||
|
|
@ -94,8 +163,18 @@ 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()
|
||||
|
|
@ -108,8 +187,18 @@ 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()
|
||||
|
|
@ -121,17 +210,13 @@ func (c *Cmd) CombinedOutput() ([]byte, error) {
|
|||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Cmd) prepare() {
|
||||
if c.ctx != nil {
|
||||
c.cmd = exec.CommandContext(c.ctx, c.name, c.args...)
|
||||
} else {
|
||||
// Should we enforce context? The issue says "Enforce context usage".
|
||||
// For now, let's allow nil but log a warning if we had a logger?
|
||||
// Or strictly panic/error?
|
||||
// Let's fallback to Background for now but maybe strict later.
|
||||
c.cmd = exec.Command(c.name, c.args...)
|
||||
func (c *Cmd) prepare() error {
|
||||
if c.ctx == nil {
|
||||
return coreerr.E("Cmd.prepare", "exec: command context is required", ErrCommandContextRequired)
|
||||
}
|
||||
|
||||
c.cmd = exec.CommandContext(c.ctx, c.name, c.args...)
|
||||
|
||||
c.cmd.Dir = c.opts.Dir
|
||||
if len(c.opts.Env) > 0 {
|
||||
c.cmd.Env = append(os.Environ(), c.opts.Env...)
|
||||
|
|
@ -140,10 +225,15 @@ 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.
|
||||
//
|
||||
// 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)
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@ package exec_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/process/exec"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockLogger captures log calls for testing
|
||||
|
|
@ -132,6 +139,29 @@ func TestSetDefaultLogger(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)
|
||||
|
|
@ -195,6 +225,75 @@ func TestCommand_WithStdinStdoutStderr(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")
|
||||
|
|
|
|||
|
|
@ -1,11 +1,19 @@
|
|||
package exec
|
||||
|
||||
import "sync"
|
||||
|
||||
// Logger interface for command execution logging.
|
||||
// Compatible with pkg/log.Logger and other structured loggers.
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -18,11 +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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// exec.SetDefaultLogger(logger)
|
||||
func SetDefaultLogger(l Logger) {
|
||||
defaultLoggerMu.Lock()
|
||||
defer defaultLoggerMu.Unlock()
|
||||
|
||||
if l == nil {
|
||||
l = NopLogger{}
|
||||
}
|
||||
|
|
@ -30,6 +50,13 @@ func SetDefaultLogger(l Logger) {
|
|||
}
|
||||
|
||||
// DefaultLogger returns the current default logger.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// logger := exec.DefaultLogger()
|
||||
func DefaultLogger() Logger {
|
||||
defaultLoggerMu.RLock()
|
||||
defer defaultLoggerMu.RUnlock()
|
||||
|
||||
return defaultLogger
|
||||
}
|
||||
|
|
|
|||
189
global_test.go
189
global_test.go
|
|
@ -2,8 +2,11 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
framework "dappco.re/go/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -30,12 +33,30 @@ func TestGlobal_DefaultNotInitialized(t *testing.T) {
|
|||
_, 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)
|
||||
|
||||
|
|
@ -74,6 +95,19 @@ func TestGlobal_SetDefault(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
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() {
|
||||
|
|
@ -239,6 +273,131 @@ func TestGlobal_RunWithOptions(t *testing.T) {
|
|||
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)
|
||||
|
||||
|
|
@ -265,3 +424,33 @@ func TestGlobal_Running(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
|
|
|
|||
20
go.mod
20
go.mod
|
|
@ -3,17 +3,17 @@ module dappco.re/go/core/process
|
|||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.4.7
|
||||
dappco.re/go/core/io v0.1.7
|
||||
dappco.re/go/core/log v0.0.4
|
||||
dappco.re/go/core/ws v0.2.4
|
||||
forge.lthn.ai/core/api v0.1.5
|
||||
dappco.re/go/core v0.5.0
|
||||
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
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go-io v0.1.5 // indirect
|
||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
github.com/99designs/gqlgen v0.17.88 // indirect
|
||||
github.com/KyleBanks/depth 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
|
||||
|
|
@ -108,10 +107,3 @@ require (
|
|||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace (
|
||||
dappco.re/go/core => ../go
|
||||
dappco.re/go/core/io => ../go-io
|
||||
dappco.re/go/core/log => ../go-log
|
||||
dappco.re/go/core/ws => ../go-ws
|
||||
)
|
||||
|
|
|
|||
14
go.sum
14
go.sum
|
|
@ -1,7 +1,13 @@
|
|||
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=
|
||||
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
|
||||
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/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/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=
|
||||
|
|
|
|||
182
health.go
182
health.go
|
|
@ -3,28 +3,34 @@ package process
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// HealthCheck is a function that returns nil if healthy.
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// server := process.NewHealthServer("127.0.0.1:0")
|
||||
func NewHealthServer(addr string) *HealthServer {
|
||||
return &HealthServer{
|
||||
addr: addr,
|
||||
|
|
@ -33,29 +39,55 @@ 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)
|
||||
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||
|
|
@ -68,9 +100,9 @@ func (h *HealthServer) Start() error {
|
|||
})
|
||||
|
||||
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)
|
||||
|
|
@ -87,50 +119,160 @@ func (h *HealthServer) Start() error {
|
|||
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 = &http.Server{Handler: mux}
|
||||
h.server = server
|
||||
h.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
_ = h.server.Serve(listener)
|
||||
_ = server.Serve(listener)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the health server.
|
||||
func (h *HealthServer) Stop(ctx context.Context) error {
|
||||
if h.server == 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
|
||||
}
|
||||
return h.server.Shutdown(ctx)
|
||||
|
||||
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.
|
||||
//
|
||||
// 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 := 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
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(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,6 +38,15 @@ func TestHealthServer_Endpoints(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
|
|
@ -66,6 +77,35 @@ func TestHealthServer_WithChecks(t *testing.T) {
|
|||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestHealthServer_NilCheckIgnored(t *testing.T) {
|
||||
hs := NewHealthServer("127.0.0.1:0")
|
||||
|
||||
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 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())
|
||||
|
|
@ -79,3 +119,30 @@ 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())
|
||||
}
|
||||
|
|
|
|||
24
pidfile.go
24
pidfile.go
|
|
@ -14,18 +14,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()
|
||||
|
|
@ -57,6 +69,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()
|
||||
|
|
@ -67,6 +83,10 @@ func (p *PIDFile) Release() error {
|
|||
}
|
||||
|
||||
// Path returns the PID file path.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// path := pidFile.Path()
|
||||
func (p *PIDFile) Path() string {
|
||||
return p.path
|
||||
}
|
||||
|
|
@ -74,6 +94,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 {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,19 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/api"
|
||||
"forge.lthn.ai/core/api/pkg/provider"
|
||||
"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"
|
||||
"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.
|
||||
|
|
@ -140,13 +168,240 @@ 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",
|
||||
"properties": map[string]any{
|
||||
"healthy": map[string]any{"type": "boolean"},
|
||||
"address": map[string]any{"type": "string"},
|
||||
"reason": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -164,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))
|
||||
}
|
||||
|
||||
|
|
@ -176,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))
|
||||
}
|
||||
|
||||
|
|
@ -232,18 +491,22 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
healthy := process.WaitForHealth(entry.Health, 2000)
|
||||
healthy, reason := process.ProbeHealth(entry.Health, 2000)
|
||||
|
||||
result := map[string]any{
|
||||
"healthy": healthy,
|
||||
"address": entry.Health,
|
||||
}
|
||||
if !healthy && reason != "" {
|
||||
result["reason"] = reason
|
||||
}
|
||||
|
||||
// Emit health event
|
||||
p.emitEvent("process.daemon.health", map[string]any{
|
||||
"code": code,
|
||||
"daemon": daemon,
|
||||
"healthy": healthy,
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
statusCode := http.StatusOK
|
||||
|
|
@ -253,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
|
||||
|
|
@ -282,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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,24 @@
|
|||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
goapi "forge.lthn.ai/core/api"
|
||||
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"
|
||||
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 {
|
||||
|
|
@ -50,13 +59,26 @@ func TestProcessProvider_Describe_Good(t *testing.T) {
|
|||
assert.NotEmpty(t, d.Summary)
|
||||
assert.NotEmpty(t, d.Tags)
|
||||
}
|
||||
|
||||
foundPipelineRoute := false
|
||||
foundSignalRoute := false
|
||||
for _, d := range descs {
|
||||
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()
|
||||
|
|
@ -71,10 +93,52 @@ func TestProcessProvider_ListDaemons_Good(t *testing.T) {
|
|||
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()
|
||||
|
|
@ -84,8 +148,45 @@ func TestProcessProvider_GetDaemon_Bad(t *testing.T) {
|
|||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
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: "broken",
|
||||
PID: os.Getpid(),
|
||||
Health: hostPort,
|
||||
}))
|
||||
|
||||
p := processapi.NewProvider(registry, nil, nil)
|
||||
|
||||
r := setupRouter(p)
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/process/daemons/test/broken/health", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
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)
|
||||
|
|
@ -96,7 +197,7 @@ func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProcessProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) {
|
||||
p := processapi.NewProvider(nil, nil)
|
||||
p := processapi.NewProvider(nil, nil, nil)
|
||||
|
||||
engine, err := goapi.New()
|
||||
require.NoError(t, err)
|
||||
|
|
@ -108,6 +209,600 @@ func TestProcessProvider_Channels_RegisterAsStreamGroup_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 {
|
||||
|
|
@ -121,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
|
||||
}
|
||||
|
|
|
|||
591
pkg/api/ui/dist/core-process.js
vendored
591
pkg/api/ui/dist/core-process.js
vendored
File diff suppressed because it is too large
Load diff
192
process.go
192
process.go
|
|
@ -3,7 +3,6 @@ package process
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
|
@ -11,10 +10,15 @@ import (
|
|||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
goio "io"
|
||||
)
|
||||
|
||||
// Process represents a managed external process.
|
||||
type Process struct {
|
||||
// ManagedProcess represents a managed external process.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// proc, err := svc.Start(ctx, "echo", "hello")
|
||||
type ManagedProcess struct {
|
||||
ID string
|
||||
Command string
|
||||
Args []string
|
||||
|
|
@ -25,19 +29,28 @@ type Process struct {
|
|||
ExitCode int
|
||||
Duration time.Duration
|
||||
|
||||
cmd *exec.Cmd
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
output *RingBuffer
|
||||
stdin io.WriteCloser
|
||||
done chan struct{}
|
||||
mu sync.RWMutex
|
||||
gracePeriod time.Duration
|
||||
killGroup 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 an alias for ManagedProcess for compatibility.
|
||||
type Process = ManagedProcess
|
||||
|
||||
// Info returns a snapshot of process state.
|
||||
func (p *Process) Info() Info {
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// info := proc.Info()
|
||||
func (p *ManagedProcess) Info() Info {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
|
|
@ -46,21 +59,31 @@ func (p *Process) Info() Info {
|
|||
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: p.Args,
|
||||
Args: append([]string(nil), p.Args...),
|
||||
Dir: p.Dir,
|
||||
StartedAt: p.StartedAt,
|
||||
Running: p.Status == StatusRunning,
|
||||
Status: p.Status,
|
||||
ExitCode: p.ExitCode,
|
||||
Duration: p.Duration,
|
||||
Duration: duration,
|
||||
PID: pid,
|
||||
}
|
||||
}
|
||||
|
||||
// Output returns the captured output as a string.
|
||||
func (p *Process) Output() string {
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// fmt.Println(proc.Output())
|
||||
func (p *ManagedProcess) Output() string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.output == nil {
|
||||
|
|
@ -70,7 +93,11 @@ func (p *Process) Output() string {
|
|||
}
|
||||
|
||||
// OutputBytes returns the captured output as bytes.
|
||||
func (p *Process) OutputBytes() []byte {
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// data := proc.OutputBytes()
|
||||
func (p *ManagedProcess) OutputBytes() []byte {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if p.output == nil {
|
||||
|
|
@ -80,14 +107,18 @@ func (p *Process) OutputBytes() []byte {
|
|||
}
|
||||
|
||||
// IsRunning returns true if the process is still executing.
|
||||
func (p *Process) IsRunning() bool {
|
||||
func (p *ManagedProcess) IsRunning() bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.Status == StatusRunning
|
||||
}
|
||||
|
||||
// Wait blocks until the process exits.
|
||||
func (p *Process) Wait() error {
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if err := proc.Wait(); err != nil { return err }
|
||||
func (p *ManagedProcess) Wait() error {
|
||||
<-p.done
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
|
@ -104,35 +135,69 @@ func (p *Process) Wait() error {
|
|||
}
|
||||
|
||||
// Done returns a channel that closes when the process exits.
|
||||
func (p *Process) Done() <-chan struct{} {
|
||||
//
|
||||
// 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.
|
||||
func (p *Process) Kill() error {
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
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.
|
||||
func (p *Process) Shutdown() error {
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// _ = proc.Shutdown()
|
||||
func (p *ManagedProcess) Shutdown() error {
|
||||
p.mu.RLock()
|
||||
grace := p.gracePeriod
|
||||
p.mu.RUnlock()
|
||||
|
|
@ -156,7 +221,7 @@ func (p *Process) Shutdown() error {
|
|||
}
|
||||
|
||||
// terminate sends SIGTERM to the process (or process group if KillGroup is set).
|
||||
func (p *Process) terminate() error {
|
||||
func (p *ManagedProcess) terminate() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
|
|
@ -176,23 +241,76 @@ func (p *Process) terminate() error {
|
|||
}
|
||||
|
||||
// Signal sends a signal to the process.
|
||||
func (p *Process) Signal(sig os.Signal) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
//
|
||||
// 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 p.Status != StatusRunning {
|
||||
if status != StatusRunning {
|
||||
return ErrProcessNotRunning
|
||||
}
|
||||
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.cmd.Process.Signal(sig)
|
||||
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.
|
||||
func (p *Process) SendInput(input string) error {
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// _ = proc.SendInput("hello\n")
|
||||
func (p *ManagedProcess) SendInput(input string) error {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
|
|
@ -209,7 +327,11 @@ func (p *Process) SendInput(input string) error {
|
|||
}
|
||||
|
||||
// CloseStdin closes the process stdin pipe.
|
||||
func (p *Process) CloseStdin() error {
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// _ = proc.CloseStdin()
|
||||
func (p *ManagedProcess) CloseStdin() error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
|
|
@ -9,7 +10,7 @@ import (
|
|||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// Global default service (follows i18n pattern).
|
||||
// Global default service used by package-level helpers.
|
||||
var (
|
||||
defaultService atomic.Pointer[Service]
|
||||
defaultOnce sync.Once
|
||||
|
|
@ -17,13 +18,21 @@ var (
|
|||
)
|
||||
|
||||
// Default returns the global process service.
|
||||
// Returns nil if not initialized.
|
||||
// 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
|
||||
|
|
@ -34,6 +43,10 @@ func SetDefault(s *Service) error {
|
|||
|
||||
// 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{})
|
||||
|
|
@ -47,9 +60,27 @@ func Init(c *core.Core) error {
|
|||
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 {
|
||||
|
|
@ -59,6 +90,10 @@ func Start(ctx context.Context, command string, args ...string) (*Process, error
|
|||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -68,6 +103,10 @@ func Run(ctx context.Context, command string, args ...string) (string, error) {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -76,7 +115,63 @@ func Get(id string) (*Process, error) {
|
|||
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 {
|
||||
|
|
@ -86,6 +181,10 @@ func List() []*Process {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -94,7 +193,50 @@ func Kill(id string) error {
|
|||
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 {
|
||||
|
|
@ -104,6 +246,10 @@ func StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -113,6 +259,10 @@ func RunWithOptions(ctx context.Context, opts RunOptions) (string, error) {
|
|||
}
|
||||
|
||||
// Running returns all currently running processes from the default service.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// running := process.Running()
|
||||
func Running() []*Process {
|
||||
svc := Default()
|
||||
if svc == nil {
|
||||
|
|
@ -121,9 +271,35 @@ func Running() []*Process {
|
|||
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 initialized.
|
||||
// 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)
|
||||
|
|
|
|||
114
process_test.go
114
process_test.go
|
|
@ -3,6 +3,7 @@ package process
|
|||
import (
|
||||
"context"
|
||||
"os"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -10,6 +11,8 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var _ *Process = (*ManagedProcess)(nil)
|
||||
|
||||
func TestProcess_Info(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
|
|
@ -22,11 +25,60 @@ func TestProcess_Info(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(t *testing.T) {
|
||||
proc := &Process{
|
||||
ID: "pending",
|
||||
Status: StatusPending,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
info := proc.Info()
|
||||
assert.Equal(t, StatusPending, info.Status)
|
||||
assert.False(t, info.Running)
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -65,11 +117,13 @@ func TestProcess_IsRunning(t *testing.T) {
|
|||
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) {
|
||||
|
|
@ -143,6 +197,8 @@ func TestProcess_Kill(t *testing.T) {
|
|||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("process should have been killed")
|
||||
}
|
||||
|
||||
assert.Equal(t, StatusKilled, proc.Status)
|
||||
})
|
||||
|
||||
t.Run("noop on completed process", func(t *testing.T) {
|
||||
|
|
@ -209,6 +265,8 @@ func TestProcess_Signal(t *testing.T) {
|
|||
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) {
|
||||
|
|
@ -221,6 +279,55 @@ func TestProcess_Signal(t *testing.T) {
|
|||
err = proc.Signal(os.Interrupt)
|
||||
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
||||
})
|
||||
|
||||
t.Run("signals process group when kill group is enabled", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
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) {
|
||||
|
|
@ -279,6 +386,7 @@ func TestProcess_Timeout(t *testing.T) {
|
|||
}
|
||||
|
||||
assert.False(t, proc.IsRunning())
|
||||
assert.Equal(t, StatusKilled, proc.Status)
|
||||
})
|
||||
|
||||
t.Run("no timeout when zero", func(t *testing.T) {
|
||||
|
|
@ -319,6 +427,8 @@ func TestProcess_Shutdown(t *testing.T) {
|
|||
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) {
|
||||
|
|
@ -367,6 +477,8 @@ func TestProcess_KillGroup(t *testing.T) {
|
|||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("process group should have been killed")
|
||||
}
|
||||
|
||||
assert.Equal(t, StatusKilled, proc.Status)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -388,5 +500,7 @@ func TestProcess_TimeoutWithGrace(t *testing.T) {
|
|||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("process should have been killed by timeout")
|
||||
}
|
||||
|
||||
assert.Equal(t, StatusKilled, proc.Status)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
51
program.go
51
program.go
|
|
@ -3,10 +3,11 @@ package process
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
|
|
@ -14,25 +15,45 @@ import (
|
|||
// 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.
|
||||
//
|
||||
// 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
|
||||
}
|
||||
|
||||
// Find resolves the program's absolute path using exec.LookPath.
|
||||
// Returns ErrProgramNotFound (wrapped) if the binary is not on PATH.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if err := p.Find(); err != nil { return err }
|
||||
func (p *Program) Find() error {
|
||||
if p.Name == "" {
|
||||
target := p.Path
|
||||
if target == "" {
|
||||
target = p.Name
|
||||
}
|
||||
if target == "" {
|
||||
return coreerr.E("Program.Find", "program name is empty", nil)
|
||||
}
|
||||
path, err := exec.LookPath(p.Name)
|
||||
path, err := exec.LookPath(target)
|
||||
if err != nil {
|
||||
return coreerr.E("Program.Find", fmt.Sprintf("%q: not found in PATH", p.Name), ErrProgramNotFound)
|
||||
return coreerr.E("Program.Find", core.Sprintf("%q: not found in PATH", target), ErrProgramNotFound)
|
||||
}
|
||||
p.Path = path
|
||||
return nil
|
||||
|
|
@ -40,6 +61,10 @@ 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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// out, err := p.Run(ctx, "hello")
|
||||
func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
|
||||
return p.RunDir(ctx, "", args...)
|
||||
}
|
||||
|
|
@ -47,12 +72,24 @@ func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
|
|||
// RunDir executes the program with args in dir.
|
||||
// Returns trimmed combined stdout+stderr output and any error.
|
||||
// If dir is empty, the process inherits the caller's working directory.
|
||||
//
|
||||
// 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 binary == "" {
|
||||
return "", coreerr.E("Program.RunDir", "program name is empty", ErrProgramNameRequired)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, binary, args...)
|
||||
cmd.Stdout = &out
|
||||
|
|
@ -62,7 +99,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin
|
|||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return strings.TrimSpace(out.String()), coreerr.E("Program.RunDir", fmt.Sprintf("%q: command failed", p.Name), err)
|
||||
return strings.TrimRightFunc(out.String(), unicode.IsSpace), coreerr.E("Program.RunDir", core.Sprintf("%q: command failed", p.Name), err)
|
||||
}
|
||||
return strings.TrimSpace(out.String()), nil
|
||||
return strings.TrimRightFunc(out.String(), unicode.IsSpace), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package process_test
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -32,6 +33,28 @@ func TestProgram_Find_UnknownBinary(t *testing.T) {
|
|||
assert.ErrorIs(t, err, process.ErrProgramNotFound)
|
||||
}
|
||||
|
||||
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())
|
||||
|
|
@ -46,6 +69,15 @@ func TestProgram_Run_ReturnsOutput(t *testing.T) {
|
|||
assert.Equal(t, "hello", out)
|
||||
}
|
||||
|
||||
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"}
|
||||
|
|
@ -78,3 +110,19 @@ func TestProgram_Run_FailingCommand(t *testing.T) {
|
|||
_, 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)
|
||||
}
|
||||
|
|
|
|||
42
registry.go
42
registry.go
|
|
@ -4,6 +4,7 @@ import (
|
|||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
|
@ -13,6 +14,10 @@ import (
|
|||
)
|
||||
|
||||
// DaemonEntry records a running daemon in the registry.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// entry := process.DaemonEntry{Code: "app", Daemon: "serve", PID: os.Getpid()}
|
||||
type DaemonEntry struct {
|
||||
Code string `json:"code"`
|
||||
Daemon string `json:"daemon"`
|
||||
|
|
@ -29,11 +34,19 @@ type Registry struct {
|
|||
}
|
||||
|
||||
// NewRegistry creates a registry backed by the given directory.
|
||||
//
|
||||
// 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 := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
|
@ -45,6 +58,10 @@ func DefaultRegistry() *Registry {
|
|||
// 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()
|
||||
|
|
@ -66,8 +83,15 @@ func (r *Registry) Register(entry DaemonEntry) error {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return coreerr.E("Registry.Unregister", "failed to delete entry file", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -75,6 +99,10 @@ func (r *Registry) Unregister(code, daemon string) error {
|
|||
|
||||
// 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)
|
||||
|
||||
|
|
@ -98,6 +126,10 @@ 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) {
|
||||
matches, err := filepath.Glob(filepath.Join(r.dir, "*.json"))
|
||||
if err != nil {
|
||||
|
|
@ -125,6 +157,16 @@ 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,14 @@ func TestRegistry_Unregister(t *testing.T) {
|
|||
assert.True(t, os.IsNotExist(err))
|
||||
}
|
||||
|
||||
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,7 +84,9 @@ func TestRegistry_List(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_List_PrunesStale(t *testing.T) {
|
||||
|
|
|
|||
198
runner.go
198
runner.go
|
|
@ -13,12 +13,32 @@ type Runner struct {
|
|||
service *Service
|
||||
}
|
||||
|
||||
// ErrRunnerNoService is returned when a runner was created without a service.
|
||||
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
|
||||
|
|
@ -43,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
|
||||
}
|
||||
|
|
@ -61,27 +87,45 @@ 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, len(specs))
|
||||
for _, spec := range specs {
|
||||
specMap[spec.Name] = spec
|
||||
indexMap[spec.Name] = len(indexMap)
|
||||
}
|
||||
|
||||
// Track completion
|
||||
completed := make(map[string]*RunResult)
|
||||
var completedMu sync.Mutex
|
||||
|
||||
results := make([]RunResult, 0, len(specs))
|
||||
var resultsMu sync.Mutex
|
||||
results := make([]RunResult, len(specs))
|
||||
|
||||
// Process specs in waves
|
||||
remaining := make(map[string]RunSpec)
|
||||
|
|
@ -90,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 {
|
||||
|
|
@ -99,14 +150,15 @@ 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.
|
||||
// Deadlock - circular dependency or missing specs.
|
||||
// Keep the output aligned with the input order.
|
||||
for name := range remaining {
|
||||
results = append(results, RunResult{
|
||||
Name: name,
|
||||
Spec: remaining[name],
|
||||
ExitCode: 1,
|
||||
Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil),
|
||||
})
|
||||
results[indexMap[name]] = RunResult{
|
||||
Name: name,
|
||||
Spec: remaining[name],
|
||||
Skipped: true,
|
||||
Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil),
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
|
@ -147,9 +199,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
|||
completed[spec.Name] = &result
|
||||
completedMu.Unlock()
|
||||
|
||||
resultsMu.Lock()
|
||||
results = append(results, result)
|
||||
resultsMu.Unlock()
|
||||
results[indexMap[spec.Name]] = result
|
||||
}(spec)
|
||||
}
|
||||
wg.Wait()
|
||||
|
|
@ -179,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 {
|
||||
|
|
@ -210,33 +267,58 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
|
|||
|
||||
<-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
|
||||
}
|
||||
|
|
@ -261,7 +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))
|
||||
|
||||
|
|
@ -270,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)
|
||||
}
|
||||
|
|
@ -292,3 +391,60 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul
|
|||
|
||||
return aggResult, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
190
runner_test.go
190
runner_test.go
|
|
@ -51,6 +51,12 @@ func TestRunner_RunSequential(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) {
|
||||
|
|
@ -148,10 +154,28 @@ func TestRunner_RunAll(t *testing.T) {
|
|||
assert.True(t, result.Success())
|
||||
assert.Equal(t, 4, result.Passed)
|
||||
})
|
||||
|
||||
t.Run("preserves input order", func(t *testing.T) {
|
||||
runner := newTestRunner(t)
|
||||
|
||||
specs := []RunSpec{
|
||||
{Name: "third", Command: "echo", Args: []string{"3"}, After: []string{"second"}},
|
||||
{Name: "first", Command: "echo", Args: []string{"1"}},
|
||||
{Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}},
|
||||
}
|
||||
|
||||
result, err := runner.RunAll(context.Background(), specs)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, result.Results, len(specs))
|
||||
for i, res := range result.Results {
|
||||
assert.Equal(t, specs[i].Name, res.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunner_RunAll_CircularDeps(t *testing.T) {
|
||||
t.Run("circular dependency counts as failed", func(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{
|
||||
|
|
@ -160,9 +184,81 @@ func TestRunner_RunAll_CircularDeps(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 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -187,3 +283,89 @@ func TestRunResult_Passed(t *testing.T) {
|
|||
assert.False(t, r.Passed())
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunner_NilService(t *testing.T) {
|
||||
runner := NewRunner(nil)
|
||||
|
||||
_, err := runner.RunAll(context.Background(), nil)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrRunnerNoService)
|
||||
|
||||
_, err = runner.RunSequential(context.Background(), nil)
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrRunnerNoService)
|
||||
|
||||
_, err = runner.RunParallel(context.Background(), nil)
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
573
service.go
573
service.go
|
|
@ -5,8 +5,9 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
|
@ -14,6 +15,7 @@ import (
|
|||
|
||||
"dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
goio "io"
|
||||
)
|
||||
|
||||
// Default buffer size for process output (1MB).
|
||||
|
|
@ -24,19 +26,33 @@ var (
|
|||
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]
|
||||
|
||||
processes map[string]*Process
|
||||
mu sync.RWMutex
|
||||
bufSize int
|
||||
idCounter atomic.Uint64
|
||||
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).
|
||||
|
|
@ -48,6 +64,10 @@ type Options struct {
|
|||
// 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 {
|
||||
|
|
@ -63,12 +83,25 @@ func NewService(opts Options) func(*core.Core) (any, error) {
|
|||
}
|
||||
|
||||
// OnStartup implements core.Startable.
|
||||
//
|
||||
// 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.
|
||||
// Gracefully shuts down all running processes (SIGTERM → SIGKILL).
|
||||
// Immediately kills all running processes to avoid shutdown stalls.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// _ = svc.OnShutdown(ctx)
|
||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
||||
s.mu.RLock()
|
||||
procs := make([]*Process, 0, len(s.processes))
|
||||
|
|
@ -80,13 +113,17 @@ func (s *Service) OnShutdown(ctx context.Context) error {
|
|||
s.mu.RUnlock()
|
||||
|
||||
for _, p := range procs {
|
||||
_ = p.Shutdown()
|
||||
_, _ = p.killTree()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start spawns a new process with the given command and args.
|
||||
//
|
||||
// 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,
|
||||
|
|
@ -95,8 +132,24 @@ func (s *Service) Start(ctx context.Context, command string, args ...string) (*P
|
|||
}
|
||||
|
||||
// StartWithOptions spawns a process with full configuration.
|
||||
//
|
||||
// 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 nil, ServiceError("command is required", nil)
|
||||
}
|
||||
if ctx == nil {
|
||||
return nil, ServiceError("context is required", ErrContextRequired)
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -113,10 +166,9 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
|
|||
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()
|
||||
|
|
@ -146,11 +198,11 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
|
|||
proc := &Process{
|
||||
ID: id,
|
||||
Command: opts.Command,
|
||||
Args: opts.Args,
|
||||
Args: append([]string(nil), opts.Args...),
|
||||
Dir: opts.Dir,
|
||||
Env: opts.Env,
|
||||
StartedAt: time.Now(),
|
||||
Status: StatusRunning,
|
||||
Env: append([]string(nil), opts.Env...),
|
||||
StartedAt: startedAt,
|
||||
Status: StatusPending,
|
||||
cmd: cmd,
|
||||
ctx: procCtx,
|
||||
cancel: cancel,
|
||||
|
|
@ -163,10 +215,34 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
|
|||
|
||||
// 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 nil, coreerr.E("Service.StartWithOptions", "failed to start process", err)
|
||||
if c := s.coreApp(); c != nil {
|
||||
_ = c.ACTION(ActionProcessExited{
|
||||
ID: id,
|
||||
ExitCode: -1,
|
||||
Duration: proc.Duration,
|
||||
Error: startErr,
|
||||
})
|
||||
}
|
||||
return proc, startErr
|
||||
}
|
||||
|
||||
proc.mu.Lock()
|
||||
proc.Status = StatusRunning
|
||||
proc.mu.Unlock()
|
||||
|
||||
// Store process
|
||||
s.mu.Lock()
|
||||
s.processes[id] = proc
|
||||
|
|
@ -185,13 +261,15 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
|
|||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -214,45 +292,37 @@ func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) (*Proce
|
|||
err := cmd.Wait()
|
||||
|
||||
duration := time.Since(proc.StartedAt)
|
||||
status, exitCode, exitErr, signalName := classifyProcessExit(err)
|
||||
|
||||
proc.mu.Lock()
|
||||
proc.Duration = duration
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
proc.ExitCode = exitErr.ExitCode()
|
||||
proc.Status = StatusExited
|
||||
} else {
|
||||
proc.Status = StatusFailed
|
||||
}
|
||||
} else {
|
||||
proc.ExitCode = 0
|
||||
proc.Status = StatusExited
|
||||
}
|
||||
status := proc.Status
|
||||
exitCode := proc.ExitCode
|
||||
proc.ExitCode = exitCode
|
||||
proc.Status = status
|
||||
proc.mu.Unlock()
|
||||
|
||||
close(proc.done)
|
||||
|
||||
// Broadcast exit
|
||||
var exitErr error
|
||||
if status == StatusFailed {
|
||||
exitErr = err
|
||||
if status == StatusKilled {
|
||||
s.emitKilledAction(proc, signalName)
|
||||
}
|
||||
_ = s.Core().ACTION(ActionProcessExited{
|
||||
|
||||
exitAction := ActionProcessExited{
|
||||
ID: id,
|
||||
ExitCode: exitCode,
|
||||
Duration: duration,
|
||||
Error: exitErr,
|
||||
})
|
||||
}
|
||||
|
||||
if c := s.coreApp(); c != nil {
|
||||
_ = c.ACTION(exitAction)
|
||||
}
|
||||
}()
|
||||
|
||||
return proc, nil
|
||||
}
|
||||
|
||||
// streamOutput reads from a pipe and broadcasts lines via ACTION.
|
||||
func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) {
|
||||
func (s *Service) streamOutput(proc *Process, r goio.Reader, stream Stream) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
// Increase buffer for long lines
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
|
|
@ -266,15 +336,21 @@ func (s *Service) streamOutput(proc *Process, r io.Reader, stream Stream) {
|
|||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// proc, err := svc.Get("proc-1")
|
||||
func (s *Service) Get(id string) (*Process, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
@ -287,6 +363,10 @@ func (s *Service) Get(id string) (*Process, error) {
|
|||
}
|
||||
|
||||
// List returns all processes.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// for _, proc := range svc.List() { _ = proc }
|
||||
func (s *Service) List() []*Process {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
@ -295,10 +375,15 @@ func (s *Service) List() []*Process {
|
|||
for _, p := range s.processes {
|
||||
result = append(result, p)
|
||||
}
|
||||
sortProcesses(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Running returns all currently running processes.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// running := svc.Running()
|
||||
func (s *Service) Running() []*Process {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
|
@ -309,29 +394,103 @@ func (s *Service) Running() []*Process {
|
|||
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
|
||||
}
|
||||
if sent {
|
||||
s.emitKilledAction(proc, "SIGKILL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = s.Core().ACTION(ActionProcessKilled{
|
||||
ID: id,
|
||||
Signal: "SIGKILL",
|
||||
})
|
||||
// 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
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
@ -350,6 +509,10 @@ func (s *Service) Remove(id string) error {
|
|||
}
|
||||
|
||||
// Clear removes all completed processes.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// svc.Clear()
|
||||
func (s *Service) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
|
@ -362,6 +525,10 @@ func (s *Service) Clear() {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -370,8 +537,72 @@ func (s *Service) Output(id string) (string, error) {
|
|||
return proc.Output(), nil
|
||||
}
|
||||
|
||||
// Input writes data to the stdin of a managed process.
|
||||
//
|
||||
// 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 {
|
||||
|
|
@ -381,6 +612,9 @@ func (s *Service) Run(ctx context.Context, command string, args ...string) (stri
|
|||
<-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)
|
||||
}
|
||||
|
|
@ -388,6 +622,10 @@ func (s *Service) Run(ctx context.Context, command string, args ...string) (stri
|
|||
}
|
||||
|
||||
// RunWithOptions executes a command with options and waits for completion.
|
||||
//
|
||||
// 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 {
|
||||
|
|
@ -397,8 +635,235 @@ func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) (string,
|
|||
<-proc.Done()
|
||||
|
||||
output := proc.Output()
|
||||
if proc.Status == StatusKilled {
|
||||
return output, coreerr.E("Service.RunWithOptions", "process was killed", nil)
|
||||
}
|
||||
if proc.ExitCode != 0 {
|
||||
return output, coreerr.E("Service.RunWithOptions", fmt.Sprintf("process exited with code %d", proc.ExitCode), nil)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// 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{}
|
||||
}
|
||||
}
|
||||
|
||||
// classifyProcessExit maps a command completion error to lifecycle state.
|
||||
func classifyProcessExit(err error) (Status, int, error, string) {
|
||||
if err == nil {
|
||||
return StatusExited, 0, nil, ""
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
exitCode := exitErr.ExitCode()
|
||||
return StatusExited, exitCode, coreerr.E("Service.StartWithOptions", fmt.Sprintf("process exited with code %d", exitCode), nil), ""
|
||||
}
|
||||
|
||||
return StatusFailed, 0, err, ""
|
||||
}
|
||||
|
||||
// emitKilledAction broadcasts a kill event once for the given process.
|
||||
func (s *Service) emitKilledAction(proc *Process, signalName string) {
|
||||
if proc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
proc.mu.Lock()
|
||||
if proc.killNotified {
|
||||
proc.mu.Unlock()
|
||||
return
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
783
service_test.go
783
service_test.go
|
|
@ -2,8 +2,10 @@ package process
|
|||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -44,6 +46,23 @@ func TestService_Start(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)
|
||||
|
||||
|
|
@ -59,8 +78,36 @@ func TestService_Start(t *testing.T) {
|
|||
t.Run("non-existent command", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
|
||||
_, err := svc.Start(context.Background(), "nonexistent_command_xyz")
|
||||
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) {
|
||||
|
|
@ -150,6 +197,18 @@ func TestService_Start(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(t *testing.T) {
|
||||
|
|
@ -225,6 +284,129 @@ func TestService_Actions(t *testing.T) {
|
|||
|
||||
assert.Len(t, exited, 1)
|
||||
assert.Equal(t, 0, exited[0].ExitCode)
|
||||
assert.Nil(t, exited[0].Error)
|
||||
})
|
||||
|
||||
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 {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
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, err := svc.Start(context.Background(), "sleep", "60")
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +422,8 @@ func TestService_List(t *testing.T) {
|
|||
|
||||
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) {
|
||||
|
|
@ -328,6 +512,8 @@ func TestService_Kill(t *testing.T) {
|
|||
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) {
|
||||
|
|
@ -338,6 +524,105 @@ func TestService_Kill(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)
|
||||
|
|
@ -359,6 +644,92 @@ func TestService_Output(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)
|
||||
|
|
@ -388,13 +759,394 @@ func TestService_OnShutdown(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_OnStartup(t *testing.T) {
|
||||
t.Run("returns nil", func(t *testing.T) {
|
||||
svc, _ := newTestService(t)
|
||||
t.Run("registers process.start task", func(t *testing.T) {
|
||||
svc, c := newTestService(t)
|
||||
|
||||
err := svc.OnStartup(context.Background())
|
||||
assert.NoError(t, err)
|
||||
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")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -420,6 +1172,16 @@ func TestService_RunWithOptions(t *testing.T) {
|
|||
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(t *testing.T) {
|
||||
|
|
@ -432,15 +1194,24 @@ func TestService_Running(t *testing.T) {
|
|||
proc1, err := svc.Start(ctx, "sleep", "60")
|
||||
require.NoError(t, err)
|
||||
|
||||
proc2, err := svc.Start(context.Background(), "echo", "done")
|
||||
doneProc, err := svc.Start(context.Background(), "echo", "done")
|
||||
require.NoError(t, err)
|
||||
<-proc2.Done()
|
||||
<-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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
26
types.go
26
types.go
|
|
@ -1,5 +1,10 @@
|
|||
// 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.
|
||||
//
|
||||
|
|
@ -35,6 +40,10 @@ package process
|
|||
import "time"
|
||||
|
||||
// Status represents the process lifecycle state.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if proc.Status == process.StatusKilled { return }
|
||||
type Status string
|
||||
|
||||
const (
|
||||
|
|
@ -51,6 +60,10 @@ const (
|
|||
)
|
||||
|
||||
// Stream identifies the output source.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// if event.Stream == process.StreamStdout { ... }
|
||||
type Stream string
|
||||
|
||||
const (
|
||||
|
|
@ -61,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
|
||||
|
|
@ -92,12 +112,18 @@ type RunOptions 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"`
|
||||
Dir string `json:"dir"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
Running bool `json:"running"`
|
||||
Status Status `json:"status"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
Duration time.Duration `json:"duration"`
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import type { ProcessInfo } from './shared/api.js';
|
||||
import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
|
||||
import { ProcessApi, type ProcessInfo } from './shared/api.js';
|
||||
|
||||
/**
|
||||
* <core-process-list> — Running processes with status and actions.
|
||||
|
|
@ -13,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 {
|
||||
|
|
@ -185,23 +185,54 @@ export class ProcessList extends LitElement {
|
|||
`;
|
||||
|
||||
@property({ attribute: 'api-url' }) apiUrl = '';
|
||||
@property({ attribute: 'ws-url' }) wsUrl = '';
|
||||
@property({ attribute: 'selected-id' }) selectedId = '';
|
||||
|
||||
@state() private processes: ProcessInfo[] = [];
|
||||
@state() private loading = false;
|
||||
@state() private error = '';
|
||||
@state() private connected = false;
|
||||
@state() private killing = new Set<string>();
|
||||
|
||||
private api!: ProcessApi;
|
||||
private ws: WebSocket | null = null;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.api = new ProcessApi(this.apiUrl);
|
||||
this.loadProcesses();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
updated(changed: Map<string, unknown>) {
|
||||
if (changed.has('apiUrl')) {
|
||||
this.api = new ProcessApi(this.apiUrl);
|
||||
}
|
||||
|
||||
if (changed.has('wsUrl') || changed.has('apiUrl')) {
|
||||
this.disconnect();
|
||||
void this.loadProcesses();
|
||||
}
|
||||
}
|
||||
|
||||
async loadProcesses() {
|
||||
// Process-level REST endpoints are not yet available.
|
||||
// This element will populate via WS events once endpoints exist.
|
||||
this.loading = false;
|
||||
this.processes = [];
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
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 = [];
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelect(proc: ProcessInfo) {
|
||||
|
|
@ -214,6 +245,104 @@ export class ProcessList extends LitElement {
|
|||
);
|
||||
}
|
||||
|
||||
private async handleKill(proc: ProcessInfo) {
|
||||
this.killing = new Set([...this.killing, proc.id]);
|
||||
try {
|
||||
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);
|
||||
});
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.connected = true;
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
this.connected = false;
|
||||
};
|
||||
}
|
||||
|
||||
private disconnect() {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connected = false;
|
||||
}
|
||||
|
||||
private applyEvent(event: ProcessEvent) {
|
||||
const channel = event.channel ?? event.type ?? '';
|
||||
const data = (event.data ?? {}) as Partial<ProcessInfo> & { id?: string };
|
||||
|
||||
if (!data.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new Map(this.processes.map((proc) => [proc.id, proc] as const));
|
||||
const current = next.get(data.id);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
this.processes = this.sortProcesses(next);
|
||||
}
|
||||
|
||||
private normalizeProcess(
|
||||
data: Partial<ProcessInfo> & { 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,
|
||||
running: status === 'running',
|
||||
status,
|
||||
exitCode: data.exitCode ?? current?.exitCode ?? (status === 'killed' ? -1 : 0),
|
||||
duration: data.duration ?? current?.duration ?? 0,
|
||||
pid: data.pid ?? current?.pid ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private sortProcesses(processes: Map<string, ProcessInfo>): ProcessInfo[] {
|
||||
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();
|
||||
|
|
@ -238,8 +367,11 @@ export class ProcessList extends LitElement {
|
|||
${this.processes.length === 0
|
||||
? html`
|
||||
<div class="info-notice">
|
||||
Process list endpoints are pending. Processes will appear here once
|
||||
the REST API for managed processes is available.
|
||||
${this.wsUrl
|
||||
? this.connected
|
||||
? 'Receiving live process updates.'
|
||||
: 'Connecting to the process event stream...'
|
||||
: 'Managed processes are loaded from the process REST API.'}
|
||||
</div>
|
||||
<div class="empty">No managed processes.</div>
|
||||
`
|
||||
|
|
@ -278,6 +410,7 @@ export class ProcessList extends LitElement {
|
|||
?disabled=${this.killing.has(proc.id)}
|
||||
@click=${(e: Event) => {
|
||||
e.stopPropagation();
|
||||
void this.handleKill(proc);
|
||||
}}
|
||||
>
|
||||
${this.killing.has(proc.id) ? 'Killing\u2026' : 'Kill'}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
|
||||
import { ProcessApi } from './shared/api.js';
|
||||
|
||||
interface OutputLine {
|
||||
text: string;
|
||||
|
|
@ -131,14 +132,15 @@ export class ProcessOutput extends LitElement {
|
|||
@state() private lines: OutputLine[] = [];
|
||||
@state() private autoScroll = true;
|
||||
@state() private connected = false;
|
||||
@state() private loadingSnapshot = false;
|
||||
|
||||
private ws: WebSocket | null = null;
|
||||
private api = new ProcessApi(this.apiUrl);
|
||||
private syncToken = 0;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.wsUrl && this.processId) {
|
||||
this.connect();
|
||||
}
|
||||
this.syncSources();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
|
|
@ -147,12 +149,12 @@ export class ProcessOutput extends LitElement {
|
|||
}
|
||||
|
||||
updated(changed: Map<string, unknown>) {
|
||||
if (changed.has('processId') || changed.has('wsUrl')) {
|
||||
this.disconnect();
|
||||
this.lines = [];
|
||||
if (this.wsUrl && this.processId) {
|
||||
this.connect();
|
||||
}
|
||||
if (changed.has('apiUrl')) {
|
||||
this.api = new ProcessApi(this.apiUrl);
|
||||
}
|
||||
|
||||
if (changed.has('processId') || changed.has('wsUrl') || changed.has('apiUrl')) {
|
||||
this.syncSources();
|
||||
}
|
||||
|
||||
if (this.autoScroll) {
|
||||
|
|
@ -160,6 +162,66 @@ export class ProcessOutput extends LitElement {
|
|||
}
|
||||
}
|
||||
|
||||
private syncSources() {
|
||||
this.disconnect();
|
||||
this.lines = [];
|
||||
if (!this.processId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.loadSnapshotAndConnect();
|
||||
}
|
||||
|
||||
private async loadSnapshotAndConnect() {
|
||||
const token = ++this.syncToken;
|
||||
|
||||
if (!this.processId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.apiUrl) {
|
||||
this.loadingSnapshot = true;
|
||||
try {
|
||||
const output = await this.api.getProcessOutput(this.processId);
|
||||
if (token !== this.syncToken) {
|
||||
return;
|
||||
}
|
||||
const snapshot = this.linesFromOutput(output);
|
||||
if (snapshot.length > 0) {
|
||||
this.lines = snapshot;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing snapshot data and continue with live streaming.
|
||||
} finally {
|
||||
if (token === this.syncToken) {
|
||||
this.loadingSnapshot = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (token === this.syncToken && this.wsUrl) {
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
private linesFromOutput(output: string): OutputLine[] {
|
||||
if (!output) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalized = output.replace(/\r\n/g, '\n');
|
||||
const parts = normalized.split('\n');
|
||||
if (parts.length > 0 && parts[parts.length - 1] === '') {
|
||||
parts.pop();
|
||||
}
|
||||
|
||||
return parts.map((text) => ({
|
||||
text,
|
||||
stream: 'stdout' as const,
|
||||
timestamp: Date.now(),
|
||||
}));
|
||||
}
|
||||
|
||||
private connect() {
|
||||
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
|
||||
const data = event.data;
|
||||
|
|
@ -231,7 +293,9 @@ export class ProcessOutput extends LitElement {
|
|||
</div>
|
||||
</div>
|
||||
<div class="output-body">
|
||||
${this.lines.length === 0
|
||||
${this.loadingSnapshot && this.lines.length === 0
|
||||
? html`<div class="waiting">Loading snapshot\u2026</div>`
|
||||
: this.lines.length === 0
|
||||
? html`<div class="waiting">Waiting for output\u2026</div>`
|
||||
: this.lines.map(
|
||||
(line) => html`
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ export class ProcessPanel extends LitElement {
|
|||
return html`
|
||||
<core-process-list
|
||||
api-url=${this.apiUrl}
|
||||
ws-url=${this.wsUrl}
|
||||
@process-selected=${this.handleProcessSelected}
|
||||
></core-process-list>
|
||||
${this.selectedProcessId
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
<div class="info-notice">
|
||||
Pipeline runner endpoints are pending. Pass pipeline results via the
|
||||
<code>result</code> property, or results will appear here once the REST
|
||||
API for pipeline execution is available.
|
||||
Pass pipeline results via the <code>result</code> property.
|
||||
</div>
|
||||
<div class="empty">No pipeline results.</div>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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<HealthResult> {
|
||||
return this.request<HealthResult>(`/daemons/${code}/${daemon}/health`);
|
||||
}
|
||||
|
||||
/** List all managed processes. */
|
||||
listProcesses(runningOnly = false): Promise<ProcessInfo[]> {
|
||||
const query = runningOnly ? '?runningOnly=true' : '';
|
||||
return this.request<ProcessInfo[]>(`/processes${query}`);
|
||||
}
|
||||
|
||||
/** Get a single managed process by ID. */
|
||||
getProcess(id: string): Promise<ProcessInfo> {
|
||||
return this.request<ProcessInfo>(`/processes/${id}`);
|
||||
}
|
||||
|
||||
/** Get the captured stdout/stderr for a managed process by ID. */
|
||||
getProcessOutput(id: string): Promise<string> {
|
||||
return this.request<string>(`/processes/${id}/output`);
|
||||
}
|
||||
|
||||
/** Start a managed process asynchronously. */
|
||||
startProcess(opts: ProcessControlRequest): Promise<ProcessInfo> {
|
||||
return this.request<ProcessInfo>('/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<string> {
|
||||
return this.request<string>('/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<ProcessInfo> {
|
||||
return this.request<ProcessInfo>(`/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<RunAllResult> {
|
||||
return this.request<RunAllResult>('/pipelines/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode, specs }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue