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 |
47 changed files with 6563 additions and 3066 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -20,12 +20,11 @@ core go vet # Vet
|
||||||
|
|
||||||
The package has three layers, all in the root `process` package (plus a `exec` subpackage):
|
The package has three layers, all in the root `process` package (plus a `exec` subpackage):
|
||||||
|
|
||||||
### Layer 1: Process Execution (service.go, process.go)
|
### Layer 1: Process Execution (service.go, process.go, process_global.go)
|
||||||
|
|
||||||
`Service` is a Core service (`*core.ServiceRuntime[Options]`) that manages all `Process` instances. It spawns subprocesses, pipes stdout/stderr through goroutines, captures output to a `RingBuffer`, and broadcasts IPC actions (`ActionProcessStarted`, `ActionProcessOutput`, `ActionProcessExited`, `ActionProcessKilled` — defined in actions.go).
|
`Service` is a Core service (`*core.ServiceRuntime[Options]`) that manages all `Process` instances. It spawns subprocesses, pipes stdout/stderr through goroutines, captures output to a `RingBuffer`, and broadcasts IPC actions (`ActionProcessStarted`, `ActionProcessOutput`, `ActionProcessExited`, `ActionProcessKilled` — defined in actions.go).
|
||||||
|
|
||||||
The legacy global singleton API (`process_global.go`) was removed in favor of
|
`process_global.go` provides package-level convenience functions (`Start`, `Run`, `Kill`, `List`) that delegate to a global `Service` singleton initialized via `Init(core)`. Follows the same pattern as Go's `i18n` package.
|
||||||
explicit Core service registration.
|
|
||||||
|
|
||||||
### Layer 2: Daemon Lifecycle (daemon.go, pidfile.go, health.go, registry.go)
|
### Layer 2: Daemon Lifecycle (daemon.go, pidfile.go, health.go, registry.go)
|
||||||
|
|
||||||
|
|
@ -46,19 +45,19 @@ Builder-pattern wrapper around `os/exec` with structured logging via a pluggable
|
||||||
|
|
||||||
## Key Patterns
|
## Key Patterns
|
||||||
|
|
||||||
- **Core integration**: `Service` embeds `*core.ServiceRuntime[Options]` and uses `s.Core().ACTION(...)` to broadcast typed action messages. Tests create a Core instance via `framework.New(framework.WithService(Register))`.
|
- **Core integration**: `Service` embeds `*core.ServiceRuntime[Options]` and uses `s.Core().ACTION(...)` to broadcast typed action messages. Tests create a Core instance via `framework.New(framework.WithName("process", NewService(...)))`.
|
||||||
- **Output capture**: All process output goes through a fixed-size `RingBuffer` (default 1MB). Oldest data is silently overwritten when full. Set `RunOptions.DisableCapture` to skip buffering for long-running processes where output is only streamed via IPC.
|
- **Output capture**: All process output goes through a fixed-size `RingBuffer` (default 1MB). Oldest data is silently overwritten when full. Set `RunOptions.DisableCapture` to skip buffering for long-running processes where output is only streamed via IPC.
|
||||||
- **Process lifecycle**: Status transitions are `StatusPending → StatusRunning → StatusExited|StatusFailed|StatusKilled`. The `done` channel closes on exit; use `<-proc.Done()` or `proc.Wait()`.
|
- **Process lifecycle**: Status transitions are `StatusPending → StatusRunning → StatusExited|StatusFailed|StatusKilled`. The `done` channel closes on exit; use `<-proc.Done()` or `proc.Wait()`.
|
||||||
- **Detach / process group isolation**: Set `RunOptions.Detach = true` to run the subprocess in its own process group (`Setpgid`). Detached processes use `context.Background()` so they survive parent context cancellation and parent death.
|
- **Detach / process group isolation**: Set `RunOptions.Detach = true` to run the subprocess in its own process group (`Setpgid`). Detached processes use `context.Background()` so they survive parent context cancellation and parent death.
|
||||||
- **Graceful shutdown**: `Service.OnShutdown` kills all running processes. `Daemon.Stop()` performs ordered teardown: sets health to not-ready → shuts down health server → releases PID file → unregisters from registry. `DaemonOptions.ShutdownTimeout` (default 30 s) bounds the shutdown context.
|
- **Graceful shutdown**: `Service.OnShutdown` kills all running processes. `Daemon.Stop()` performs ordered teardown: sets health to not-ready → shuts down health server → releases PID file → unregisters from registry. `DaemonOptions.ShutdownTimeout` (default 30 s) bounds the shutdown context.
|
||||||
- **Auto-registration**: Pass a `Registry` and `RegistryEntry` in `DaemonOptions` to automatically register the daemon on `Start()` and unregister on `Stop()`.
|
- **Auto-registration**: Pass a `Registry` and `RegistryEntry` in `DaemonOptions` to automatically register the daemon on `Start()` and unregister on `Stop()`.
|
||||||
- **PID liveness checks**: Both `PIDFile` and `Registry` use `proc.Signal(syscall.Signal(0))` to check if a PID is alive before trusting stored state.
|
- **PID liveness checks**: Both `PIDFile` and `Registry` use `proc.Signal(syscall.Signal(0))` to check if a PID is alive before trusting stored state.
|
||||||
- **Error handling**: All errors MUST use `core.E()`, never `fmt.Errorf` or
|
- **Error handling**: All errors MUST use `coreerr.E()` from `go-log` (imported as `coreerr`), never `fmt.Errorf` or `errors.New`. Sentinel errors are package-level vars created with `coreerr.E("", "message", nil)`.
|
||||||
`errors.New`. Sentinel errors are package-level vars created with `core.E("", "message", nil)`.
|
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- `dappco.re/go/core` — Core DI framework, IPC actions, `ServiceRuntime`
|
- `dappco.re/go/core` — Core DI framework, IPC actions, `ServiceRuntime`
|
||||||
|
- `dappco.re/go/core/log` — Structured error constructor (`coreerr.E()`)
|
||||||
- `dappco.re/go/core/io` — Filesystem abstraction (`coreio.Local`) used by PIDFile and Registry
|
- `dappco.re/go/core/io` — Filesystem abstraction (`coreio.Local`) used by PIDFile and Registry
|
||||||
- `github.com/stretchr/testify` — test assertions (require/assert)
|
- `github.com/stretchr/testify` — test assertions (require/assert)
|
||||||
|
|
||||||
|
|
|
||||||
310
actions.go
310
actions.go
|
|
@ -1,16 +1,195 @@
|
||||||
package process
|
package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- ACTION messages (broadcast via Core.ACTION) ---
|
// --- 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.
|
// ActionProcessStarted is broadcast when a process begins execution.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// case process.ActionProcessStarted: fmt.Println("started", msg.ID)
|
||||||
type ActionProcessStarted struct {
|
type ActionProcessStarted struct {
|
||||||
ID string
|
ID string
|
||||||
Command string
|
Command string
|
||||||
|
|
@ -21,6 +200,10 @@ type ActionProcessStarted struct {
|
||||||
|
|
||||||
// ActionProcessOutput is broadcast for each line of output.
|
// ActionProcessOutput is broadcast for each line of output.
|
||||||
// Subscribe to this for real-time streaming.
|
// Subscribe to this for real-time streaming.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// case process.ActionProcessOutput: fmt.Println(msg.Line)
|
||||||
type ActionProcessOutput struct {
|
type ActionProcessOutput struct {
|
||||||
ID string
|
ID string
|
||||||
Line string
|
Line string
|
||||||
|
|
@ -29,126 +212,23 @@ type ActionProcessOutput struct {
|
||||||
|
|
||||||
// ActionProcessExited is broadcast when a process completes.
|
// ActionProcessExited is broadcast when a process completes.
|
||||||
// Check ExitCode for success (0) or failure.
|
// Check ExitCode for success (0) or failure.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// case process.ActionProcessExited: fmt.Println(msg.ExitCode)
|
||||||
type ActionProcessExited struct {
|
type ActionProcessExited struct {
|
||||||
ID string
|
ID string
|
||||||
ExitCode int
|
ExitCode int
|
||||||
Duration time.Duration
|
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.
|
// ActionProcessKilled is broadcast when a process is terminated.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// case process.ActionProcessKilled: fmt.Println(msg.Signal)
|
||||||
type ActionProcessKilled struct {
|
type ActionProcessKilled struct {
|
||||||
ID string
|
ID string
|
||||||
Signal string
|
Signal string
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Core Action Handlers ---------------------------------------------------
|
|
||||||
|
|
||||||
func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
command := opts.String("command")
|
|
||||||
if command == "" {
|
|
||||||
return core.Result{Value: core.E("process.run", "command is required", nil), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
runOpts := RunOptions{
|
|
||||||
Command: command,
|
|
||||||
Dir: opts.String("dir"),
|
|
||||||
}
|
|
||||||
if r := opts.Get("args"); r.OK {
|
|
||||||
runOpts.Args = optionStrings(r.Value)
|
|
||||||
}
|
|
||||||
if r := opts.Get("env"); r.OK {
|
|
||||||
runOpts.Env = optionStrings(r.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.runCommand(ctx, runOpts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
command := opts.String("command")
|
|
||||||
if command == "" {
|
|
||||||
return core.Result{Value: core.E("process.start", "command is required", nil), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
runOpts := RunOptions{
|
|
||||||
Command: command,
|
|
||||||
Dir: opts.String("dir"),
|
|
||||||
Detach: opts.Bool("detach"),
|
|
||||||
}
|
|
||||||
if r := opts.Get("args"); r.OK {
|
|
||||||
runOpts.Args = optionStrings(r.Value)
|
|
||||||
}
|
|
||||||
if r := opts.Get("env"); r.OK {
|
|
||||||
runOpts.Env = optionStrings(r.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := s.StartWithOptions(ctx, runOpts)
|
|
||||||
if !r.OK {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
return core.Result{Value: r.Value.(*ManagedProcess).ID, OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleKill(_ context.Context, opts core.Options) core.Result {
|
|
||||||
id := opts.String("id")
|
|
||||||
if id != "" {
|
|
||||||
if err := s.Kill(id); err != nil {
|
|
||||||
if core.Is(err, ErrProcessNotFound) {
|
|
||||||
return core.Result{Value: core.E("process.kill", core.Concat("not found: ", id), nil), OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{Value: core.E("process.kill", core.Concat("kill failed: ", id), err), OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
pid := opts.Int("pid")
|
|
||||||
if pid > 0 {
|
|
||||||
proc, err := processHandle(pid)
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: core.E("process.kill", core.Concat("find pid failed: ", core.Sprintf("%d", pid)), err), OK: false}
|
|
||||||
}
|
|
||||||
if err := proc.Signal(syscall.SIGTERM); err != nil {
|
|
||||||
return core.Result{Value: core.E("process.kill", core.Concat("signal failed: ", core.Sprintf("%d", pid)), err), OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
return core.Result{Value: core.E("process.kill", "need id or pid", nil), OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleList(_ context.Context, _ core.Options) core.Result {
|
|
||||||
return core.Result{Value: s.managed.Names(), OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleGet(_ context.Context, opts core.Options) core.Result {
|
|
||||||
id := opts.String("id")
|
|
||||||
if id == "" {
|
|
||||||
return core.Result{Value: core.E("process.get", "id is required", nil), OK: false}
|
|
||||||
}
|
|
||||||
proc, err := s.Get(id)
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: core.E("process.get", core.Concat("not found: ", id), err), OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{Value: proc.Info(), OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func optionStrings(value any) []string {
|
|
||||||
switch typed := value.(type) {
|
|
||||||
case nil:
|
|
||||||
return nil
|
|
||||||
case []string:
|
|
||||||
return append([]string(nil), typed...)
|
|
||||||
case []any:
|
|
||||||
result := make([]string, 0, len(typed))
|
|
||||||
for _, item := range typed {
|
|
||||||
text, ok := item.(string)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result = append(result, text)
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
13
buffer.go
13
buffer.go
|
|
@ -4,8 +4,6 @@ import "sync"
|
||||||
|
|
||||||
// RingBuffer is a fixed-size circular buffer that overwrites old data.
|
// RingBuffer is a fixed-size circular buffer that overwrites old data.
|
||||||
// Thread-safe for concurrent reads and writes.
|
// Thread-safe for concurrent reads and writes.
|
||||||
//
|
|
||||||
// rb := process.NewRingBuffer(1024)
|
|
||||||
type RingBuffer struct {
|
type RingBuffer struct {
|
||||||
data []byte
|
data []byte
|
||||||
size int
|
size int
|
||||||
|
|
@ -16,13 +14,10 @@ type RingBuffer struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRingBuffer creates a ring buffer with the given capacity.
|
// NewRingBuffer creates a ring buffer with the given capacity.
|
||||||
//
|
|
||||||
// rb := process.NewRingBuffer(256)
|
|
||||||
func NewRingBuffer(size int) *RingBuffer {
|
func NewRingBuffer(size int) *RingBuffer {
|
||||||
if size <= 0 {
|
if size < 0 {
|
||||||
size = 1
|
size = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return &RingBuffer{
|
return &RingBuffer{
|
||||||
data: make([]byte, size),
|
data: make([]byte, size),
|
||||||
size: size,
|
size: size,
|
||||||
|
|
@ -34,6 +29,10 @@ func (rb *RingBuffer) Write(p []byte) (n int, err error) {
|
||||||
rb.mu.Lock()
|
rb.mu.Lock()
|
||||||
defer rb.mu.Unlock()
|
defer rb.mu.Unlock()
|
||||||
|
|
||||||
|
if rb.size == 0 {
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
for _, b := range p {
|
for _, b := range p {
|
||||||
rb.data[rb.end] = b
|
rb.data[rb.end] = b
|
||||||
rb.end = (rb.end + 1) % rb.size
|
rb.end = (rb.end + 1) % rb.size
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRingBuffer_Basics_Good(t *testing.T) {
|
func TestRingBuffer(t *testing.T) {
|
||||||
t.Run("write and read", func(t *testing.T) {
|
t.Run("write and read", func(t *testing.T) {
|
||||||
rb := NewRingBuffer(10)
|
rb := NewRingBuffer(10)
|
||||||
|
|
||||||
|
|
@ -69,4 +69,18 @@ func TestRingBuffer_Basics_Good(t *testing.T) {
|
||||||
bytes[0] = 'x'
|
bytes[0] = 'x'
|
||||||
assert.Equal(t, "hello", rb.String())
|
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())
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
105
daemon.go
105
daemon.go
|
|
@ -2,15 +2,22 @@ package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DaemonOptions configures daemon mode execution.
|
// DaemonOptions configures daemon mode execution.
|
||||||
//
|
//
|
||||||
// opts := process.DaemonOptions{PIDFile: "/tmp/process.pid", HealthAddr: "127.0.0.1:0"}
|
// Example:
|
||||||
|
//
|
||||||
|
// opts := process.DaemonOptions{
|
||||||
|
// PIDFile: "/var/run/myapp.pid",
|
||||||
|
// HealthAddr: "127.0.0.1:0",
|
||||||
|
// }
|
||||||
type DaemonOptions struct {
|
type DaemonOptions struct {
|
||||||
// PIDFile path for single-instance enforcement.
|
// PIDFile path for single-instance enforcement.
|
||||||
// Leave empty to skip PID file management.
|
// Leave empty to skip PID file management.
|
||||||
|
|
@ -32,13 +39,11 @@ type DaemonOptions struct {
|
||||||
Registry *Registry
|
Registry *Registry
|
||||||
|
|
||||||
// RegistryEntry provides the code and daemon name for registration.
|
// 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
|
RegistryEntry DaemonEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daemon manages daemon lifecycle: PID file, health server, graceful shutdown.
|
// Daemon manages daemon lifecycle: PID file, health server, graceful shutdown.
|
||||||
//
|
|
||||||
// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"})
|
|
||||||
type Daemon struct {
|
type Daemon struct {
|
||||||
opts DaemonOptions
|
opts DaemonOptions
|
||||||
pid *PIDFile
|
pid *PIDFile
|
||||||
|
|
@ -49,7 +54,9 @@ type Daemon struct {
|
||||||
|
|
||||||
// NewDaemon creates a daemon runner with the given options.
|
// NewDaemon creates a daemon runner with the given options.
|
||||||
//
|
//
|
||||||
// daemon := process.NewDaemon(process.DaemonOptions{PIDFile: "/tmp/process.pid"})
|
// Example:
|
||||||
|
//
|
||||||
|
// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"})
|
||||||
func NewDaemon(opts DaemonOptions) *Daemon {
|
func NewDaemon(opts DaemonOptions) *Daemon {
|
||||||
if opts.ShutdownTimeout == 0 {
|
if opts.ShutdownTimeout == 0 {
|
||||||
opts.ShutdownTimeout = 30 * time.Second
|
opts.ShutdownTimeout = 30 * time.Second
|
||||||
|
|
@ -72,12 +79,16 @@ func NewDaemon(opts DaemonOptions) *Daemon {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initialises the daemon (PID file, health server).
|
// Start initialises the daemon (PID file, health server).
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if err := daemon.Start(); err != nil { return err }
|
||||||
func (d *Daemon) Start() error {
|
func (d *Daemon) Start() error {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
defer d.mu.Unlock()
|
defer d.mu.Unlock()
|
||||||
|
|
||||||
if d.running {
|
if d.running {
|
||||||
return core.E("daemon.start", "daemon already running", nil)
|
return coreerr.E("Daemon.Start", "daemon already running", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.pid != nil {
|
if d.pid != nil {
|
||||||
|
|
@ -95,38 +106,52 @@ func (d *Daemon) Start() error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
d.running = true
|
|
||||||
|
|
||||||
// Auto-register if registry is set
|
// Auto-register if registry is set
|
||||||
if d.opts.Registry != nil {
|
if d.opts.Registry != nil {
|
||||||
entry := d.opts.RegistryEntry
|
entry := d.opts.RegistryEntry
|
||||||
entry.PID = currentPID()
|
entry.PID = os.Getpid()
|
||||||
if d.health != nil {
|
if d.health != nil {
|
||||||
entry.Health = d.health.Addr()
|
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 err := d.opts.Registry.Register(entry); err != nil {
|
||||||
if d.health != nil {
|
if d.health != nil {
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
|
_ = d.health.Stop(context.Background())
|
||||||
_ = d.health.Stop(shutdownCtx)
|
|
||||||
cancel()
|
|
||||||
}
|
}
|
||||||
if d.pid != nil {
|
if d.pid != nil {
|
||||||
_ = d.pid.Release()
|
_ = d.pid.Release()
|
||||||
}
|
}
|
||||||
d.running = false
|
return coreerr.E("Daemon.Start", "registry", err)
|
||||||
return core.E("daemon.start", "registry", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
d.running = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run blocks until the context is cancelled.
|
// 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 {
|
func (d *Daemon) Run(ctx context.Context) error {
|
||||||
|
if ctx == nil {
|
||||||
|
return coreerr.E("Daemon.Run", "daemon context is required", ErrDaemonContextRequired)
|
||||||
|
}
|
||||||
|
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
if !d.running {
|
if !d.running {
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
return core.E("daemon.run", "daemon not started - call Start() first", nil)
|
return coreerr.E("Daemon.Run", "daemon not started - call Start() first", nil)
|
||||||
}
|
}
|
||||||
d.mu.Unlock()
|
d.mu.Unlock()
|
||||||
|
|
||||||
|
|
@ -136,6 +161,10 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop performs graceful shutdown.
|
// Stop performs graceful shutdown.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = daemon.Stop()
|
||||||
func (d *Daemon) Stop() error {
|
func (d *Daemon) Stop() error {
|
||||||
d.mu.Lock()
|
d.mu.Lock()
|
||||||
defer d.mu.Unlock()
|
defer d.mu.Unlock()
|
||||||
|
|
@ -149,45 +178,75 @@ func (d *Daemon) Stop() error {
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), d.opts.ShutdownTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
// Mark the daemon unavailable before tearing down listeners or registry state.
|
||||||
if d.health != nil {
|
if d.health != nil {
|
||||||
d.health.SetReady(false)
|
d.health.SetReady(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.health != nil {
|
||||||
if err := d.health.Stop(shutdownCtx); err != nil {
|
if err := d.health.Stop(shutdownCtx); err != nil {
|
||||||
errs = append(errs, core.E("daemon.stop", "health server", err))
|
errs = append(errs, coreerr.E("Daemon.Stop", "health server", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.pid != nil {
|
if d.pid != nil {
|
||||||
if err := d.pid.Release(); err != nil && !isNotExist(err) {
|
if err := d.pid.Release(); err != nil && !os.IsNotExist(err) {
|
||||||
errs = append(errs, core.E("daemon.stop", "pid file", err))
|
errs = append(errs, coreerr.E("Daemon.Stop", "pid file", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-unregister
|
// Auto-unregister after the daemon has stopped serving traffic and
|
||||||
|
// relinquished its PID file.
|
||||||
if d.opts.Registry != nil {
|
if d.opts.Registry != nil {
|
||||||
if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil {
|
if err := d.opts.Registry.Unregister(d.opts.RegistryEntry.Code, d.opts.RegistryEntry.Daemon); err != nil {
|
||||||
errs = append(errs, core.E("daemon.stop", "registry", err))
|
errs = append(errs, coreerr.E("Daemon.Stop", "registry", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
d.running = false
|
d.running = false
|
||||||
|
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
return core.ErrorJoin(errs...)
|
return errors.Join(errs...)
|
||||||
}
|
}
|
||||||
return nil
|
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) {
|
func (d *Daemon) SetReady(ready bool) {
|
||||||
if d.health != nil {
|
if d.health != nil {
|
||||||
d.health.SetReady(ready)
|
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.
|
// HealthAddr returns the health server address, or empty if disabled.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// addr := daemon.HealthAddr()
|
||||||
func (d *Daemon) HealthAddr() string {
|
func (d *Daemon) HealthAddr() string {
|
||||||
if d.health != nil {
|
if d.health != nil {
|
||||||
return d.health.Addr()
|
return d.health.Addr()
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ErrDaemonContextRequired is returned when Run is called without a context.
|
||||||
|
var ErrDaemonContextRequired = coreerr.E("", "daemon context is required", nil)
|
||||||
|
|
|
||||||
244
daemon_test.go
244
daemon_test.go
|
|
@ -4,16 +4,17 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDaemon_Lifecycle_Good(t *testing.T) {
|
func TestDaemon_StartAndStop(t *testing.T) {
|
||||||
pidPath := core.JoinPath(t.TempDir(), "test.pid")
|
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||||
|
|
||||||
d := NewDaemon(DaemonOptions{
|
d := NewDaemon(DaemonOptions{
|
||||||
PIDFile: pidPath,
|
PIDFile: pidPath,
|
||||||
|
|
@ -36,7 +37,166 @@ func TestDaemon_Lifecycle_Good(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemon_AlreadyRunning_Bad(t *testing.T) {
|
func TestDaemon_StopMarksNotReadyBeforeShutdownCompletes(t *testing.T) {
|
||||||
|
blockCheck := make(chan struct{})
|
||||||
|
checkEntered := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
|
||||||
|
d := NewDaemon(DaemonOptions{
|
||||||
|
HealthAddr: "127.0.0.1:0",
|
||||||
|
ShutdownTimeout: 5 * time.Second,
|
||||||
|
HealthChecks: []HealthCheck{
|
||||||
|
func() error {
|
||||||
|
once.Do(func() { close(checkEntered) })
|
||||||
|
<-blockCheck
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := d.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
addr := d.HealthAddr()
|
||||||
|
require.NotEmpty(t, addr)
|
||||||
|
|
||||||
|
healthErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
resp, err := http.Get("http://" + addr + "/health")
|
||||||
|
if err != nil {
|
||||||
|
healthErr <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
healthErr <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-checkEntered:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("/health request did not enter the blocking check")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
stopDone <- d.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return !d.Ready()
|
||||||
|
}, 500*time.Millisecond, 10*time.Millisecond, "daemon should become not ready before shutdown completes")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-stopDone:
|
||||||
|
t.Fatalf("daemon stopped too early: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
close(blockCheck)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-stopDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("daemon stop did not finish after health check unblocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-healthErr:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("/health request did not finish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemon_StopUnregistersAfterHealthShutdownCompletes(t *testing.T) {
|
||||||
|
blockCheck := make(chan struct{})
|
||||||
|
checkEntered := make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
dir := t.TempDir()
|
||||||
|
reg := NewRegistry(filepath.Join(dir, "registry"))
|
||||||
|
|
||||||
|
d := NewDaemon(DaemonOptions{
|
||||||
|
HealthAddr: "127.0.0.1:0",
|
||||||
|
ShutdownTimeout: 5 * time.Second,
|
||||||
|
Registry: reg,
|
||||||
|
RegistryEntry: DaemonEntry{
|
||||||
|
Code: "test-app",
|
||||||
|
Daemon: "serve",
|
||||||
|
},
|
||||||
|
HealthChecks: []HealthCheck{
|
||||||
|
func() error {
|
||||||
|
once.Do(func() { close(checkEntered) })
|
||||||
|
<-blockCheck
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := d.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
addr := d.HealthAddr()
|
||||||
|
require.NotEmpty(t, addr)
|
||||||
|
|
||||||
|
healthErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
resp, err := http.Get("http://" + addr + "/health")
|
||||||
|
if err != nil {
|
||||||
|
healthErr <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
healthErr <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-checkEntered:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("/health request did not enter the blocking check")
|
||||||
|
}
|
||||||
|
|
||||||
|
stopDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
stopDone <- d.Stop()
|
||||||
|
}()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return !d.Ready()
|
||||||
|
}, 500*time.Millisecond, 10*time.Millisecond, "daemon should become not ready before shutdown completes")
|
||||||
|
|
||||||
|
_, ok := reg.Get("test-app", "serve")
|
||||||
|
assert.True(t, ok, "daemon should remain registered until health shutdown completes")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-stopDone:
|
||||||
|
t.Fatalf("daemon stopped too early: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
close(blockCheck)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-stopDone:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("daemon stop did not finish after health check unblocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
_, ok := reg.Get("test-app", "serve")
|
||||||
|
return !ok
|
||||||
|
}, 500*time.Millisecond, 10*time.Millisecond, "daemon should unregister after health shutdown completes")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-healthErr:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("/health request did not finish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemon_DoubleStartFails(t *testing.T) {
|
||||||
d := NewDaemon(DaemonOptions{
|
d := NewDaemon(DaemonOptions{
|
||||||
HealthAddr: "127.0.0.1:0",
|
HealthAddr: "127.0.0.1:0",
|
||||||
})
|
})
|
||||||
|
|
@ -50,7 +210,7 @@ func TestDaemon_AlreadyRunning_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "already running")
|
assert.Contains(t, err.Error(), "already running")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemon_RunUnstarted_Bad(t *testing.T) {
|
func TestDaemon_RunWithoutStartFails(t *testing.T) {
|
||||||
d := NewDaemon(DaemonOptions{})
|
d := NewDaemon(DaemonOptions{})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
@ -61,7 +221,15 @@ func TestDaemon_RunUnstarted_Bad(t *testing.T) {
|
||||||
assert.Contains(t, err.Error(), "not started")
|
assert.Contains(t, err.Error(), "not started")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemon_SetReady_Good(t *testing.T) {
|
func TestDaemon_RunNilContextFails(t *testing.T) {
|
||||||
|
d := NewDaemon(DaemonOptions{})
|
||||||
|
|
||||||
|
err := d.Run(nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, ErrDaemonContextRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemon_SetReady(t *testing.T) {
|
||||||
d := NewDaemon(DaemonOptions{
|
d := NewDaemon(DaemonOptions{
|
||||||
HealthAddr: "127.0.0.1:0",
|
HealthAddr: "127.0.0.1:0",
|
||||||
})
|
})
|
||||||
|
|
@ -75,25 +243,32 @@ func TestDaemon_SetReady_Good(t *testing.T) {
|
||||||
resp, _ := http.Get("http://" + addr + "/ready")
|
resp, _ := http.Get("http://" + addr + "/ready")
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
assert.True(t, d.Ready())
|
||||||
|
|
||||||
d.SetReady(false)
|
d.SetReady(false)
|
||||||
|
assert.False(t, d.Ready())
|
||||||
|
|
||||||
resp, _ = http.Get("http://" + addr + "/ready")
|
resp, _ = http.Get("http://" + addr + "/ready")
|
||||||
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemon_HealthAddrDisabled_Good(t *testing.T) {
|
func TestDaemon_ReadyWithoutHealthServer(t *testing.T) {
|
||||||
|
d := NewDaemon(DaemonOptions{})
|
||||||
|
assert.False(t, d.Ready())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDaemon_NoHealthAddrReturnsEmpty(t *testing.T) {
|
||||||
d := NewDaemon(DaemonOptions{})
|
d := NewDaemon(DaemonOptions{})
|
||||||
assert.Empty(t, d.HealthAddr())
|
assert.Empty(t, d.HealthAddr())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemon_DefaultTimeout_Good(t *testing.T) {
|
func TestDaemon_DefaultShutdownTimeout(t *testing.T) {
|
||||||
d := NewDaemon(DaemonOptions{})
|
d := NewDaemon(DaemonOptions{})
|
||||||
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
|
assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemon_RunBlocking_Good(t *testing.T) {
|
func TestDaemon_RunBlocksUntilCancelled(t *testing.T) {
|
||||||
d := NewDaemon(DaemonOptions{
|
d := NewDaemon(DaemonOptions{
|
||||||
HealthAddr: "127.0.0.1:0",
|
HealthAddr: "127.0.0.1:0",
|
||||||
})
|
})
|
||||||
|
|
@ -126,7 +301,7 @@ func TestDaemon_RunBlocking_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemon_StopIdempotent_Good(t *testing.T) {
|
func TestDaemon_StopIdempotent(t *testing.T) {
|
||||||
d := NewDaemon(DaemonOptions{})
|
d := NewDaemon(DaemonOptions{})
|
||||||
|
|
||||||
// Stop without Start should be a no-op
|
// Stop without Start should be a no-op
|
||||||
|
|
@ -134,9 +309,13 @@ func TestDaemon_StopIdempotent_Good(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDaemon_AutoRegister_Good(t *testing.T) {
|
func TestDaemon_AutoRegisters(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
reg := NewRegistry(core.JoinPath(dir, "daemons"))
|
reg := NewRegistry(filepath.Join(dir, "daemons"))
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
exe, err := os.Executable()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
d := NewDaemon(DaemonOptions{
|
d := NewDaemon(DaemonOptions{
|
||||||
HealthAddr: "127.0.0.1:0",
|
HealthAddr: "127.0.0.1:0",
|
||||||
|
|
@ -147,7 +326,7 @@ func TestDaemon_AutoRegister_Good(t *testing.T) {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
err := d.Start()
|
err = d.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should be registered
|
// Should be registered
|
||||||
|
|
@ -155,6 +334,8 @@ func TestDaemon_AutoRegister_Good(t *testing.T) {
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, os.Getpid(), entry.PID)
|
assert.Equal(t, os.Getpid(), entry.PID)
|
||||||
assert.NotEmpty(t, entry.Health)
|
assert.NotEmpty(t, entry.Health)
|
||||||
|
assert.Equal(t, wd, entry.Project)
|
||||||
|
assert.Equal(t, exe, entry.Binary)
|
||||||
|
|
||||||
// Stop should unregister
|
// Stop should unregister
|
||||||
err = d.Stop()
|
err = d.Stop()
|
||||||
|
|
@ -163,3 +344,40 @@ func TestDaemon_AutoRegister_Good(t *testing.T) {
|
||||||
_, ok = reg.Get("test-app", "serve")
|
_, ok = reg.Get("test-app", "serve")
|
||||||
assert.False(t, ok)
|
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())
|
||||||
|
}
|
||||||
|
|
|
||||||
302
docs/RFC.md
302
docs/RFC.md
|
|
@ -1,302 +0,0 @@
|
||||||
# go-process API Contract — RFC Specification
|
|
||||||
|
|
||||||
> `dappco.re/go/core/process` — Managed process execution for the Core ecosystem.
|
|
||||||
> This package is the ONLY package that imports `os/exec`. Everything else uses
|
|
||||||
> `c.Process()` which delegates to Actions registered by this package.
|
|
||||||
|
|
||||||
**Status:** v0.8.0
|
|
||||||
**Module:** `dappco.re/go/core/process`
|
|
||||||
**Depends on:** core/go v0.8.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
go-process provides the implementation behind `c.Process()`. Core defines the primitive (Section 17). go-process registers the Action handlers that make it work.
|
|
||||||
|
|
||||||
```
|
|
||||||
core/go defines: c.Process().Run(ctx, "git", "log")
|
|
||||||
→ calls c.Action("process.run").Run(ctx, opts)
|
|
||||||
|
|
||||||
go-process provides: c.Action("process.run", s.handleRun)
|
|
||||||
→ actually executes the command via os/exec
|
|
||||||
```
|
|
||||||
|
|
||||||
Without go-process registered, `c.Process().Run()` returns `Result{OK: false}`. Permission-by-registration.
|
|
||||||
|
|
||||||
### Current State (2026-03-30)
|
|
||||||
|
|
||||||
The codebase now matches the v0.8.0 target. The bullets below are the historical migration delta that was closed out:
|
|
||||||
|
|
||||||
- `service.go` — `NewService(opts) func(*Core) (any, error)` — **old factory signature**. Change to `Register(c *Core) core.Result`
|
|
||||||
- `OnStartup() error` / `OnShutdown() error` — **Change** to return `core.Result`
|
|
||||||
- `process.SetDefault(svc)` global singleton — **Remove**. Service registers in Core conclave
|
|
||||||
- Own ID generation `fmt.Sprintf("proc-%d", ...)` — **Replace** with `core.ID()`
|
|
||||||
- Custom `map[string]*ManagedProcess` — **Replace** with `core.Registry[*ManagedProcess]`
|
|
||||||
- No named Actions registered — **Add** `process.run/start/kill/list/get` during OnStartup
|
|
||||||
|
|
||||||
### File Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
service.go — main service (factory, lifecycle, process execution)
|
|
||||||
registry.go — daemon registry (PID files, health, restart)
|
|
||||||
daemon.go — DaemonEntry, managed daemon lifecycle
|
|
||||||
health.go — health check endpoints
|
|
||||||
pidfile.go — PID file management
|
|
||||||
buffer.go — output buffering
|
|
||||||
actions.go — Action payloads and Core action handlers
|
|
||||||
global.go — global Default() singleton — DELETE after migration
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Registration
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Register is the WithService factory.
|
|
||||||
//
|
|
||||||
// core.New(core.WithService(process.Register))
|
|
||||||
func Register(c *core.Core) core.Result {
|
|
||||||
svc := &Service{
|
|
||||||
ServiceRuntime: core.NewServiceRuntime(c, Options{}),
|
|
||||||
managed: core.NewRegistry[*ManagedProcess](),
|
|
||||||
}
|
|
||||||
return core.Result{Value: svc, OK: true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### OnStartup — Register Actions
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) OnStartup(ctx context.Context) core.Result {
|
|
||||||
c := s.Core()
|
|
||||||
c.Action("process.run", s.handleRun)
|
|
||||||
c.Action("process.start", s.handleStart)
|
|
||||||
c.Action("process.kill", s.handleKill)
|
|
||||||
c.Action("process.list", s.handleList)
|
|
||||||
c.Action("process.get", s.handleGet)
|
|
||||||
return core.Result{OK: true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### OnShutdown — Kill Managed Processes
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) OnShutdown(ctx context.Context) core.Result {
|
|
||||||
s.managed.Each(func(id string, p *ManagedProcess) {
|
|
||||||
p.Kill()
|
|
||||||
})
|
|
||||||
return core.Result{OK: true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Action Handlers
|
|
||||||
|
|
||||||
### process.run — Synchronous Execution
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
command := opts.String("command")
|
|
||||||
args, _ := opts.Get("args").Value.([]string)
|
|
||||||
dir := opts.String("dir")
|
|
||||||
env, _ := opts.Get("env").Value.([]string)
|
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, command, args...)
|
|
||||||
if dir != "" { cmd.Dir = dir }
|
|
||||||
if len(env) > 0 { cmd.Env = append(os.Environ(), env...) }
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{Value: string(output), OK: true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note: go-process is the ONLY package allowed to import `os` and `os/exec`.
|
|
||||||
|
|
||||||
### process.start — Detached/Background
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
command := opts.String("command")
|
|
||||||
args, _ := opts.Get("args").Value.([]string)
|
|
||||||
|
|
||||||
cmd := exec.Command(command, args...)
|
|
||||||
cmd.Dir = opts.String("dir")
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
|
|
||||||
id := core.ID()
|
|
||||||
managed := &ManagedProcess{
|
|
||||||
ID: id, PID: cmd.Process.Pid, Command: command,
|
|
||||||
cmd: cmd, done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
s.managed.Set(id, managed)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
cmd.Wait()
|
|
||||||
close(managed.done)
|
|
||||||
managed.ExitCode = cmd.ProcessState.ExitCode()
|
|
||||||
}()
|
|
||||||
|
|
||||||
return core.Result{Value: id, OK: true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### process.kill — Terminate by ID or PID
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
id := opts.String("id")
|
|
||||||
if id != "" {
|
|
||||||
r := s.managed.Get(id)
|
|
||||||
if !r.OK {
|
|
||||||
return core.Result{Value: core.E("process.kill", core.Concat("not found: ", id), nil), OK: false}
|
|
||||||
}
|
|
||||||
r.Value.(*ManagedProcess).Kill()
|
|
||||||
return core.Result{OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
pid := opts.Int("pid")
|
|
||||||
if pid > 0 {
|
|
||||||
proc, err := os.FindProcess(pid)
|
|
||||||
if err != nil { return core.Result{Value: err, OK: false} }
|
|
||||||
proc.Signal(syscall.SIGTERM)
|
|
||||||
return core.Result{OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
return core.Result{Value: core.E("process.kill", "need id or pid", nil), OK: false}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### process.list / process.get
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) handleList(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
return core.Result{Value: s.managed.Names(), OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleGet(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
id := opts.String("id")
|
|
||||||
r := s.managed.Get(id)
|
|
||||||
if !r.OK { return r }
|
|
||||||
return core.Result{Value: r.Value.(*ManagedProcess).Info(), OK: true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. ManagedProcess
|
|
||||||
|
|
||||||
```go
|
|
||||||
type ManagedProcess struct {
|
|
||||||
ID string
|
|
||||||
PID int
|
|
||||||
Command string
|
|
||||||
ExitCode int
|
|
||||||
StartedAt time.Time
|
|
||||||
cmd *exec.Cmd
|
|
||||||
done chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ManagedProcess) IsRunning() bool {
|
|
||||||
select {
|
|
||||||
case <-p.done: return false
|
|
||||||
default: return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ManagedProcess) Kill() {
|
|
||||||
if p.cmd != nil && p.cmd.Process != nil {
|
|
||||||
p.cmd.Process.Signal(syscall.SIGTERM)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ManagedProcess) Done() <-chan struct{} { return p.done }
|
|
||||||
|
|
||||||
func (p *ManagedProcess) Info() ProcessInfo {
|
|
||||||
return ProcessInfo{
|
|
||||||
ID: p.ID, PID: p.PID, Command: p.Command,
|
|
||||||
Running: p.IsRunning(), ExitCode: p.ExitCode, StartedAt: p.StartedAt,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Daemon Registry
|
|
||||||
|
|
||||||
Higher-level abstraction over `process.start`:
|
|
||||||
|
|
||||||
```
|
|
||||||
process.start → low level: start a command, get a handle
|
|
||||||
daemon.Start → high level: PID file, health endpoint, restart policy, signals
|
|
||||||
```
|
|
||||||
|
|
||||||
Daemon registry uses `core.Registry[*DaemonEntry]`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Error Handling
|
|
||||||
|
|
||||||
All errors via `core.E()`. String building via `core.Concat()`.
|
|
||||||
|
|
||||||
```go
|
|
||||||
return core.Result{Value: core.E("process.run", core.Concat("command failed: ", command), err), OK: false}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Test Strategy
|
|
||||||
|
|
||||||
AX-7: `TestFile_Function_{Good,Bad,Ugly}`
|
|
||||||
|
|
||||||
```
|
|
||||||
TestService_Register_Good — factory returns Result
|
|
||||||
TestService_OnStartup_Good — registers 5 Actions
|
|
||||||
TestService_HandleRun_Good — runs command, returns output
|
|
||||||
TestService_HandleRun_Bad — command not found
|
|
||||||
TestService_HandleRun_Ugly — timeout via context
|
|
||||||
TestService_HandleStart_Good — starts detached, returns ID
|
|
||||||
TestService_HandleStart_Bad — invalid command
|
|
||||||
TestService_HandleKill_Good — kills by ID
|
|
||||||
TestService_HandleKill_Bad — unknown ID
|
|
||||||
TestService_HandleList_Good — returns managed process IDs
|
|
||||||
TestService_OnShutdown_Good — kills all managed processes
|
|
||||||
TestService_Ugly_PermissionModel — no go-process = c.Process().Run() fails
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Quality Gates
|
|
||||||
|
|
||||||
go-process is the ONE exception — it imports `os` and `os/exec` because it IS the process primitive. All other disallowed imports still apply:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Should only find os/exec in service.go, os in service.go
|
|
||||||
grep -rn '"os"\|"os/exec"' *.go | grep -v _test.go
|
|
||||||
|
|
||||||
# No other disallowed imports
|
|
||||||
grep -rn '"io"\|"fmt"\|"errors"\|"log"\|"encoding/json"\|"path/filepath"\|"unsafe"\|"strings"' *.go \
|
|
||||||
| grep -v _test.go
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Consumer RFCs
|
|
||||||
|
|
||||||
| Package | RFC | Role |
|
|
||||||
|---------|-----|------|
|
|
||||||
| core/go | `core/go/docs/RFC.md` | Primitives — Process primitive (Section 17) |
|
|
||||||
| core/agent | `core/agent/docs/RFC.md` | Consumer — `c.Process().RunIn()` for git/build ops |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Changelog
|
|
||||||
|
|
||||||
- 2026-03-25: v0.8.0 spec — written with full core/go domain context.
|
|
||||||
|
|
@ -60,28 +60,32 @@ participate in the Core DI container and implements both `Startable` and
|
||||||
```go
|
```go
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
managed *core.Registry[*ManagedProcess]
|
processes map[string]*Process
|
||||||
|
mu sync.RWMutex
|
||||||
bufSize int
|
bufSize int
|
||||||
|
idCounter atomic.Uint64
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Key behaviours:
|
Key behaviours:
|
||||||
|
|
||||||
- **OnStartup** — registers the named Core actions `process.run`, `process.start`, `process.kill`, `process.list`, and `process.get`.
|
- **OnStartup** — currently a no-op; reserved for future initialisation.
|
||||||
- **OnShutdown** — iterates all running processes and calls `Kill()` on each,
|
- **OnShutdown** — iterates all running processes and calls `Kill()` on each,
|
||||||
ensuring no orphaned child processes when the application exits.
|
ensuring no orphaned child processes when the application exits.
|
||||||
- Process IDs are generated with `core.ID()` and stored in a Core registry.
|
- Process IDs are generated as `proc-N` using an atomic counter, guaranteeing
|
||||||
|
uniqueness without locks.
|
||||||
|
|
||||||
#### Registration
|
#### Registration
|
||||||
|
|
||||||
The service is registered with Core via a factory function:
|
The service is registered with Core via a factory function:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
core.New(core.WithService(process.Register))
|
process.NewService(process.Options{BufferSize: 2 * 1024 * 1024})
|
||||||
```
|
```
|
||||||
|
|
||||||
`Register` returns `core.Result{Value: *Service, OK: true}` — the standard
|
`NewService` returns a `func(*core.Core) (any, error)` closure — the standard
|
||||||
Core `WithService` factory signature used by the v0.8.0 contract.
|
Core service factory signature. The `Options` struct is captured by the closure
|
||||||
|
and applied when Core instantiates the service.
|
||||||
|
|
||||||
### Process
|
### Process
|
||||||
|
|
||||||
|
|
@ -159,12 +163,12 @@ const (
|
||||||
When `Service.StartWithOptions()` is called:
|
When `Service.StartWithOptions()` is called:
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Generate a unique ID with `core.ID()`
|
1. Generate unique ID (atomic counter)
|
||||||
2. Create context with cancel
|
2. Create context with cancel
|
||||||
3. Build os/exec.Cmd with dir, env, pipes
|
3. Build os/exec.Cmd with dir, env, pipes
|
||||||
4. Create RingBuffer (unless DisableCapture is set)
|
4. Create RingBuffer (unless DisableCapture is set)
|
||||||
5. cmd.Start()
|
5. cmd.Start()
|
||||||
6. Store process in the Core registry
|
6. Store process in map
|
||||||
7. Broadcast ActionProcessStarted via Core.ACTION
|
7. Broadcast ActionProcessStarted via Core.ACTION
|
||||||
8. Spawn 2 goroutines to stream stdout and stderr
|
8. Spawn 2 goroutines to stream stdout and stderr
|
||||||
- Each line is written to the RingBuffer
|
- Each line is written to the RingBuffer
|
||||||
|
|
@ -172,9 +176,8 @@ When `Service.StartWithOptions()` is called:
|
||||||
9. Spawn 1 goroutine to wait for process exit
|
9. Spawn 1 goroutine to wait for process exit
|
||||||
- Waits for output goroutines to finish first
|
- Waits for output goroutines to finish first
|
||||||
- Calls cmd.Wait()
|
- Calls cmd.Wait()
|
||||||
- Classifies the exit as exited, failed, or killed
|
- Updates process status and exit code
|
||||||
- Closes the done channel
|
- Closes the done channel
|
||||||
- Broadcasts ActionProcessKilled when the process died from a signal
|
|
||||||
- Broadcasts ActionProcessExited
|
- Broadcasts ActionProcessExited
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -293,12 +296,12 @@ File naming convention: `{code}-{daemon}.json` (slashes replaced with dashes).
|
||||||
|
|
||||||
## exec Sub-Package
|
## exec Sub-Package
|
||||||
|
|
||||||
The `exec` package (`dappco.re/go/core/process/exec`) provides a fluent
|
The `exec` package (`forge.lthn.ai/core/go-process/exec`) provides a fluent
|
||||||
wrapper around `os/exec` for simple, one-shot commands that do not need Core
|
wrapper around `os/exec` for simple, one-shot commands that do not need Core
|
||||||
integration:
|
integration:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "dappco.re/go/core/process/exec"
|
import "forge.lthn.ai/core/go-process/exec"
|
||||||
|
|
||||||
// Fluent API
|
// Fluent API
|
||||||
err := exec.Command(ctx, "go", "build", "./...").
|
err := exec.Command(ctx, "go", "build", "./...").
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,9 @@ go-process/
|
||||||
pidfile.go # PID file single-instance lock
|
pidfile.go # PID file single-instance lock
|
||||||
pidfile_test.go # PID file tests
|
pidfile_test.go # PID file tests
|
||||||
process.go # Process type and methods
|
process.go # Process type and methods
|
||||||
|
process_global.go # Global singleton and convenience API
|
||||||
process_test.go # Process tests
|
process_test.go # Process tests
|
||||||
|
global_test.go # Global API tests (concurrency)
|
||||||
registry.go # Daemon registry (JSON file store)
|
registry.go # Daemon registry (JSON file store)
|
||||||
registry_test.go # Registry tests
|
registry_test.go # Registry tests
|
||||||
runner.go # Pipeline runner (sequential, parallel, DAG)
|
runner.go # Pipeline runner (sequential, parallel, DAG)
|
||||||
|
|
@ -140,6 +142,8 @@ go-process/
|
||||||
| `ErrProcessNotFound` | No process with the given ID exists in the service |
|
| `ErrProcessNotFound` | No process with the given ID exists in the service |
|
||||||
| `ErrProcessNotRunning` | Operation requires a running process (e.g. SendInput, Signal) |
|
| `ErrProcessNotRunning` | Operation requires a running process (e.g. SendInput, Signal) |
|
||||||
| `ErrStdinNotAvailable` | Stdin pipe is nil (already closed or never created) |
|
| `ErrStdinNotAvailable` | Stdin pipe is nil (already closed or never created) |
|
||||||
|
| `ErrServiceNotInitialized` | Global convenience function called before `process.Init()` |
|
||||||
|
| `ServiceError` | Wraps service-level errors with a message string |
|
||||||
|
|
||||||
## Build Configuration
|
## Build Configuration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ description: Process management with Core IPC integration for Go applications.
|
||||||
|
|
||||||
# go-process
|
# go-process
|
||||||
|
|
||||||
`dappco.re/go/core/process` is a process management library that provides
|
`forge.lthn.ai/core/go-process` is a process management library that provides
|
||||||
spawning, monitoring, and controlling external processes with real-time output
|
spawning, monitoring, and controlling external processes with real-time output
|
||||||
streaming via the Core ACTION (IPC) system. It integrates directly with the
|
streaming via the Core ACTION (IPC) system. It integrates directly with the
|
||||||
[Core DI framework](https://dappco.re/go/core) as a first-class service.
|
[Core DI framework](https://forge.lthn.ai/core/go) as a first-class service.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -28,17 +28,22 @@ streaming via the Core ACTION (IPC) system. It integrates directly with the
|
||||||
```go
|
```go
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"dappco.re/go/core"
|
framework "forge.lthn.ai/core/go/pkg/core"
|
||||||
"dappco.re/go/core/process"
|
"forge.lthn.ai/core/go-process"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create a Core instance with the process service registered.
|
// Create a Core instance with the process service
|
||||||
c := core.New(core.WithService(process.Register))
|
c, err := framework.New(
|
||||||
|
framework.WithName("process", process.NewService(process.Options{})),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
// Retrieve the typed service
|
// Retrieve the typed service
|
||||||
svc, ok := core.ServiceFor[*process.Service](c, "process")
|
svc, err := framework.ServiceFor[*process.Service](c, "process")
|
||||||
if !ok {
|
if err != nil {
|
||||||
panic("process service not registered")
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -46,19 +51,15 @@ if !ok {
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Fire-and-forget (async)
|
// Fire-and-forget (async)
|
||||||
start := svc.Start(ctx, "go", "test", "./...")
|
proc, err := svc.Start(ctx, "go", "test", "./...")
|
||||||
if !start.OK {
|
if err != nil {
|
||||||
return start.Value.(error)
|
return err
|
||||||
}
|
}
|
||||||
proc := start.Value.(*process.Process)
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
fmt.Println(proc.Output())
|
fmt.Println(proc.Output())
|
||||||
|
|
||||||
// Synchronous convenience
|
// Synchronous convenience
|
||||||
run := svc.Run(ctx, "echo", "hello world")
|
output, err := svc.Run(ctx, "echo", "hello world")
|
||||||
if run.OK {
|
|
||||||
fmt.Println(run.Value.(string))
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Listen for Events
|
### Listen for Events
|
||||||
|
|
@ -66,7 +67,7 @@ if run.OK {
|
||||||
Process lifecycle events are broadcast through Core's ACTION system:
|
Process lifecycle events are broadcast through Core's ACTION system:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
|
c.RegisterAction(func(c *framework.Core, msg framework.Message) error {
|
||||||
switch m := msg.(type) {
|
switch m := msg.(type) {
|
||||||
case process.ActionProcessStarted:
|
case process.ActionProcessStarted:
|
||||||
fmt.Printf("Started: %s (PID %d)\n", m.Command, m.PID)
|
fmt.Printf("Started: %s (PID %d)\n", m.Command, m.PID)
|
||||||
|
|
@ -77,24 +78,24 @@ c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
|
||||||
case process.ActionProcessKilled:
|
case process.ActionProcessKilled:
|
||||||
fmt.Printf("Killed with %s\n", m.Signal)
|
fmt.Printf("Killed with %s\n", m.Signal)
|
||||||
}
|
}
|
||||||
return core.Result{OK: true}
|
return nil
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
### Permission Model
|
### Global Convenience API
|
||||||
|
|
||||||
Core's process primitive delegates to named actions registered by this module.
|
For applications that only need a single process service, a global singleton
|
||||||
Without `process.Register`, `c.Process().Run(...)` fails with `OK: false`.
|
is available:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
c := core.New()
|
// Initialise once at startup
|
||||||
r := c.Process().Run(ctx, "echo", "blocked")
|
process.Init(coreInstance)
|
||||||
fmt.Println(r.OK) // false
|
|
||||||
|
|
||||||
c = core.New(core.WithService(process.Register))
|
// Then use package-level functions anywhere
|
||||||
_ = c.ServiceStartup(ctx, nil)
|
proc, _ := process.Start(ctx, "ls", "-la")
|
||||||
r = c.Process().Run(ctx, "echo", "allowed")
|
output, _ := process.Run(ctx, "date")
|
||||||
fmt.Println(r.OK) // true
|
procs := process.List()
|
||||||
|
running := process.Running()
|
||||||
```
|
```
|
||||||
|
|
||||||
## Package Layout
|
## Package Layout
|
||||||
|
|
@ -108,7 +109,7 @@ fmt.Println(r.OK) // true
|
||||||
|
|
||||||
| Field | Value |
|
| Field | Value |
|
||||||
|-------|-------|
|
|-------|-------|
|
||||||
| Module path | `dappco.re/go/core/process` |
|
| Module path | `forge.lthn.ai/core/go-process` |
|
||||||
| Go version | 1.26.0 |
|
| Go version | 1.26.0 |
|
||||||
| Licence | EUPL-1.2 |
|
| Licence | EUPL-1.2 |
|
||||||
|
|
||||||
|
|
@ -116,7 +117,7 @@ fmt.Println(r.OK) // true
|
||||||
|
|
||||||
| Module | Purpose |
|
| Module | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `dappco.re/go/core` | Core DI framework (`ServiceRuntime`, `Core.ACTION`, lifecycle interfaces) |
|
| `forge.lthn.ai/core/go` | Core DI framework (`ServiceRuntime`, `Core.ACTION`, lifecycle interfaces) |
|
||||||
| `github.com/stretchr/testify` | Test assertions (test-only) |
|
| `github.com/stretchr/testify` | Test assertions (test-only) |
|
||||||
|
|
||||||
The package has no other runtime dependencies beyond the Go standard library
|
The package has no other runtime dependencies beyond the Go standard library
|
||||||
|
|
|
||||||
|
|
@ -1,151 +0,0 @@
|
||||||
# go-process v0.7.0 — Core Alignment
|
|
||||||
|
|
||||||
> Written by Cladius with full core/go domain context (2026-03-25).
|
|
||||||
> Read core/go docs/RFC.md Section 17 for the full Process primitive spec.
|
|
||||||
|
|
||||||
## What Changed in core/go
|
|
||||||
|
|
||||||
core/go v0.8.0 added:
|
|
||||||
- `c.Process()` — primitive that delegates to `c.Action("process.*")`
|
|
||||||
- `c.Action("name")` — named action registry with panic recovery
|
|
||||||
- `Startable.OnStartup()` returns `core.Result` (not `error`)
|
|
||||||
- `Registry[T]` — universal thread-safe named collection
|
|
||||||
- `core.ID()` — unique identifier primitive
|
|
||||||
|
|
||||||
go-process needs to align its factory signature and register process Actions.
|
|
||||||
|
|
||||||
## Step 1: Fix Factory Signature
|
|
||||||
|
|
||||||
Current (`service.go`):
|
|
||||||
```go
|
|
||||||
func NewService(opts Options) func(*core.Core) (any, error) {
|
|
||||||
```
|
|
||||||
|
|
||||||
Target:
|
|
||||||
```go
|
|
||||||
func Register(c *core.Core) core.Result {
|
|
||||||
svc := &Service{
|
|
||||||
ServiceRuntime: core.NewServiceRuntime(c, Options{}),
|
|
||||||
processes: make(map[string]*ManagedProcess),
|
|
||||||
}
|
|
||||||
return core.Result{Value: svc, OK: true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This matches `core.WithService(process.Register)` — the standard pattern.
|
|
||||||
|
|
||||||
## Step 2: Register Process Actions During OnStartup
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) OnStartup(ctx context.Context) core.Result {
|
|
||||||
c := s.Core()
|
|
||||||
|
|
||||||
// Register named actions — these are what c.Process() calls
|
|
||||||
c.Action("process.run", s.handleRun)
|
|
||||||
c.Action("process.start", s.handleStart)
|
|
||||||
c.Action("process.kill", s.handleKill)
|
|
||||||
|
|
||||||
return core.Result{OK: true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: `OnStartup` now returns `core.Result` not `error`.
|
|
||||||
|
|
||||||
## Step 3: Implement Action Handlers
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) handleRun(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
command := opts.String("command")
|
|
||||||
args, _ := opts.Get("args").Value.([]string)
|
|
||||||
dir := opts.String("dir")
|
|
||||||
env, _ := opts.Get("env").Value.([]string)
|
|
||||||
|
|
||||||
// Use existing RunWithOptions internally
|
|
||||||
out, err := s.RunWithOptions(ctx, RunOptions{
|
|
||||||
Command: command,
|
|
||||||
Args: args,
|
|
||||||
Dir: dir,
|
|
||||||
Env: env,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{Value: out, OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleStart(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
// Detached process — returns handle ID
|
|
||||||
command := opts.String("command")
|
|
||||||
args, _ := opts.Get("args").Value.([]string)
|
|
||||||
|
|
||||||
handle, err := s.Start(ctx, StartOptions{
|
|
||||||
Command: command,
|
|
||||||
Args: args,
|
|
||||||
Dir: opts.String("dir"),
|
|
||||||
Detach: opts.Bool("detach"),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return core.Result{Value: err, OK: false}
|
|
||||||
}
|
|
||||||
return core.Result{Value: handle.ID, OK: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) handleKill(ctx context.Context, opts core.Options) core.Result {
|
|
||||||
id := opts.String("id")
|
|
||||||
pid := opts.Int("pid")
|
|
||||||
|
|
||||||
if id != "" {
|
|
||||||
return s.KillByID(id)
|
|
||||||
}
|
|
||||||
return s.KillByPID(pid)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 4: Remove Global Singleton Pattern
|
|
||||||
|
|
||||||
Current: `process.SetDefault(svc)` and `process.Default()` global state.
|
|
||||||
|
|
||||||
Target: Service registered in Core's conclave. No global state.
|
|
||||||
|
|
||||||
The `ensureProcess()` hack in core/agent exists because go-process doesn't register properly. Once this is done, that bridge can be deleted.
|
|
||||||
|
|
||||||
## Step 5: Update OnShutdown
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) OnShutdown(ctx context.Context) core.Result {
|
|
||||||
// Kill all managed processes
|
|
||||||
for _, p := range s.processes {
|
|
||||||
p.Kill()
|
|
||||||
}
|
|
||||||
return core.Result{OK: true}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Step 6: Use core.ID() for Process IDs
|
|
||||||
|
|
||||||
Current: `fmt.Sprintf("proc-%d", s.idCounter.Add(1))`
|
|
||||||
|
|
||||||
Target: `core.ID()` — consistent format across ecosystem.
|
|
||||||
|
|
||||||
## Step 7: AX-7 Tests
|
|
||||||
|
|
||||||
All tests renamed to `TestFile_Function_{Good,Bad,Ugly}`:
|
|
||||||
- `TestService_Register_Good` — factory returns Result
|
|
||||||
- `TestService_HandleRun_Good` — runs command via Action
|
|
||||||
- `TestService_HandleRun_Bad` — command not found
|
|
||||||
- `TestService_HandleKill_Good` — kills by ID
|
|
||||||
- `TestService_OnStartup_Good` — registers Actions
|
|
||||||
- `TestService_OnShutdown_Good` — kills all processes
|
|
||||||
|
|
||||||
## What This Unlocks
|
|
||||||
|
|
||||||
Once go-process v0.7.0 ships:
|
|
||||||
- `core.New(core.WithService(process.Register))` — standard registration
|
|
||||||
- `c.Process().Run(ctx, "git", "log")` — works end-to-end
|
|
||||||
- core/agent deletes `proc.go`, `ensureProcess()`, `ProcessRegister`
|
|
||||||
- Tests can mock process execution by registering a fake handler
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
- core/go v0.8.0 (already done — Action system, Process primitive, Result lifecycle)
|
|
||||||
- No other deps change
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
// Package exec provides a small command wrapper around `os/exec` with
|
|
||||||
// structured logging hooks.
|
|
||||||
//
|
|
||||||
// ctx := context.Background()
|
|
||||||
// out, err := exec.Command(ctx, "echo", "hello").Output()
|
|
||||||
package exec
|
|
||||||
160
exec/exec.go
160
exec/exec.go
|
|
@ -3,27 +3,34 @@ package exec
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"io"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
goio "io"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrCommandContextRequired is returned when a command is created without a context.
|
||||||
|
var ErrCommandContextRequired = coreerr.E("", "exec: command context is required", nil)
|
||||||
|
|
||||||
// Options configures command execution.
|
// Options configures command execution.
|
||||||
//
|
|
||||||
// opts := exec.Options{Dir: "/workspace", Env: []string{"CI=1"}}
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Dir string
|
Dir string
|
||||||
Env []string
|
Env []string
|
||||||
Stdin io.Reader
|
Stdin goio.Reader
|
||||||
Stdout io.Writer
|
Stdout goio.Writer
|
||||||
Stderr io.Writer
|
Stderr goio.Writer
|
||||||
|
// Background runs the command asynchronously and returns from Run immediately.
|
||||||
|
Background bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Command wraps `os/exec.Command` with logging and context.
|
// Command wraps os/exec.Command with logging and context.
|
||||||
//
|
//
|
||||||
// cmd := exec.Command(ctx, "git", "status").WithDir("/workspace")
|
// Example:
|
||||||
|
//
|
||||||
|
// cmd := exec.Command(ctx, "go", "test", "./...")
|
||||||
func Command(ctx context.Context, name string, args ...string) *Cmd {
|
func Command(ctx context.Context, name string, args ...string) *Cmd {
|
||||||
return &Cmd{
|
return &Cmd{
|
||||||
name: name,
|
name: name,
|
||||||
|
|
@ -43,31 +50,51 @@ type Cmd struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithDir sets the working directory.
|
// WithDir sets the working directory.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cmd.WithDir("/tmp")
|
||||||
func (c *Cmd) WithDir(dir string) *Cmd {
|
func (c *Cmd) WithDir(dir string) *Cmd {
|
||||||
c.opts.Dir = dir
|
c.opts.Dir = dir
|
||||||
return c
|
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 {
|
func (c *Cmd) WithEnv(env []string) *Cmd {
|
||||||
c.opts.Env = env
|
c.opts.Env = env
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithStdin sets stdin.
|
// WithStdin sets stdin.
|
||||||
func (c *Cmd) WithStdin(r io.Reader) *Cmd {
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cmd.WithStdin(strings.NewReader("input"))
|
||||||
|
func (c *Cmd) WithStdin(r goio.Reader) *Cmd {
|
||||||
c.opts.Stdin = r
|
c.opts.Stdin = r
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithStdout sets stdout.
|
// WithStdout sets stdout.
|
||||||
func (c *Cmd) WithStdout(w io.Writer) *Cmd {
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cmd.WithStdout(os.Stdout)
|
||||||
|
func (c *Cmd) WithStdout(w goio.Writer) *Cmd {
|
||||||
c.opts.Stdout = w
|
c.opts.Stdout = w
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithStderr sets stderr.
|
// WithStderr sets stderr.
|
||||||
func (c *Cmd) WithStderr(w io.Writer) *Cmd {
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// cmd.WithStderr(os.Stderr)
|
||||||
|
func (c *Cmd) WithStderr(w goio.Writer) *Cmd {
|
||||||
c.opts.Stderr = w
|
c.opts.Stderr = w
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
@ -79,14 +106,56 @@ func (c *Cmd) WithLogger(l Logger) *Cmd {
|
||||||
return c
|
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.
|
// Run executes the command and waits for it to finish.
|
||||||
// It automatically logs the command execution at debug level.
|
// It automatically logs the command execution at debug level.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if err := cmd.Run(); err != nil { return err }
|
||||||
func (c *Cmd) Run() error {
|
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")
|
c.logDebug("executing command")
|
||||||
|
|
||||||
if err := c.cmd.Run(); err != nil {
|
if err := c.cmd.Run(); err != nil {
|
||||||
wrapped := wrapError("exec.cmd.run", err, c.name, c.args)
|
wrapped := wrapError("Cmd.Run", err, c.name, c.args)
|
||||||
c.logError("command failed", wrapped)
|
c.logError("command failed", wrapped)
|
||||||
return wrapped
|
return wrapped
|
||||||
}
|
}
|
||||||
|
|
@ -94,13 +163,23 @@ func (c *Cmd) Run() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output runs the command and returns its standard output.
|
// Output runs the command and returns its standard output.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// out, err := cmd.Output()
|
||||||
func (c *Cmd) Output() ([]byte, error) {
|
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")
|
c.logDebug("executing command")
|
||||||
|
|
||||||
out, err := c.cmd.Output()
|
out, err := c.cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wrapped := wrapError("exec.cmd.output", err, c.name, c.args)
|
wrapped := wrapError("Cmd.Output", err, c.name, c.args)
|
||||||
c.logError("command failed", wrapped)
|
c.logError("command failed", wrapped)
|
||||||
return nil, wrapped
|
return nil, wrapped
|
||||||
}
|
}
|
||||||
|
|
@ -108,26 +187,35 @@ func (c *Cmd) Output() ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CombinedOutput runs the command and returns its combined standard output and standard error.
|
// CombinedOutput runs the command and returns its combined standard output and standard error.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// out, err := cmd.CombinedOutput()
|
||||||
func (c *Cmd) CombinedOutput() ([]byte, error) {
|
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")
|
c.logDebug("executing command")
|
||||||
|
|
||||||
out, err := c.cmd.CombinedOutput()
|
out, err := c.cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wrapped := wrapError("exec.cmd.combined_output", err, c.name, c.args)
|
wrapped := wrapError("Cmd.CombinedOutput", err, c.name, c.args)
|
||||||
c.logError("command failed", wrapped)
|
c.logError("command failed", wrapped)
|
||||||
return out, wrapped
|
return out, wrapped
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cmd) prepare() {
|
func (c *Cmd) prepare() error {
|
||||||
ctx := c.ctx
|
if c.ctx == nil {
|
||||||
if ctx == nil {
|
return coreerr.E("Cmd.prepare", "exec: command context is required", ErrCommandContextRequired)
|
||||||
ctx = context.Background()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.cmd = exec.CommandContext(ctx, c.name, c.args...)
|
c.cmd = exec.CommandContext(c.ctx, c.name, c.args...)
|
||||||
|
|
||||||
c.cmd.Dir = c.opts.Dir
|
c.cmd.Dir = c.opts.Dir
|
||||||
if len(c.opts.Env) > 0 {
|
if len(c.opts.Env) > 0 {
|
||||||
|
|
@ -137,27 +225,31 @@ func (c *Cmd) prepare() {
|
||||||
c.cmd.Stdin = c.opts.Stdin
|
c.cmd.Stdin = c.opts.Stdin
|
||||||
c.cmd.Stdout = c.opts.Stdout
|
c.cmd.Stdout = c.opts.Stdout
|
||||||
c.cmd.Stderr = c.opts.Stderr
|
c.cmd.Stderr = c.opts.Stderr
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunQuiet executes the command suppressing stdout unless there is an error.
|
// RunQuiet executes the command suppressing stdout unless there is an error.
|
||||||
// Useful for internal commands.
|
// Useful for internal commands.
|
||||||
//
|
//
|
||||||
// _ = exec.RunQuiet(ctx, "go", "test", "./...")
|
// Example:
|
||||||
|
//
|
||||||
|
// err := exec.RunQuiet(ctx, "go", "vet", "./...")
|
||||||
func RunQuiet(ctx context.Context, name string, args ...string) error {
|
func RunQuiet(ctx context.Context, name string, args ...string) error {
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
cmd := Command(ctx, name, args...).WithStderr(&stderr)
|
cmd := Command(ctx, name, args...).WithStderr(&stderr)
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return core.E("exec.run_quiet", core.Trim(stderr.String()), err)
|
// Include stderr in error message
|
||||||
|
return coreerr.E("RunQuiet", strings.TrimSpace(stderr.String()), err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func wrapError(caller string, err error, name string, args []string) error {
|
func wrapError(caller string, err error, name string, args []string) error {
|
||||||
cmdStr := commandString(name, args)
|
cmdStr := name + " " + strings.Join(args, " ")
|
||||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
return core.E(caller, core.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err)
|
return coreerr.E(caller, fmt.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err)
|
||||||
}
|
}
|
||||||
return core.E(caller, core.Sprintf("failed to execute %q", cmdStr), err)
|
return coreerr.E(caller, fmt.Sprintf("failed to execute %q", cmdStr), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cmd) getLogger() Logger {
|
func (c *Cmd) getLogger() Logger {
|
||||||
|
|
@ -168,17 +260,9 @@ func (c *Cmd) getLogger() Logger {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cmd) logDebug(msg string) {
|
func (c *Cmd) logDebug(msg string) {
|
||||||
c.getLogger().Debug(msg, "cmd", c.name, "args", core.Join(" ", c.args...))
|
c.getLogger().Debug(msg, "cmd", c.name, "args", strings.Join(c.args, " "))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Cmd) logError(msg string, err error) {
|
func (c *Cmd) logError(msg string, err error) {
|
||||||
c.getLogger().Error(msg, "cmd", c.name, "args", core.Join(" ", c.args...), "err", err)
|
c.getLogger().Error(msg, "cmd", c.name, "args", strings.Join(c.args, " "), "err", err)
|
||||||
}
|
|
||||||
|
|
||||||
func commandString(name string, args []string) string {
|
|
||||||
if len(args) == 0 {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
parts := append([]string{name}, args...)
|
|
||||||
return core.Join(" ", parts...)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,17 @@ package exec_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
|
||||||
"dappco.re/go/core/process/exec"
|
"dappco.re/go/core/process/exec"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mockLogger captures log calls for testing
|
// mockLogger captures log calls for testing
|
||||||
|
|
@ -27,7 +34,7 @@ func (m *mockLogger) Error(msg string, keyvals ...any) {
|
||||||
m.errorCalls = append(m.errorCalls, logCall{msg, keyvals})
|
m.errorCalls = append(m.errorCalls, logCall{msg, keyvals})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommand_Run_Good(t *testing.T) {
|
func TestCommand_Run_Good_LogsDebug(t *testing.T) {
|
||||||
logger := &mockLogger{}
|
logger := &mockLogger{}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
|
@ -49,7 +56,7 @@ func TestCommand_Run_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommand_Run_Bad(t *testing.T) {
|
func TestCommand_Run_Bad_LogsError(t *testing.T) {
|
||||||
logger := &mockLogger{}
|
logger := &mockLogger{}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
|
@ -71,14 +78,6 @@ func TestCommand_Run_Bad(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommand_Run_WithNilContext_Good(t *testing.T) {
|
|
||||||
var ctx context.Context
|
|
||||||
|
|
||||||
if err := exec.Command(ctx, "echo", "hello").Run(); err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommand_Output_Good(t *testing.T) {
|
func TestCommand_Output_Good(t *testing.T) {
|
||||||
logger := &mockLogger{}
|
logger := &mockLogger{}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
@ -89,7 +88,7 @@ func TestCommand_Output_Good(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if core.Trim(string(out)) != "test" {
|
if strings.TrimSpace(string(out)) != "test" {
|
||||||
t.Errorf("expected 'test', got %q", string(out))
|
t.Errorf("expected 'test', got %q", string(out))
|
||||||
}
|
}
|
||||||
if len(logger.debugCalls) != 1 {
|
if len(logger.debugCalls) != 1 {
|
||||||
|
|
@ -107,7 +106,7 @@ func TestCommand_CombinedOutput_Good(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if core.Trim(string(out)) != "combined" {
|
if strings.TrimSpace(string(out)) != "combined" {
|
||||||
t.Errorf("expected 'combined', got %q", string(out))
|
t.Errorf("expected 'combined', got %q", string(out))
|
||||||
}
|
}
|
||||||
if len(logger.debugCalls) != 1 {
|
if len(logger.debugCalls) != 1 {
|
||||||
|
|
@ -115,14 +114,14 @@ func TestCommand_CombinedOutput_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNopLogger_Methods_Good(t *testing.T) {
|
func TestNopLogger(t *testing.T) {
|
||||||
// Verify NopLogger doesn't panic
|
// Verify NopLogger doesn't panic
|
||||||
var nop exec.NopLogger
|
var nop exec.NopLogger
|
||||||
nop.Debug("msg", "key", "val")
|
nop.Debug("msg", "key", "val")
|
||||||
nop.Error("msg", "key", "val")
|
nop.Error("msg", "key", "val")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLogger_SetDefault_Good(t *testing.T) {
|
func TestSetDefaultLogger(t *testing.T) {
|
||||||
original := exec.DefaultLogger()
|
original := exec.DefaultLogger()
|
||||||
defer exec.SetDefaultLogger(original)
|
defer exec.SetDefaultLogger(original)
|
||||||
|
|
||||||
|
|
@ -140,7 +139,30 @@ func TestLogger_SetDefault_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommand_UsesDefaultLogger_Good(t *testing.T) {
|
func TestDefaultLogger_IsConcurrentSafe(t *testing.T) {
|
||||||
|
original := exec.DefaultLogger()
|
||||||
|
defer exec.SetDefaultLogger(original)
|
||||||
|
|
||||||
|
logger := &mockLogger{}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 32; i++ {
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
exec.SetDefaultLogger(logger)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = exec.DefaultLogger()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
assert.NotNil(t, exec.DefaultLogger())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommand_UsesDefaultLogger(t *testing.T) {
|
||||||
original := exec.DefaultLogger()
|
original := exec.DefaultLogger()
|
||||||
defer exec.SetDefaultLogger(original)
|
defer exec.SetDefaultLogger(original)
|
||||||
|
|
||||||
|
|
@ -155,7 +177,7 @@ func TestCommand_UsesDefaultLogger_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommand_WithDir_Good(t *testing.T) {
|
func TestCommand_WithDir(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
out, err := exec.Command(ctx, "pwd").
|
out, err := exec.Command(ctx, "pwd").
|
||||||
WithDir("/tmp").
|
WithDir("/tmp").
|
||||||
|
|
@ -164,13 +186,13 @@ func TestCommand_WithDir_Good(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
trimmed := core.Trim(string(out))
|
trimmed := strings.TrimSpace(string(out))
|
||||||
if trimmed != "/tmp" && trimmed != "/private/tmp" {
|
if trimmed != "/tmp" && trimmed != "/private/tmp" {
|
||||||
t.Errorf("expected /tmp or /private/tmp, got %q", trimmed)
|
t.Errorf("expected /tmp or /private/tmp, got %q", trimmed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommand_WithEnv_Good(t *testing.T) {
|
func TestCommand_WithEnv(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
out, err := exec.Command(ctx, "sh", "-c", "echo $TEST_EXEC_VAR").
|
out, err := exec.Command(ctx, "sh", "-c", "echo $TEST_EXEC_VAR").
|
||||||
WithEnv([]string{"TEST_EXEC_VAR=exec_val"}).
|
WithEnv([]string{"TEST_EXEC_VAR=exec_val"}).
|
||||||
|
|
@ -179,32 +201,100 @@ func TestCommand_WithEnv_Good(t *testing.T) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if core.Trim(string(out)) != "exec_val" {
|
if strings.TrimSpace(string(out)) != "exec_val" {
|
||||||
t.Errorf("expected 'exec_val', got %q", string(out))
|
t.Errorf("expected 'exec_val', got %q", string(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCommand_WithStdinStdoutStderr_Good(t *testing.T) {
|
func TestCommand_WithStdinStdoutStderr(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
input := core.NewReader("piped input\n")
|
input := strings.NewReader("piped input\n")
|
||||||
stdout := core.NewBuilder()
|
var stdout, stderr strings.Builder
|
||||||
stderr := core.NewBuilder()
|
|
||||||
|
|
||||||
err := exec.Command(ctx, "cat").
|
err := exec.Command(ctx, "cat").
|
||||||
WithStdin(input).
|
WithStdin(input).
|
||||||
WithStdout(stdout).
|
WithStdout(&stdout).
|
||||||
WithStderr(stderr).
|
WithStderr(&stderr).
|
||||||
WithLogger(&mockLogger{}).
|
WithLogger(&mockLogger{}).
|
||||||
Run()
|
Run()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if core.Trim(stdout.String()) != "piped input" {
|
if strings.TrimSpace(stdout.String()) != "piped input" {
|
||||||
t.Errorf("expected 'piped input', got %q", stdout.String())
|
t.Errorf("expected 'piped input', got %q", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunQuiet_Command_Good(t *testing.T) {
|
func TestCommand_Run_Background(t *testing.T) {
|
||||||
|
logger := &mockLogger{}
|
||||||
|
ctx := context.Background()
|
||||||
|
dir := t.TempDir()
|
||||||
|
marker := filepath.Join(dir, "marker.txt")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := exec.Command(ctx, "sh", "-c", fmt.Sprintf("sleep 0.2; printf done > %q", marker)).
|
||||||
|
WithBackground(true).
|
||||||
|
WithLogger(logger).
|
||||||
|
Run()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||||
|
t.Fatalf("background run took too long: %s", elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for {
|
||||||
|
data, readErr := os.ReadFile(marker)
|
||||||
|
if readErr == nil && strings.TrimSpace(string(data)) == "done" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("background command did not create marker file")
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommand_NilContextRejected(t *testing.T) {
|
||||||
|
t.Run("start", func(t *testing.T) {
|
||||||
|
err := exec.Command(nil, "echo", "test").Start()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, exec.ErrCommandContextRequired)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run", func(t *testing.T) {
|
||||||
|
err := exec.Command(nil, "echo", "test").Run()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, exec.ErrCommandContextRequired)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("output", func(t *testing.T) {
|
||||||
|
_, err := exec.Command(nil, "echo", "test").Output()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, exec.ErrCommandContextRequired)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("combined output", func(t *testing.T) {
|
||||||
|
_, err := exec.Command(nil, "echo", "test").CombinedOutput()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, exec.ErrCommandContextRequired)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommand_Output_BackgroundRejected(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := exec.Command(ctx, "echo", "test").
|
||||||
|
WithBackground(true).
|
||||||
|
Output()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunQuiet_Good(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := exec.RunQuiet(ctx, "echo", "quiet")
|
err := exec.RunQuiet(ctx, "echo", "quiet")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -212,7 +302,7 @@ func TestRunQuiet_Command_Good(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunQuiet_Command_Bad(t *testing.T) {
|
func TestRunQuiet_Bad(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
err := exec.RunQuiet(ctx, "sh", "-c", "echo fail >&2; exit 1")
|
err := exec.RunQuiet(ctx, "sh", "-c", "echo fail >&2; exit 1")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,23 @@
|
||||||
package exec
|
package exec
|
||||||
|
|
||||||
|
import "sync"
|
||||||
|
|
||||||
// Logger interface for command execution logging.
|
// Logger interface for command execution logging.
|
||||||
// Compatible with pkg/log.Logger and other structured loggers.
|
// Compatible with pkg/log.Logger and other structured loggers.
|
||||||
//
|
|
||||||
// exec.SetDefaultLogger(myLogger)
|
|
||||||
type Logger interface {
|
type Logger interface {
|
||||||
// Debug logs a debug-level message with optional key-value pairs.
|
// Debug logs a debug-level message with optional key-value pairs.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// logger.Debug("starting", "cmd", "go")
|
||||||
Debug(msg string, keyvals ...any)
|
Debug(msg string, keyvals ...any)
|
||||||
// Error logs an error-level message with optional key-value pairs.
|
// Error logs an error-level message with optional key-value pairs.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// logger.Error("failed", "cmd", "go", "err", err)
|
||||||
Error(msg string, keyvals ...any)
|
Error(msg string, keyvals ...any)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NopLogger is a no-op logger that discards all messages.
|
// NopLogger is a no-op logger that discards all messages.
|
||||||
//
|
|
||||||
// var logger exec.NopLogger
|
|
||||||
type NopLogger struct{}
|
type NopLogger struct{}
|
||||||
|
|
||||||
// Debug discards the message (no-op implementation).
|
// Debug discards the message (no-op implementation).
|
||||||
|
|
@ -22,13 +26,23 @@ func (NopLogger) Debug(string, ...any) {}
|
||||||
// Error discards the message (no-op implementation).
|
// Error discards the message (no-op implementation).
|
||||||
func (NopLogger) Error(string, ...any) {}
|
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.
|
// SetDefaultLogger sets the package-level default logger.
|
||||||
// Commands without an explicit logger will use this.
|
// Commands without an explicit logger will use this.
|
||||||
//
|
//
|
||||||
// exec.SetDefaultLogger(myLogger)
|
// Example:
|
||||||
|
//
|
||||||
|
// exec.SetDefaultLogger(logger)
|
||||||
func SetDefaultLogger(l Logger) {
|
func SetDefaultLogger(l Logger) {
|
||||||
|
defaultLoggerMu.Lock()
|
||||||
|
defer defaultLoggerMu.Unlock()
|
||||||
|
|
||||||
if l == nil {
|
if l == nil {
|
||||||
l = NopLogger{}
|
l = NopLogger{}
|
||||||
}
|
}
|
||||||
|
|
@ -37,7 +51,12 @@ func SetDefaultLogger(l Logger) {
|
||||||
|
|
||||||
// DefaultLogger returns the current default logger.
|
// DefaultLogger returns the current default logger.
|
||||||
//
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
// logger := exec.DefaultLogger()
|
// logger := exec.DefaultLogger()
|
||||||
func DefaultLogger() Logger {
|
func DefaultLogger() Logger {
|
||||||
|
defaultLoggerMu.RLock()
|
||||||
|
defer defaultLoggerMu.RUnlock()
|
||||||
|
|
||||||
return defaultLogger
|
return defaultLogger
|
||||||
}
|
}
|
||||||
|
|
|
||||||
456
global_test.go
Normal file
456
global_test.go
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
framework "dappco.re/go/core"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGlobal_DefaultNotInitialized(t *testing.T) {
|
||||||
|
// Reset global state for this test
|
||||||
|
old := defaultService.Swap(nil)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
assert.Nil(t, Default())
|
||||||
|
|
||||||
|
_, err := Start(context.Background(), "echo", "test")
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
_, err = Run(context.Background(), "echo", "test")
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
_, err = Get("proc-1")
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
_, err = Output("proc-1")
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
err = Input("proc-1", "test")
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
err = CloseStdin("proc-1")
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
assert.Nil(t, List())
|
||||||
|
assert.Nil(t, Running())
|
||||||
|
|
||||||
|
err = Remove("proc-1")
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
// Clear is a no-op without a default service.
|
||||||
|
Clear()
|
||||||
|
|
||||||
|
err = Kill("proc-1")
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
err = KillPID(1234)
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
_, err = StartWithOptions(context.Background(), RunOptions{Command: "echo"})
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
|
||||||
|
_, err = RunWithOptions(context.Background(), RunOptions{Command: "echo"})
|
||||||
|
assert.ErrorIs(t, err, ErrServiceNotInitialized)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGlobalTestService(t *testing.T) *Service {
|
||||||
|
t.Helper()
|
||||||
|
c := framework.New()
|
||||||
|
factory := NewService(Options{})
|
||||||
|
raw, err := factory(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return raw.(*Service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_SetDefault(t *testing.T) {
|
||||||
|
t.Run("sets and retrieves service", func(t *testing.T) {
|
||||||
|
old := defaultService.Swap(nil)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
svc := newGlobalTestService(t)
|
||||||
|
|
||||||
|
err := SetDefault(svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, svc, Default())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("errors on nil", func(t *testing.T) {
|
||||||
|
err := SetDefault(nil)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_Register(t *testing.T) {
|
||||||
|
c := framework.New()
|
||||||
|
|
||||||
|
result := Register(c)
|
||||||
|
require.True(t, result.OK)
|
||||||
|
|
||||||
|
svc, ok := result.Value.(*Service)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotNil(t, svc)
|
||||||
|
assert.NotNil(t, svc.ServiceRuntime)
|
||||||
|
assert.Equal(t, DefaultBufferSize, svc.bufSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_ConcurrentDefault(t *testing.T) {
|
||||||
|
old := defaultService.Swap(nil)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
svc := newGlobalTestService(t)
|
||||||
|
|
||||||
|
err := SetDefault(svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
s := Default()
|
||||||
|
assert.NotNil(t, s)
|
||||||
|
assert.Equal(t, svc, s)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_ConcurrentSetDefault(t *testing.T) {
|
||||||
|
old := defaultService.Swap(nil)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var services []*Service
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
svc := newGlobalTestService(t)
|
||||||
|
services = append(services, svc)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, svc := range services {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(s *Service) {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = SetDefault(s)
|
||||||
|
}(svc)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
final := Default()
|
||||||
|
assert.NotNil(t, final)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, svc := range services {
|
||||||
|
if svc == final {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, found, "Default should be one of the set services")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_ConcurrentOperations(t *testing.T) {
|
||||||
|
old := defaultService.Swap(nil)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
svc := newGlobalTestService(t)
|
||||||
|
|
||||||
|
err := SetDefault(svc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var processes []*Process
|
||||||
|
var procMu sync.Mutex
|
||||||
|
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
proc, err := Start(context.Background(), "echo", "concurrent")
|
||||||
|
if err == nil {
|
||||||
|
procMu.Lock()
|
||||||
|
processes = append(processes, proc)
|
||||||
|
procMu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_ = List()
|
||||||
|
_ = Running()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
procMu.Lock()
|
||||||
|
for _, p := range processes {
|
||||||
|
<-p.Done()
|
||||||
|
}
|
||||||
|
procMu.Unlock()
|
||||||
|
|
||||||
|
assert.Len(t, processes, 20)
|
||||||
|
|
||||||
|
var wg2 sync.WaitGroup
|
||||||
|
for _, p := range processes {
|
||||||
|
wg2.Add(1)
|
||||||
|
go func(id string) {
|
||||||
|
defer wg2.Done()
|
||||||
|
got, err := Get(id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, got)
|
||||||
|
}(p.ID)
|
||||||
|
}
|
||||||
|
wg2.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_StartWithOptions(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
old := defaultService.Swap(svc)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
proc, err := StartWithOptions(context.Background(), RunOptions{
|
||||||
|
Command: "echo",
|
||||||
|
Args: []string{"with", "options"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
<-proc.Done()
|
||||||
|
|
||||||
|
assert.Equal(t, 0, proc.ExitCode)
|
||||||
|
assert.Contains(t, proc.Output(), "with options")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_RunWithOptions(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
old := defaultService.Swap(svc)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
output, err := RunWithOptions(context.Background(), RunOptions{
|
||||||
|
Command: "echo",
|
||||||
|
Args: []string{"run", "options"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "run options")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_Output(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
old := defaultService.Swap(svc)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
proc, err := Start(context.Background(), "echo", "global-output")
|
||||||
|
require.NoError(t, err)
|
||||||
|
<-proc.Done()
|
||||||
|
|
||||||
|
output, err := Output(proc.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, output, "global-output")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_InputAndCloseStdin(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
old := defaultService.Swap(svc)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
proc, err := Start(context.Background(), "cat")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = Input(proc.ID, "global-input\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = CloseStdin(proc.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
<-proc.Done()
|
||||||
|
|
||||||
|
assert.Contains(t, proc.Output(), "global-input")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_Wait(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
old := defaultService.Swap(svc)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
proc, err := Start(context.Background(), "echo", "global-wait")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
info, err := Wait(proc.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, proc.ID, info.ID)
|
||||||
|
assert.Equal(t, StatusExited, info.Status)
|
||||||
|
assert.Equal(t, 0, info.ExitCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_Signal(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
old := defaultService.Swap(svc)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
proc, err := Start(context.Background(), "sleep", "60")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = Signal(proc.ID, syscall.SIGTERM)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-proc.Done():
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("process should have been signalled through the global helper")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_SignalPID(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
old := defaultService.Swap(svc)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
cmd := exec.Command("sleep", "60")
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
|
||||||
|
waitCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
waitCh <- cmd.Wait()
|
||||||
|
}()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if cmd.ProcessState == nil && cmd.Process != nil {
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-waitCh:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
err := SignalPID(cmd.Process.Pid, syscall.SIGTERM)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-waitCh:
|
||||||
|
require.Error(t, err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("unmanaged process should have been signalled through the global helper")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_Running(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
old := defaultService.Swap(svc)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
proc, err := Start(ctx, "sleep", "60")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
running := Running()
|
||||||
|
assert.Len(t, running, 1)
|
||||||
|
assert.Equal(t, proc.ID, running[0].ID)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
<-proc.Done()
|
||||||
|
|
||||||
|
running = Running()
|
||||||
|
assert.Len(t, running, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlobal_RemoveAndClear(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
old := defaultService.Swap(svc)
|
||||||
|
defer func() {
|
||||||
|
if old != nil {
|
||||||
|
defaultService.Store(old)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
proc, err := Start(context.Background(), "echo", "remove-me")
|
||||||
|
require.NoError(t, err)
|
||||||
|
<-proc.Done()
|
||||||
|
|
||||||
|
err = Remove(proc.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = Get(proc.ID)
|
||||||
|
require.ErrorIs(t, err, ErrProcessNotFound)
|
||||||
|
|
||||||
|
proc2, err := Start(context.Background(), "echo", "clear-me")
|
||||||
|
require.NoError(t, err)
|
||||||
|
<-proc2.Done()
|
||||||
|
|
||||||
|
Clear()
|
||||||
|
|
||||||
|
_, err = Get(proc2.ID)
|
||||||
|
require.ErrorIs(t, err, ErrProcessNotFound)
|
||||||
|
}
|
||||||
11
go.mod
11
go.mod
|
|
@ -3,18 +3,18 @@ module dappco.re/go/core/process
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core v0.8.0-alpha.1
|
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/io v0.2.0
|
||||||
|
dappco.re/go/core/log v0.1.0
|
||||||
dappco.re/go/core/ws v0.3.0
|
dappco.re/go/core/ws v0.3.0
|
||||||
dappco.re/go/core/api v0.1.5
|
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
dappco.re/go/core/log v0.1.0 // indirect
|
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||||
dappco.re/go/core/io v0.1.5 // indirect
|
|
||||||
dappco.re/go/core/log v0.0.4 // indirect
|
|
||||||
github.com/99designs/gqlgen v0.17.88 // indirect
|
github.com/99designs/gqlgen v0.17.88 // indirect
|
||||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||||
|
|
@ -67,7 +67,6 @@ require (
|
||||||
github.com/gorilla/context v1.1.2 // indirect
|
github.com/gorilla/context v1.1.2 // indirect
|
||||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
github.com/gorilla/sessions v1.4.0 // 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/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
|
|
||||||
10
go.sum
10
go.sum
|
|
@ -1,15 +1,13 @@
|
||||||
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
|
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
|
||||||
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
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 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
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 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
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 h1:ZxR8y5pfrWvnCHVN7qExXz7fdP5a063uNqyqE0Ab8pQ=
|
||||||
dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic=
|
dappco.re/go/core/ws v0.3.0/go.mod h1:aLyXrJnbCOGL0SW9rC1EHAAIS83w3djO374gHIz4Nic=
|
||||||
forge.lthn.ai/core/api v0.1.5 h1:NwZrcOyBjaiz5/cn0n0tnlMUodi8Or6FHMx59C7Kv2o=
|
|
||||||
forge.lthn.ai/core/api v0.1.5/go.mod h1:PBnaWyOVXSOGy+0x2XAPUFMYJxQ2CNhppia/D06ZPII=
|
|
||||||
forge.lthn.ai/core/go-io v0.1.5 h1:+XJ1YhaGGFLGtcNbPtVlndTjk+pO0Ydi2hRDj5/cHOM=
|
|
||||||
forge.lthn.ai/core/go-io v0.1.5/go.mod h1:FRtXSsi8W+U9vewCU+LBAqqbIj3wjXA4dBdSv3SAtWI=
|
|
||||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
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=
|
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
|
||||||
|
|
|
||||||
189
health.go
189
health.go
|
|
@ -2,34 +2,35 @@ package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
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.
|
||||||
//
|
|
||||||
// check := process.HealthCheck(func() error { return nil })
|
|
||||||
type HealthCheck func() error
|
type HealthCheck func() error
|
||||||
|
|
||||||
// HealthServer provides HTTP /health and /ready endpoints for process monitoring.
|
// HealthServer provides HTTP `/health` and `/ready` endpoints for process monitoring.
|
||||||
//
|
|
||||||
// hs := process.NewHealthServer("127.0.0.1:0")
|
|
||||||
type HealthServer struct {
|
type HealthServer struct {
|
||||||
addr string
|
addr string
|
||||||
server *http.Server
|
server *http.Server
|
||||||
listener net.Listener
|
listener net.Listener
|
||||||
mu sync.Mutex
|
mu sync.RWMutex
|
||||||
ready bool
|
ready bool
|
||||||
checks []HealthCheck
|
checks []HealthCheck
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHealthServer creates a health check server on the given address.
|
// NewHealthServer creates a health check server on the given address.
|
||||||
//
|
//
|
||||||
// hs := process.NewHealthServer("127.0.0.1:0")
|
// Example:
|
||||||
|
//
|
||||||
|
// server := process.NewHealthServer("127.0.0.1:0")
|
||||||
func NewHealthServer(addr string) *HealthServer {
|
func NewHealthServer(addr string) *HealthServer {
|
||||||
return &HealthServer{
|
return &HealthServer{
|
||||||
addr: addr,
|
addr: addr,
|
||||||
|
|
@ -38,114 +39,240 @@ func NewHealthServer(addr string) *HealthServer {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddCheck registers a health check function.
|
// AddCheck registers a health check function.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// server.AddCheck(func() error { return nil })
|
||||||
func (h *HealthServer) AddCheck(check HealthCheck) {
|
func (h *HealthServer) AddCheck(check HealthCheck) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.checks = append(h.checks, check)
|
h.checks = append(h.checks, check)
|
||||||
h.mu.Unlock()
|
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) {
|
func (h *HealthServer) SetReady(ready bool) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.ready = ready
|
h.ready = ready
|
||||||
h.mu.Unlock()
|
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.
|
// Start begins serving health check endpoints.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if err := server.Start(); err != nil { return err }
|
||||||
func (h *HealthServer) Start() error {
|
func (h *HealthServer) Start() error {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
h.mu.Lock()
|
checks := h.checksSnapshot()
|
||||||
checks := h.checks
|
|
||||||
h.mu.Unlock()
|
|
||||||
|
|
||||||
for _, check := range checks {
|
for _, check := range checks {
|
||||||
|
if check == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := check(); err != nil {
|
if err := check(); err != nil {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
_, _ = w.Write([]byte("unhealthy: " + err.Error() + "\n"))
|
_, _ = fmt.Fprintf(w, "unhealthy: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ok\n"))
|
_, _ = fmt.Fprintln(w, "ok")
|
||||||
})
|
})
|
||||||
|
|
||||||
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
|
||||||
h.mu.Lock()
|
h.mu.RLock()
|
||||||
ready := h.ready
|
ready := h.ready
|
||||||
h.mu.Unlock()
|
h.mu.RUnlock()
|
||||||
|
|
||||||
if !ready {
|
if !ready {
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
_, _ = w.Write([]byte("not ready\n"))
|
_, _ = fmt.Fprintln(w, "not ready")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write([]byte("ready\n"))
|
_, _ = fmt.Fprintln(w, "ready")
|
||||||
})
|
})
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", h.addr)
|
listener, err := net.Listen("tcp", h.addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return core.E("health.start", core.Concat("failed to listen on ", h.addr), err)
|
return coreerr.E("HealthServer.Start", fmt.Sprintf("failed to listen on %s", h.addr), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
server := &http.Server{Handler: mux}
|
server := &http.Server{Handler: mux}
|
||||||
|
h.mu.Lock()
|
||||||
h.listener = listener
|
h.listener = listener
|
||||||
h.server = server
|
h.server = server
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
go func(srv *http.Server, ln net.Listener) {
|
go func() {
|
||||||
_ = srv.Serve(ln)
|
_ = server.Serve(listener)
|
||||||
}(server, listener)
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checksSnapshot returns a stable copy of the registered health checks.
|
||||||
|
func (h *HealthServer) checksSnapshot() []HealthCheck {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(h.checks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
checks := make([]HealthCheck, len(h.checks))
|
||||||
|
copy(checks, h.checks)
|
||||||
|
return checks
|
||||||
|
}
|
||||||
|
|
||||||
// Stop gracefully shuts down the health server.
|
// Stop gracefully shuts down the health server.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = server.Stop(context.Background())
|
||||||
func (h *HealthServer) Stop(ctx context.Context) error {
|
func (h *HealthServer) Stop(ctx context.Context) error {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
server := h.server
|
server := h.server
|
||||||
h.server = nil
|
h.server = nil
|
||||||
h.listener = nil
|
h.listener = nil
|
||||||
|
h.ready = false
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
if server == nil {
|
if server == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return server.Shutdown(ctx)
|
return server.Shutdown(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Addr returns the actual address the server is listening on.
|
// Addr returns the actual address the server is listening on.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// addr := server.Addr()
|
||||||
func (h *HealthServer) Addr() string {
|
func (h *HealthServer) Addr() string {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
if h.listener != nil {
|
if h.listener != nil {
|
||||||
return h.listener.Addr().String()
|
return h.listener.Addr().String()
|
||||||
}
|
}
|
||||||
return h.addr
|
return h.addr
|
||||||
}
|
}
|
||||||
|
|
||||||
// WaitForHealth polls a health endpoint until it responds 200 or the timeout
|
// WaitForHealth polls `/health` until it responds 200 or the timeout expires.
|
||||||
// (in milliseconds) expires. Returns true if healthy, false on timeout.
|
|
||||||
//
|
//
|
||||||
// ok := process.WaitForHealth("127.0.0.1:9000", 2_000)
|
// Example:
|
||||||
|
//
|
||||||
|
// if !process.WaitForHealth("127.0.0.1:8080", 5_000) {
|
||||||
|
// return errors.New("service did not become ready")
|
||||||
|
// }
|
||||||
func WaitForHealth(addr string, timeoutMs int) bool {
|
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)
|
deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)
|
||||||
url := core.Concat("http://", addr, "/health")
|
url := fmt.Sprintf("http://%s/health", addr)
|
||||||
|
|
||||||
client := &http.Client{Timeout: 2 * time.Second}
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
var lastReason string
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
resp.Body.Close()
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
if resp.StatusCode == http.StatusOK {
|
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)
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,9 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthServer_Endpoints_Good(t *testing.T) {
|
func TestHealthServer_Endpoints(t *testing.T) {
|
||||||
hs := NewHealthServer("127.0.0.1:0")
|
hs := NewHealthServer("127.0.0.1:0")
|
||||||
|
assert.True(t, hs.Ready())
|
||||||
err := hs.Start()
|
err := hs.Start()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer func() { _ = hs.Stop(context.Background()) }()
|
defer func() { _ = hs.Stop(context.Background()) }()
|
||||||
|
|
@ -29,6 +30,7 @@ func TestHealthServer_Endpoints_Good(t *testing.T) {
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
hs.SetReady(false)
|
hs.SetReady(false)
|
||||||
|
assert.False(t, hs.Ready())
|
||||||
|
|
||||||
resp, err = http.Get("http://" + addr + "/ready")
|
resp, err = http.Get("http://" + addr + "/ready")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -36,7 +38,16 @@ func TestHealthServer_Endpoints_Good(t *testing.T) {
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthServer_WithChecks_Good(t *testing.T) {
|
func TestHealthServer_Ready(t *testing.T) {
|
||||||
|
hs := NewHealthServer("127.0.0.1:0")
|
||||||
|
|
||||||
|
assert.True(t, hs.Ready())
|
||||||
|
|
||||||
|
hs.SetReady(false)
|
||||||
|
assert.False(t, hs.Ready())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealthServer_WithChecks(t *testing.T) {
|
||||||
hs := NewHealthServer("127.0.0.1:0")
|
hs := NewHealthServer("127.0.0.1:0")
|
||||||
|
|
||||||
healthy := true
|
healthy := true
|
||||||
|
|
@ -66,13 +77,36 @@ func TestHealthServer_WithChecks_Good(t *testing.T) {
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHealthServer_StopImmediately_Good(t *testing.T) {
|
func TestHealthServer_NilCheckIgnored(t *testing.T) {
|
||||||
hs := NewHealthServer("127.0.0.1:0")
|
hs := NewHealthServer("127.0.0.1:0")
|
||||||
require.NoError(t, hs.Start())
|
|
||||||
require.NoError(t, hs.Stop(context.Background()))
|
var check HealthCheck
|
||||||
|
hs.AddCheck(check)
|
||||||
|
|
||||||
|
err := hs.Start()
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = hs.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
addr := hs.Addr()
|
||||||
|
|
||||||
|
resp, err := http.Get("http://" + addr + "/health")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWaitForHealth_Reachable_Good(t *testing.T) {
|
func TestHealthServer_ChecksSnapshotIsStable(t *testing.T) {
|
||||||
|
hs := NewHealthServer("127.0.0.1:0")
|
||||||
|
|
||||||
|
hs.AddCheck(func() error { return nil })
|
||||||
|
snapshot := hs.checksSnapshot()
|
||||||
|
hs.AddCheck(func() error { return assert.AnError })
|
||||||
|
|
||||||
|
require.Len(t, snapshot, 1)
|
||||||
|
require.NotNil(t, snapshot[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWaitForHealth_Reachable(t *testing.T) {
|
||||||
hs := NewHealthServer("127.0.0.1:0")
|
hs := NewHealthServer("127.0.0.1:0")
|
||||||
require.NoError(t, hs.Start())
|
require.NoError(t, hs.Start())
|
||||||
defer func() { _ = hs.Stop(context.Background()) }()
|
defer func() { _ = hs.Stop(context.Background()) }()
|
||||||
|
|
@ -81,7 +115,34 @@ func TestWaitForHealth_Reachable_Good(t *testing.T) {
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWaitForHealth_Unreachable_Bad(t *testing.T) {
|
func TestWaitForHealth_Unreachable(t *testing.T) {
|
||||||
ok := WaitForHealth("127.0.0.1:19999", 500)
|
ok := WaitForHealth("127.0.0.1:19999", 500)
|
||||||
assert.False(t, ok)
|
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())
|
||||||
|
}
|
||||||
|
|
|
||||||
52
pidfile.go
52
pidfile.go
|
|
@ -1,70 +1,92 @@
|
||||||
package process
|
package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"fmt"
|
||||||
"path"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
|
||||||
coreio "dappco.re/go/core/io"
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PIDFile manages a process ID file for single-instance enforcement.
|
// PIDFile manages a process ID file for single-instance enforcement.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// pidFile := process.NewPIDFile("/var/run/myapp.pid")
|
||||||
type PIDFile struct {
|
type PIDFile struct {
|
||||||
path string
|
path string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPIDFile creates a PID file manager.
|
// NewPIDFile creates a PID file manager.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// pidFile := process.NewPIDFile("/var/run/myapp.pid")
|
||||||
func NewPIDFile(path string) *PIDFile {
|
func NewPIDFile(path string) *PIDFile {
|
||||||
return &PIDFile{path: path}
|
return &PIDFile{path: path}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Acquire writes the current PID to the file.
|
// Acquire writes the current PID to the file.
|
||||||
// Returns error if another instance is running.
|
// Returns error if another instance is running.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if err := pidFile.Acquire(); err != nil { return err }
|
||||||
func (p *PIDFile) Acquire() error {
|
func (p *PIDFile) Acquire() error {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
if data, err := coreio.Local.Read(p.path); err == nil {
|
if data, err := coreio.Local.Read(p.path); err == nil {
|
||||||
pid, err := strconv.Atoi(string(bytes.TrimSpace([]byte(data))))
|
pid, err := strconv.Atoi(strings.TrimSpace(data))
|
||||||
if err == nil && pid > 0 {
|
if err == nil && pid > 0 {
|
||||||
if proc, err := processHandle(pid); err == nil {
|
if proc, err := os.FindProcess(pid); err == nil {
|
||||||
if err := proc.Signal(syscall.Signal(0)); err == nil {
|
if err := proc.Signal(syscall.Signal(0)); err == nil {
|
||||||
return core.E("pidfile.acquire", core.Concat("another instance is running (PID ", strconv.Itoa(pid), ")"), nil)
|
return coreerr.E("PIDFile.Acquire", fmt.Sprintf("another instance is running (PID %d)", pid), nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = coreio.Local.Delete(p.path)
|
_ = coreio.Local.Delete(p.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if dir := path.Dir(p.path); dir != "." {
|
if dir := filepath.Dir(p.path); dir != "." {
|
||||||
if err := coreio.Local.EnsureDir(dir); err != nil {
|
if err := coreio.Local.EnsureDir(dir); err != nil {
|
||||||
return core.E("pidfile.acquire", "failed to create PID directory", err)
|
return coreerr.E("PIDFile.Acquire", "failed to create PID directory", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pid := currentPID()
|
pid := os.Getpid()
|
||||||
if err := coreio.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
|
if err := coreio.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
|
||||||
return core.E("pidfile.acquire", "failed to write PID file", err)
|
return coreerr.E("PIDFile.Acquire", "failed to write PID file", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release removes the PID file.
|
// Release removes the PID file.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = pidFile.Release()
|
||||||
func (p *PIDFile) Release() error {
|
func (p *PIDFile) Release() error {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
if err := coreio.Local.Delete(p.path); err != nil {
|
if err := coreio.Local.Delete(p.path); err != nil {
|
||||||
return core.E("pidfile.release", "failed to remove PID file", err)
|
return coreerr.E("PIDFile.Release", "failed to remove PID file", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path returns the PID file path.
|
// Path returns the PID file path.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// path := pidFile.Path()
|
||||||
func (p *PIDFile) Path() string {
|
func (p *PIDFile) Path() string {
|
||||||
return p.path
|
return p.path
|
||||||
}
|
}
|
||||||
|
|
@ -72,18 +94,22 @@ func (p *PIDFile) Path() string {
|
||||||
// ReadPID reads a PID file and checks if the process is still running.
|
// 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,
|
// 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.
|
// 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) {
|
func ReadPID(path string) (int, bool) {
|
||||||
data, err := coreio.Local.Read(path)
|
data, err := coreio.Local.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
pid, err := strconv.Atoi(string(bytes.TrimSpace([]byte(data))))
|
pid, err := strconv.Atoi(strings.TrimSpace(data))
|
||||||
if err != nil || pid <= 0 {
|
if err != nil || pid <= 0 {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
proc, err := processHandle(pid)
|
proc, err := os.FindProcess(pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return pid, false
|
return pid, false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@ package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPIDFile_Acquire_Good(t *testing.T) {
|
func TestPIDFile_AcquireAndRelease(t *testing.T) {
|
||||||
pidPath := core.JoinPath(t.TempDir(), "test.pid")
|
pidPath := filepath.Join(t.TempDir(), "test.pid")
|
||||||
pid := NewPIDFile(pidPath)
|
pid := NewPIDFile(pidPath)
|
||||||
err := pid.Acquire()
|
err := pid.Acquire()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -23,8 +23,8 @@ func TestPIDFile_Acquire_Good(t *testing.T) {
|
||||||
assert.True(t, os.IsNotExist(err))
|
assert.True(t, os.IsNotExist(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPIDFile_AcquireStale_Good(t *testing.T) {
|
func TestPIDFile_StalePID(t *testing.T) {
|
||||||
pidPath := core.JoinPath(t.TempDir(), "stale.pid")
|
pidPath := filepath.Join(t.TempDir(), "stale.pid")
|
||||||
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
|
require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644))
|
||||||
pid := NewPIDFile(pidPath)
|
pid := NewPIDFile(pidPath)
|
||||||
err := pid.Acquire()
|
err := pid.Acquire()
|
||||||
|
|
@ -33,8 +33,8 @@ func TestPIDFile_AcquireStale_Good(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPIDFile_CreateDirectory_Good(t *testing.T) {
|
func TestPIDFile_CreatesParentDirectory(t *testing.T) {
|
||||||
pidPath := core.JoinPath(t.TempDir(), "subdir", "nested", "test.pid")
|
pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid")
|
||||||
pid := NewPIDFile(pidPath)
|
pid := NewPIDFile(pidPath)
|
||||||
err := pid.Acquire()
|
err := pid.Acquire()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -42,27 +42,27 @@ func TestPIDFile_CreateDirectory_Good(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPIDFile_Path_Good(t *testing.T) {
|
func TestPIDFile_Path(t *testing.T) {
|
||||||
pid := NewPIDFile("/tmp/test.pid")
|
pid := NewPIDFile("/tmp/test.pid")
|
||||||
assert.Equal(t, "/tmp/test.pid", pid.Path())
|
assert.Equal(t, "/tmp/test.pid", pid.Path())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadPID_Missing_Bad(t *testing.T) {
|
func TestReadPID_Missing(t *testing.T) {
|
||||||
pid, running := ReadPID("/nonexistent/path.pid")
|
pid, running := ReadPID("/nonexistent/path.pid")
|
||||||
assert.Equal(t, 0, pid)
|
assert.Equal(t, 0, pid)
|
||||||
assert.False(t, running)
|
assert.False(t, running)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadPID_Invalid_Bad(t *testing.T) {
|
func TestReadPID_InvalidContent(t *testing.T) {
|
||||||
path := core.JoinPath(t.TempDir(), "bad.pid")
|
path := filepath.Join(t.TempDir(), "bad.pid")
|
||||||
require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644))
|
require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644))
|
||||||
pid, running := ReadPID(path)
|
pid, running := ReadPID(path)
|
||||||
assert.Equal(t, 0, pid)
|
assert.Equal(t, 0, pid)
|
||||||
assert.False(t, running)
|
assert.False(t, running)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReadPID_Stale_Bad(t *testing.T) {
|
func TestReadPID_StalePID(t *testing.T) {
|
||||||
path := core.JoinPath(t.TempDir(), "stale.pid")
|
path := filepath.Join(t.TempDir(), "stale.pid")
|
||||||
require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644))
|
require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644))
|
||||||
pid, running := ReadPID(path)
|
pid, running := ReadPID(path)
|
||||||
assert.Equal(t, 999999999, pid)
|
assert.Equal(t, 999999999, pid)
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,21 @@
|
||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
|
"dappco.re/go/core/api"
|
||||||
|
"dappco.re/go/core/api/pkg/provider"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
process "dappco.re/go/core/process"
|
process "dappco.re/go/core/process"
|
||||||
"dappco.re/go/core/ws"
|
"dappco.re/go/core/ws"
|
||||||
"forge.lthn.ai/core/api"
|
|
||||||
"forge.lthn.ai/core/api/pkg/provider"
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,7 +28,10 @@ import (
|
||||||
// and provider.Renderable.
|
// and provider.Renderable.
|
||||||
type ProcessProvider struct {
|
type ProcessProvider struct {
|
||||||
registry *process.Registry
|
registry *process.Registry
|
||||||
|
service *process.Service
|
||||||
|
runner *process.Runner
|
||||||
hub *ws.Hub
|
hub *ws.Hub
|
||||||
|
actions sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// compile-time interface checks
|
// compile-time interface checks
|
||||||
|
|
@ -33,17 +42,25 @@ var (
|
||||||
_ provider.Renderable = (*ProcessProvider)(nil)
|
_ 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
|
// The WS hub is used to emit daemon state change events. Pass nil for hub
|
||||||
// if WebSocket streaming is not needed.
|
// 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 {
|
if registry == nil {
|
||||||
registry = process.DefaultRegistry()
|
registry = process.DefaultRegistry()
|
||||||
}
|
}
|
||||||
return &ProcessProvider{
|
p := &ProcessProvider{
|
||||||
registry: registry,
|
registry: registry,
|
||||||
|
service: service,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
}
|
}
|
||||||
|
if service != nil {
|
||||||
|
p.runner = process.NewRunner(service)
|
||||||
|
}
|
||||||
|
p.registerProcessEvents()
|
||||||
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// Name implements api.RouteGroup.
|
// Name implements api.RouteGroup.
|
||||||
|
|
@ -79,6 +96,17 @@ func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup) {
|
||||||
rg.GET("/daemons/:code/:daemon", p.getDaemon)
|
rg.GET("/daemons/:code/:daemon", p.getDaemon)
|
||||||
rg.POST("/daemons/:code/:daemon/stop", p.stopDaemon)
|
rg.POST("/daemons/:code/:daemon/stop", p.stopDaemon)
|
||||||
rg.GET("/daemons/:code/:daemon/health", p.healthCheck)
|
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.
|
// Describe implements api.DescribableGroup.
|
||||||
|
|
@ -119,8 +147,6 @@ func (p *ProcessProvider) Describe() []api.RouteDescription {
|
||||||
"daemon": map[string]any{"type": "string"},
|
"daemon": map[string]any{"type": "string"},
|
||||||
"pid": map[string]any{"type": "integer"},
|
"pid": map[string]any{"type": "integer"},
|
||||||
"health": map[string]any{"type": "string"},
|
"health": map[string]any{"type": "string"},
|
||||||
"project": map[string]any{"type": "string"},
|
|
||||||
"binary": map[string]any{"type": "string"},
|
|
||||||
"started": map[string]any{"type": "string", "format": "date-time"},
|
"started": map[string]any{"type": "string", "format": "date-time"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -142,7 +168,7 @@ func (p *ProcessProvider) Describe() []api.RouteDescription {
|
||||||
Method: "GET",
|
Method: "GET",
|
||||||
Path: "/daemons/:code/:daemon/health",
|
Path: "/daemons/:code/:daemon/health",
|
||||||
Summary: "Check 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"},
|
Tags: []string{"process"},
|
||||||
Response: map[string]any{
|
Response: map[string]any{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|
@ -153,6 +179,232 @@ func (p *ProcessProvider) Describe() []api.RouteDescription {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/processes",
|
||||||
|
Summary: "List managed processes",
|
||||||
|
Description: "Returns the current process service snapshot as serialisable process info entries. Pass runningOnly=true to limit results to active processes.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"id": map[string]any{"type": "string"},
|
||||||
|
"command": map[string]any{"type": "string"},
|
||||||
|
"args": map[string]any{"type": "array"},
|
||||||
|
"dir": map[string]any{"type": "string"},
|
||||||
|
"startedAt": map[string]any{"type": "string", "format": "date-time"},
|
||||||
|
"running": map[string]any{"type": "boolean"},
|
||||||
|
"status": map[string]any{"type": "string"},
|
||||||
|
"exitCode": map[string]any{"type": "integer"},
|
||||||
|
"duration": map[string]any{"type": "integer"},
|
||||||
|
"pid": map[string]any{"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/processes",
|
||||||
|
Summary: "Start a managed process",
|
||||||
|
Description: "Starts a process asynchronously and returns its initial snapshot immediately.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
RequestBody: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"command": map[string]any{"type": "string"},
|
||||||
|
"args": map[string]any{"type": "array"},
|
||||||
|
"dir": map[string]any{"type": "string"},
|
||||||
|
"env": map[string]any{"type": "array"},
|
||||||
|
"disableCapture": map[string]any{"type": "boolean"},
|
||||||
|
"detach": map[string]any{"type": "boolean"},
|
||||||
|
"timeout": map[string]any{"type": "integer"},
|
||||||
|
"gracePeriod": map[string]any{"type": "integer"},
|
||||||
|
"killGroup": map[string]any{"type": "boolean"},
|
||||||
|
},
|
||||||
|
"required": []string{"command"},
|
||||||
|
},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"id": map[string]any{"type": "string"},
|
||||||
|
"command": map[string]any{"type": "string"},
|
||||||
|
"args": map[string]any{"type": "array"},
|
||||||
|
"dir": map[string]any{"type": "string"},
|
||||||
|
"startedAt": map[string]any{"type": "string", "format": "date-time"},
|
||||||
|
"running": map[string]any{"type": "boolean"},
|
||||||
|
"status": map[string]any{"type": "string"},
|
||||||
|
"exitCode": map[string]any{"type": "integer"},
|
||||||
|
"duration": map[string]any{"type": "integer"},
|
||||||
|
"pid": map[string]any{"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/processes/run",
|
||||||
|
Summary: "Run a managed process",
|
||||||
|
Description: "Runs a process synchronously and returns its combined output on success.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
RequestBody: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"command": map[string]any{"type": "string"},
|
||||||
|
"args": map[string]any{"type": "array"},
|
||||||
|
"dir": map[string]any{"type": "string"},
|
||||||
|
"env": map[string]any{"type": "array"},
|
||||||
|
"disableCapture": map[string]any{"type": "boolean"},
|
||||||
|
"detach": map[string]any{"type": "boolean"},
|
||||||
|
"timeout": map[string]any{"type": "integer"},
|
||||||
|
"gracePeriod": map[string]any{"type": "integer"},
|
||||||
|
"killGroup": map[string]any{"type": "boolean"},
|
||||||
|
},
|
||||||
|
"required": []string{"command"},
|
||||||
|
},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/processes/:id",
|
||||||
|
Summary: "Get a managed process",
|
||||||
|
Description: "Returns a single managed process by ID as a process info snapshot.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"id": map[string]any{"type": "string"},
|
||||||
|
"command": map[string]any{"type": "string"},
|
||||||
|
"args": map[string]any{"type": "array"},
|
||||||
|
"dir": map[string]any{"type": "string"},
|
||||||
|
"startedAt": map[string]any{"type": "string", "format": "date-time"},
|
||||||
|
"running": map[string]any{"type": "boolean"},
|
||||||
|
"status": map[string]any{"type": "string"},
|
||||||
|
"exitCode": map[string]any{"type": "integer"},
|
||||||
|
"duration": map[string]any{"type": "integer"},
|
||||||
|
"pid": map[string]any{"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "GET",
|
||||||
|
Path: "/processes/:id/output",
|
||||||
|
Summary: "Get process output",
|
||||||
|
Description: "Returns the captured stdout and stderr for a managed process.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/processes/:id/wait",
|
||||||
|
Summary: "Wait for a managed process",
|
||||||
|
Description: "Blocks until the process exits and returns the final process snapshot. Non-zero exits include the snapshot in the error details payload.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"id": map[string]any{"type": "string"},
|
||||||
|
"command": map[string]any{"type": "string"},
|
||||||
|
"args": map[string]any{"type": "array"},
|
||||||
|
"dir": map[string]any{"type": "string"},
|
||||||
|
"startedAt": map[string]any{"type": "string", "format": "date-time"},
|
||||||
|
"running": map[string]any{"type": "boolean"},
|
||||||
|
"status": map[string]any{"type": "string"},
|
||||||
|
"exitCode": map[string]any{"type": "integer"},
|
||||||
|
"duration": map[string]any{"type": "integer"},
|
||||||
|
"pid": map[string]any{"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/processes/:id/input",
|
||||||
|
Summary: "Write process input",
|
||||||
|
Description: "Writes the provided input string to a managed process stdin pipe.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
RequestBody: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"input": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"input"},
|
||||||
|
},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"written": map[string]any{"type": "boolean"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/processes/:id/close-stdin",
|
||||||
|
Summary: "Close process stdin",
|
||||||
|
Description: "Closes the stdin pipe of a managed process so it can exit cleanly.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"closed": map[string]any{"type": "boolean"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/processes/:id/kill",
|
||||||
|
Summary: "Kill a managed process",
|
||||||
|
Description: "Sends SIGKILL to the managed process identified by ID, or to a raw OS PID when the path value is numeric.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"killed": map[string]any{"type": "boolean"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/processes/:id/signal",
|
||||||
|
Summary: "Signal a managed process",
|
||||||
|
Description: "Sends a Unix signal to the managed process identified by ID, or to a raw OS PID when the path value is numeric.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
RequestBody: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"signal": map[string]any{"type": "string"},
|
||||||
|
},
|
||||||
|
"required": []string{"signal"},
|
||||||
|
},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"signalled": map[string]any{"type": "boolean"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/pipelines/run",
|
||||||
|
Summary: "Run a process pipeline",
|
||||||
|
Description: "Executes a list of process specs using the configured runner in sequential, parallel, or dependency-aware mode.",
|
||||||
|
Tags: []string{"process"},
|
||||||
|
Response: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"results": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
"duration": map[string]any{"type": "integer"},
|
||||||
|
"passed": map[string]any{"type": "integer"},
|
||||||
|
"failed": map[string]any{"type": "integer"},
|
||||||
|
"skipped": map[string]any{"type": "integer"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,6 +419,9 @@ func (p *ProcessProvider) listDaemons(c *gin.Context) {
|
||||||
if entries == nil {
|
if entries == nil {
|
||||||
entries = []process.DaemonEntry{}
|
entries = []process.DaemonEntry{}
|
||||||
}
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
p.emitEvent("process.daemon.started", daemonEventPayload(entry))
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, api.OK(entries))
|
c.JSON(http.StatusOK, api.OK(entries))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -179,6 +434,7 @@ func (p *ProcessProvider) getDaemon(c *gin.Context) {
|
||||||
c.JSON(http.StatusNotFound, api.Fail("not_found", "daemon not found or not running"))
|
c.JSON(http.StatusNotFound, api.Fail("not_found", "daemon not found or not running"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
p.emitEvent("process.daemon.started", daemonEventPayload(*entry))
|
||||||
c.JSON(http.StatusOK, api.OK(entry))
|
c.JSON(http.StatusOK, api.OK(entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,16 +491,14 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
healthy := process.WaitForHealth(entry.Health, 2000)
|
healthy, reason := process.ProbeHealth(entry.Health, 2000)
|
||||||
reason := ""
|
|
||||||
if !healthy {
|
|
||||||
reason = "health endpoint did not report healthy"
|
|
||||||
}
|
|
||||||
|
|
||||||
result := map[string]any{
|
result := map[string]any{
|
||||||
"healthy": healthy,
|
"healthy": healthy,
|
||||||
"address": entry.Health,
|
"address": entry.Health,
|
||||||
"reason": reason,
|
}
|
||||||
|
if !healthy && reason != "" {
|
||||||
|
result["reason"] = reason
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit health event
|
// Emit health event
|
||||||
|
|
@ -262,15 +516,346 @@ func (p *ProcessProvider) healthCheck(c *gin.Context) {
|
||||||
c.JSON(statusCode, api.OK(result))
|
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.
|
// emitEvent sends a WS event if the hub is available.
|
||||||
func (p *ProcessProvider) emitEvent(channel string, data any) {
|
func (p *ProcessProvider) emitEvent(channel string, data any) {
|
||||||
if p.hub == nil {
|
if p.hub == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = p.hub.SendToChannel(channel, ws.Message{
|
msg := ws.Message{
|
||||||
Type: ws.TypeEvent,
|
Type: ws.TypeEvent,
|
||||||
Data: data,
|
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
|
// PIDAlive checks whether a PID is still running. Exported for use by
|
||||||
|
|
@ -291,3 +876,125 @@ func intParam(c *gin.Context, name string) int {
|
||||||
v, _ := strconv.Atoi(c.Param(name))
|
v, _ := strconv.Atoi(c.Param(name))
|
||||||
return v
|
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
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
core "dappco.re/go/core"
|
||||||
|
goapi "dappco.re/go/core/api"
|
||||||
process "dappco.re/go/core/process"
|
process "dappco.re/go/core/process"
|
||||||
processapi "dappco.re/go/core/process/pkg/api"
|
processapi "dappco.re/go/core/process/pkg/api"
|
||||||
goapi "forge.lthn.ai/core/api"
|
corews "dappco.re/go/core/ws"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
@ -21,17 +30,17 @@ func init() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessProvider_Name_Good(t *testing.T) {
|
func TestProcessProvider_Name_Good(t *testing.T) {
|
||||||
p := processapi.NewProvider(nil, nil)
|
p := processapi.NewProvider(nil, nil, nil)
|
||||||
assert.Equal(t, "process", p.Name())
|
assert.Equal(t, "process", p.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessProvider_BasePath_Good(t *testing.T) {
|
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())
|
assert.Equal(t, "/api/process", p.BasePath())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessProvider_Channels_Good(t *testing.T) {
|
func TestProcessProvider_Channels_Good(t *testing.T) {
|
||||||
p := processapi.NewProvider(nil, nil)
|
p := processapi.NewProvider(nil, nil, nil)
|
||||||
channels := p.Channels()
|
channels := p.Channels()
|
||||||
assert.Contains(t, channels, "process.daemon.started")
|
assert.Contains(t, channels, "process.daemon.started")
|
||||||
assert.Contains(t, channels, "process.daemon.stopped")
|
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) {
|
func TestProcessProvider_Describe_Good(t *testing.T) {
|
||||||
p := processapi.NewProvider(nil, nil)
|
p := processapi.NewProvider(nil, nil, nil)
|
||||||
descs := p.Describe()
|
descs := p.Describe()
|
||||||
assert.GreaterOrEqual(t, len(descs), 4)
|
assert.GreaterOrEqual(t, len(descs), 5)
|
||||||
|
|
||||||
// Verify all descriptions have required fields
|
// Verify all descriptions have required fields
|
||||||
for _, d := range descs {
|
for _, d := range descs {
|
||||||
|
|
@ -51,20 +60,25 @@ func TestProcessProvider_Describe_Good(t *testing.T) {
|
||||||
assert.NotEmpty(t, d.Tags)
|
assert.NotEmpty(t, d.Tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foundPipelineRoute := false
|
||||||
|
foundSignalRoute := false
|
||||||
for _, d := range descs {
|
for _, d := range descs {
|
||||||
if d.Path == "/daemons/:code/:daemon/health" {
|
if d.Method == "POST" && d.Path == "/pipelines/run" {
|
||||||
props, ok := d.Response["properties"].(map[string]any)
|
foundPipelineRoute = true
|
||||||
require.True(t, ok)
|
}
|
||||||
assert.Contains(t, props, "reason")
|
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) {
|
func TestProcessProvider_ListDaemons_Good(t *testing.T) {
|
||||||
// Use a temp directory so the registry has no daemons
|
// Use a temp directory so the registry has no daemons
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
registry := newTestRegistry(dir)
|
registry := newTestRegistry(dir)
|
||||||
p := processapi.NewProvider(registry, nil)
|
p := processapi.NewProvider(registry, nil, nil)
|
||||||
|
|
||||||
r := setupRouter(p)
|
r := setupRouter(p)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -73,14 +87,58 @@ func TestProcessProvider_ListDaemons_Good(t *testing.T) {
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
body := w.Body.String()
|
var resp goapi.Response[[]any]
|
||||||
assert.NotEmpty(t, body)
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, resp.Success)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessProvider_ListDaemons_BroadcastsStarted_Good(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
registry := newTestRegistry(dir)
|
||||||
|
require.NoError(t, registry.Register(process.DaemonEntry{
|
||||||
|
Code: "test",
|
||||||
|
Daemon: "serve",
|
||||||
|
PID: os.Getpid(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
hub := corews.NewHub()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go hub.Run(ctx)
|
||||||
|
|
||||||
|
p := processapi.NewProvider(registry, nil, hub)
|
||||||
|
server := httptest.NewServer(hub.Handler())
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
conn := connectWS(t, server.URL)
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return hub.ClientCount() == 1
|
||||||
|
}, time.Second, 10*time.Millisecond)
|
||||||
|
|
||||||
|
r := setupRouter(p)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req, _ := http.NewRequest("GET", "/api/process/daemons", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
events := readWSEvents(t, conn, "process.daemon.started")
|
||||||
|
started := events["process.daemon.started"]
|
||||||
|
require.NotNil(t, started)
|
||||||
|
|
||||||
|
startedData := started.Data.(map[string]any)
|
||||||
|
assert.Equal(t, "test", startedData["code"])
|
||||||
|
assert.Equal(t, "serve", startedData["daemon"])
|
||||||
|
assert.Equal(t, float64(os.Getpid()), startedData["pid"])
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessProvider_GetDaemon_Bad(t *testing.T) {
|
func TestProcessProvider_GetDaemon_Bad(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
registry := newTestRegistry(dir)
|
registry := newTestRegistry(dir)
|
||||||
p := processapi.NewProvider(registry, nil)
|
p := processapi.NewProvider(registry, nil, nil)
|
||||||
|
|
||||||
r := setupRouter(p)
|
r := setupRouter(p)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|
@ -90,29 +148,45 @@ func TestProcessProvider_GetDaemon_Bad(t *testing.T) {
|
||||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessProvider_HealthCheck_NoEndpoint_Good(t *testing.T) {
|
func TestProcessProvider_HealthCheck_Bad(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
registry := newTestRegistry(dir)
|
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{
|
require.NoError(t, registry.Register(process.DaemonEntry{
|
||||||
Code: "test",
|
Code: "test",
|
||||||
Daemon: "nohealth",
|
Daemon: "broken",
|
||||||
PID: os.Getpid(),
|
PID: os.Getpid(),
|
||||||
|
Health: hostPort,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
p := processapi.NewProvider(registry, nil)
|
p := processapi.NewProvider(registry, nil, nil)
|
||||||
|
|
||||||
r := setupRouter(p)
|
r := setupRouter(p)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
req, _ := http.NewRequest("GET", "/api/process/daemons/test/nohealth/health", nil)
|
req, _ := http.NewRequest("GET", "/api/process/daemons/test/broken/health", nil)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||||
assert.Contains(t, w.Body.String(), "no health endpoint configured")
|
|
||||||
assert.Contains(t, w.Body.String(), "\"reason\"")
|
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) {
|
func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) {
|
||||||
p := processapi.NewProvider(nil, nil)
|
p := processapi.NewProvider(nil, nil, nil)
|
||||||
|
|
||||||
engine, err := goapi.New()
|
engine, err := goapi.New()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -122,8 +196,8 @@ func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) {
|
||||||
assert.Equal(t, "process", engine.Groups()[0].Name())
|
assert.Equal(t, "process", engine.Groups()[0].Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessProvider_StreamGroup_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()
|
engine, err := goapi.New()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -135,6 +209,600 @@ func TestProcessProvider_StreamGroup_Good(t *testing.T) {
|
||||||
assert.Contains(t, channels, "process.daemon.started")
|
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 -------------------------------------------------------------
|
// -- Test helpers -------------------------------------------------------------
|
||||||
|
|
||||||
func setupRouter(p *processapi.ProcessProvider) *gin.Engine {
|
func setupRouter(p *processapi.ProcessProvider) *gin.Engine {
|
||||||
|
|
@ -148,3 +816,58 @@ func setupRouter(p *processapi.ProcessProvider) *gin.Engine {
|
||||||
func newTestRegistry(dir string) *process.Registry {
|
func newTestRegistry(dir string) *process.Registry {
|
||||||
return process.NewRegistry(dir)
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
852
pkg/api/ui/dist/core-process.js
vendored
852
pkg/api/ui/dist/core-process.js
vendored
File diff suppressed because it is too large
Load diff
226
process.go
226
process.go
|
|
@ -2,23 +2,24 @@ package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strconv"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
coreerr "dappco.re/go/core/log"
|
||||||
|
goio "io"
|
||||||
)
|
)
|
||||||
|
|
||||||
type processStdin interface {
|
// ManagedProcess represents a managed external process.
|
||||||
Write(p []byte) (n int, err error)
|
//
|
||||||
Close() error
|
// Example:
|
||||||
}
|
//
|
||||||
|
// proc, err := svc.Start(ctx, "echo", "hello")
|
||||||
// ManagedProcess represents a tracked external process started by the service.
|
|
||||||
type ManagedProcess struct {
|
type ManagedProcess struct {
|
||||||
ID string
|
ID string
|
||||||
PID int
|
|
||||||
Command string
|
Command string
|
||||||
Args []string
|
Args []string
|
||||||
Dir string
|
Dir string
|
||||||
|
|
@ -28,28 +29,42 @@ type ManagedProcess struct {
|
||||||
ExitCode int
|
ExitCode int
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
|
|
||||||
cmd *execCmd
|
cmd *exec.Cmd
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
output *RingBuffer
|
output *RingBuffer
|
||||||
stdin processStdin
|
stdin goio.WriteCloser
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
gracePeriod time.Duration
|
gracePeriod time.Duration
|
||||||
killGroup bool
|
killGroup bool
|
||||||
lastSignal string
|
killNotified bool
|
||||||
killEmitted bool
|
killSignal string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process is kept as a compatibility alias for ManagedProcess.
|
// Process is kept as an alias for ManagedProcess for compatibility.
|
||||||
type Process = ManagedProcess
|
type Process = ManagedProcess
|
||||||
|
|
||||||
// Info returns a snapshot of process state.
|
// Info returns a snapshot of process state.
|
||||||
func (p *ManagedProcess) Info() ProcessInfo {
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// info := proc.Info()
|
||||||
|
func (p *ManagedProcess) Info() Info {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
|
|
||||||
return ProcessInfo{
|
pid := 0
|
||||||
|
if p.cmd != nil && p.cmd.Process != nil {
|
||||||
|
pid = p.cmd.Process.Pid
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := p.Duration
|
||||||
|
if p.Status == StatusRunning {
|
||||||
|
duration = time.Since(p.StartedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Info{
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
Command: p.Command,
|
Command: p.Command,
|
||||||
Args: append([]string(nil), p.Args...),
|
Args: append([]string(nil), p.Args...),
|
||||||
|
|
@ -58,12 +73,16 @@ func (p *ManagedProcess) Info() ProcessInfo {
|
||||||
Running: p.Status == StatusRunning,
|
Running: p.Status == StatusRunning,
|
||||||
Status: p.Status,
|
Status: p.Status,
|
||||||
ExitCode: p.ExitCode,
|
ExitCode: p.ExitCode,
|
||||||
Duration: p.Duration,
|
Duration: duration,
|
||||||
PID: p.PID,
|
PID: pid,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output returns the captured output as a string.
|
// Output returns the captured output as a string.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// fmt.Println(proc.Output())
|
||||||
func (p *ManagedProcess) Output() string {
|
func (p *ManagedProcess) Output() string {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
|
|
@ -74,6 +93,10 @@ func (p *ManagedProcess) Output() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// OutputBytes returns the captured output as bytes.
|
// OutputBytes returns the captured output as bytes.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// data := proc.OutputBytes()
|
||||||
func (p *ManagedProcess) OutputBytes() []byte {
|
func (p *ManagedProcess) OutputBytes() []byte {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
|
|
@ -85,61 +108,95 @@ func (p *ManagedProcess) OutputBytes() []byte {
|
||||||
|
|
||||||
// IsRunning returns true if the process is still executing.
|
// IsRunning returns true if the process is still executing.
|
||||||
func (p *ManagedProcess) IsRunning() bool {
|
func (p *ManagedProcess) IsRunning() bool {
|
||||||
select {
|
p.mu.RLock()
|
||||||
case <-p.done:
|
defer p.mu.RUnlock()
|
||||||
return false
|
return p.Status == StatusRunning
|
||||||
default:
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait blocks until the process exits.
|
// Wait blocks until the process exits.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if err := proc.Wait(); err != nil { return err }
|
||||||
func (p *ManagedProcess) Wait() error {
|
func (p *ManagedProcess) Wait() error {
|
||||||
<-p.done
|
<-p.done
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
if p.Status == StatusFailed {
|
if p.Status == StatusFailed {
|
||||||
return core.E("process.wait", core.Concat("process failed to start: ", p.ID), nil)
|
return coreerr.E("Process.Wait", fmt.Sprintf("process failed to start: %s", p.ID), nil)
|
||||||
}
|
}
|
||||||
if p.Status == StatusKilled {
|
if p.Status == StatusKilled {
|
||||||
return core.E("process.wait", core.Concat("process was killed: ", p.ID), nil)
|
return coreerr.E("Process.Wait", fmt.Sprintf("process was killed: %s", p.ID), nil)
|
||||||
}
|
}
|
||||||
if p.ExitCode != 0 {
|
if p.ExitCode != 0 {
|
||||||
return core.E("process.wait", core.Concat("process exited with code ", strconv.Itoa(p.ExitCode)), nil)
|
return coreerr.E("Process.Wait", fmt.Sprintf("process exited with code %d", p.ExitCode), nil)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Done returns a channel that closes when the process exits.
|
// Done returns a channel that closes when the process exits.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// <-proc.Done()
|
||||||
func (p *ManagedProcess) Done() <-chan struct{} {
|
func (p *ManagedProcess) Done() <-chan struct{} {
|
||||||
return p.done
|
return p.done
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kill forcefully terminates the process.
|
// Kill forcefully terminates the process.
|
||||||
// If KillGroup is set, kills the entire process group.
|
// If KillGroup is set, kills the entire process group.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = proc.Kill()
|
||||||
func (p *ManagedProcess) Kill() error {
|
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()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
if p.Status != StatusRunning {
|
if p.Status != StatusRunning {
|
||||||
return nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.cmd == nil || p.cmd.Process == nil {
|
if p.cmd == nil || p.cmd.Process == nil {
|
||||||
return nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
p.lastSignal = "SIGKILL"
|
|
||||||
if p.killGroup {
|
if p.killGroup {
|
||||||
// Kill entire process group (negative PID)
|
// 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.
|
// Shutdown gracefully stops the process: SIGTERM, then SIGKILL after grace period.
|
||||||
// If GracePeriod was not set (zero), falls back to immediate Kill().
|
// If GracePeriod was not set (zero), falls back to immediate Kill().
|
||||||
// If KillGroup is set, signals are sent to the entire process group.
|
// If KillGroup is set, signals are sent to the entire process group.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = proc.Shutdown()
|
||||||
func (p *ManagedProcess) Shutdown() error {
|
func (p *ManagedProcess) Shutdown() error {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
grace := p.gracePeriod
|
grace := p.gracePeriod
|
||||||
|
|
@ -180,11 +237,79 @@ func (p *ManagedProcess) terminate() error {
|
||||||
if p.killGroup {
|
if p.killGroup {
|
||||||
pid = -pid
|
pid = -pid
|
||||||
}
|
}
|
||||||
p.lastSignal = "SIGTERM"
|
|
||||||
return syscall.Kill(pid, syscall.SIGTERM)
|
return syscall.Kill(pid, syscall.SIGTERM)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signal sends a signal to the process.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = proc.Signal(os.Interrupt)
|
||||||
|
func (p *ManagedProcess) Signal(sig os.Signal) error {
|
||||||
|
p.mu.RLock()
|
||||||
|
status := p.Status
|
||||||
|
cmd := p.cmd
|
||||||
|
killGroup := p.killGroup
|
||||||
|
p.mu.RUnlock()
|
||||||
|
|
||||||
|
if status != StatusRunning {
|
||||||
|
return ErrProcessNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd == nil || cmd.Process == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !killGroup {
|
||||||
|
return cmd.Process.Signal(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
sysSig, ok := sig.(syscall.Signal)
|
||||||
|
if !ok {
|
||||||
|
return cmd.Process.Signal(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sysSig == 0 {
|
||||||
|
return syscall.Kill(-cmd.Process.Pid, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := syscall.Kill(-cmd.Process.Pid, sysSig); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some shells briefly ignore or defer the signal while they are still
|
||||||
|
// initialising child jobs. Retry a few times after short delays so the
|
||||||
|
// whole process group is more reliably terminated. If the requested signal
|
||||||
|
// still does not stop the group, escalate to SIGKILL so callers do not hang.
|
||||||
|
go func(pid int, sig syscall.Signal, done <-chan struct{}) {
|
||||||
|
ticker := time.NewTicker(100 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_ = syscall.Kill(-pid, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
}(cmd.Process.Pid, sysSig, p.done)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SendInput writes to the process stdin.
|
// SendInput writes to the process stdin.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = proc.SendInput("hello\n")
|
||||||
func (p *ManagedProcess) SendInput(input string) error {
|
func (p *ManagedProcess) SendInput(input string) error {
|
||||||
p.mu.RLock()
|
p.mu.RLock()
|
||||||
defer p.mu.RUnlock()
|
defer p.mu.RUnlock()
|
||||||
|
|
@ -202,6 +327,10 @@ func (p *ManagedProcess) SendInput(input string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseStdin closes the process stdin pipe.
|
// CloseStdin closes the process stdin pipe.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = proc.CloseStdin()
|
||||||
func (p *ManagedProcess) CloseStdin() error {
|
func (p *ManagedProcess) CloseStdin() error {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
@ -214,20 +343,3 @@ func (p *ManagedProcess) CloseStdin() error {
|
||||||
p.stdin = nil
|
p.stdin = nil
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ManagedProcess) requestedSignal() string {
|
|
||||||
p.mu.RLock()
|
|
||||||
defer p.mu.RUnlock()
|
|
||||||
return p.lastSignal
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ManagedProcess) markKillEmitted() bool {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
|
|
||||||
if p.killEmitted {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
p.killEmitted = true
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
|
||||||
306
process_global.go
Normal file
306
process_global.go
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global default service used by package-level helpers.
|
||||||
|
var (
|
||||||
|
defaultService atomic.Pointer[Service]
|
||||||
|
defaultOnce sync.Once
|
||||||
|
defaultErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default returns the global process service.
|
||||||
|
// Returns nil if not initialised.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// svc := process.Default()
|
||||||
|
func Default() *Service {
|
||||||
|
return defaultService.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefault sets the global process service.
|
||||||
|
// Thread-safe: can be called concurrently with Default().
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = process.SetDefault(svc)
|
||||||
|
func SetDefault(s *Service) error {
|
||||||
|
if s == nil {
|
||||||
|
return ErrSetDefaultNil
|
||||||
|
}
|
||||||
|
defaultService.Store(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes the default global service with a Core instance.
|
||||||
|
// This is typically called during application startup.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = process.Init(coreInstance)
|
||||||
|
func Init(c *core.Core) error {
|
||||||
|
defaultOnce.Do(func() {
|
||||||
|
factory := NewService(Options{})
|
||||||
|
svc, err := factory(c)
|
||||||
|
if err != nil {
|
||||||
|
defaultErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defaultService.Store(svc.(*Service))
|
||||||
|
})
|
||||||
|
return defaultErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register creates a process service for Core registration.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// result := process.Register(coreInstance)
|
||||||
|
func Register(c *core.Core) core.Result {
|
||||||
|
factory := NewService(Options{})
|
||||||
|
svc, err := factory(c)
|
||||||
|
if err != nil {
|
||||||
|
return core.Result{Value: err, OK: false}
|
||||||
|
}
|
||||||
|
return core.Result{Value: svc, OK: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Global convenience functions ---
|
||||||
|
|
||||||
|
// Start spawns a new process using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// proc, err := process.Start(ctx, "echo", "hello")
|
||||||
|
func Start(ctx context.Context, command string, args ...string) (*Process, error) {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return nil, ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.Start(ctx, command, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes a command and waits for completion using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// out, err := process.Run(ctx, "echo", "hello")
|
||||||
|
func Run(ctx context.Context, command string, args ...string) (string, error) {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return "", ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.Run(ctx, command, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a process by ID from the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// proc, err := process.Get("proc-1")
|
||||||
|
func Get(id string) (*Process, error) {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return nil, ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.Get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output returns the captured output for a process from the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// out, err := process.Output("proc-1")
|
||||||
|
func Output(id string) (string, error) {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return "", ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.Output(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input writes data to the stdin of a managed process using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = process.Input("proc-1", "hello\n")
|
||||||
|
func Input(id string, input string) error {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.Input(id, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloseStdin closes the stdin pipe of a managed process using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = process.CloseStdin("proc-1")
|
||||||
|
func CloseStdin(id string) error {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.CloseStdin(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait blocks until a managed process exits and returns its final snapshot.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// info, err := process.Wait("proc-1")
|
||||||
|
func Wait(id string) (Info, error) {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return Info{}, ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.Wait(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all processes from the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// procs := process.List()
|
||||||
|
func List() []*Process {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return svc.List()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill terminates a process by ID using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = process.Kill("proc-1")
|
||||||
|
func Kill(id string) error {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.Kill(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KillPID terminates a process by operating-system PID using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = process.KillPID(1234)
|
||||||
|
func KillPID(pid int) error {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.KillPID(pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal sends a signal to a process by ID using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = process.Signal("proc-1", syscall.SIGTERM)
|
||||||
|
func Signal(id string, sig os.Signal) error {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.Signal(id, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalPID sends a signal to a process by operating-system PID using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = process.SignalPID(1234, syscall.SIGTERM)
|
||||||
|
func SignalPID(pid int, sig os.Signal) error {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.SignalPID(pid, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartWithOptions spawns a process with full configuration using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// proc, err := process.StartWithOptions(ctx, process.RunOptions{Command: "pwd", Dir: "/tmp"})
|
||||||
|
func StartWithOptions(ctx context.Context, opts RunOptions) (*Process, error) {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return nil, ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.StartWithOptions(ctx, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunWithOptions executes a command with options and waits using the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// out, err := process.RunWithOptions(ctx, process.RunOptions{Command: "echo", Args: []string{"hello"}})
|
||||||
|
func RunWithOptions(ctx context.Context, opts RunOptions) (string, error) {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return "", ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.RunWithOptions(ctx, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Running returns all currently running processes from the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// running := process.Running()
|
||||||
|
func Running() []*Process {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return svc.Running()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes a completed process from the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = process.Remove("proc-1")
|
||||||
|
func Remove(id string) error {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return ErrServiceNotInitialized
|
||||||
|
}
|
||||||
|
return svc.Remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all completed processes from the default service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// process.Clear()
|
||||||
|
func Clear() {
|
||||||
|
svc := Default()
|
||||||
|
if svc == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc.Clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Errors
|
||||||
|
var (
|
||||||
|
// ErrServiceNotInitialized is returned when the service is not initialised.
|
||||||
|
ErrServiceNotInitialized = coreerr.E("", "process: service not initialized; call process.Init(core) first", nil)
|
||||||
|
// ErrSetDefaultNil is returned when SetDefault is called with nil.
|
||||||
|
ErrSetDefaultNil = coreerr.E("", "process: SetDefault called with nil service", nil)
|
||||||
|
)
|
||||||
305
process_test.go
305
process_test.go
|
|
@ -3,6 +3,7 @@ package process
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -10,10 +11,13 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProcess_Info_Good(t *testing.T) {
|
var _ *Process = (*ManagedProcess)(nil)
|
||||||
|
|
||||||
|
func TestProcess_Info(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
proc := startProc(t, svc, context.Background(), "echo", "hello")
|
proc, err := svc.Start(context.Background(), "echo", "hello")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
|
|
||||||
|
|
@ -21,13 +25,14 @@ func TestProcess_Info_Good(t *testing.T) {
|
||||||
assert.Equal(t, proc.ID, info.ID)
|
assert.Equal(t, proc.ID, info.ID)
|
||||||
assert.Equal(t, "echo", info.Command)
|
assert.Equal(t, "echo", info.Command)
|
||||||
assert.Equal(t, []string{"hello"}, info.Args)
|
assert.Equal(t, []string{"hello"}, info.Args)
|
||||||
|
assert.False(t, info.Running)
|
||||||
assert.Equal(t, StatusExited, info.Status)
|
assert.Equal(t, StatusExited, info.Status)
|
||||||
assert.Equal(t, 0, info.ExitCode)
|
assert.Equal(t, 0, info.ExitCode)
|
||||||
assert.Greater(t, info.Duration, time.Duration(0))
|
assert.Greater(t, info.Duration, time.Duration(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_Info_Pending_Good(t *testing.T) {
|
func TestProcess_Info_Pending(t *testing.T) {
|
||||||
proc := &ManagedProcess{
|
proc := &Process{
|
||||||
ID: "pending",
|
ID: "pending",
|
||||||
Status: StatusPending,
|
Status: StatusPending,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
|
@ -38,163 +43,307 @@ func TestProcess_Info_Pending_Good(t *testing.T) {
|
||||||
assert.False(t, info.Running)
|
assert.False(t, info.Running)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_Output_Good(t *testing.T) {
|
func TestProcess_Info_RunningDuration(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
proc, err := svc.Start(ctx, "sleep", "10")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
info := proc.Info()
|
||||||
|
assert.True(t, info.Running)
|
||||||
|
assert.Equal(t, StatusRunning, info.Status)
|
||||||
|
assert.Greater(t, info.Duration, time.Duration(0))
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
<-proc.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_InfoSnapshot(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
proc, err := svc.Start(context.Background(), "echo", "snapshot")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
<-proc.Done()
|
||||||
|
|
||||||
|
info := proc.Info()
|
||||||
|
require.NotEmpty(t, info.Args)
|
||||||
|
|
||||||
|
info.Args[0] = "mutated"
|
||||||
|
|
||||||
|
assert.Equal(t, "snapshot", proc.Args[0])
|
||||||
|
assert.Equal(t, "mutated", info.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_Output(t *testing.T) {
|
||||||
t.Run("captures stdout", func(t *testing.T) {
|
t.Run("captures stdout", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "echo", "hello world")
|
|
||||||
|
proc, err := svc.Start(context.Background(), "echo", "hello world")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
assert.Contains(t, proc.Output(), "hello world")
|
|
||||||
|
output := proc.Output()
|
||||||
|
assert.Contains(t, output, "hello world")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("OutputBytes returns copy", func(t *testing.T) {
|
t.Run("OutputBytes returns copy", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "echo", "test")
|
|
||||||
|
proc, err := svc.Start(context.Background(), "echo", "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
|
|
||||||
bytes := proc.OutputBytes()
|
bytes := proc.OutputBytes()
|
||||||
assert.NotNil(t, bytes)
|
assert.NotNil(t, bytes)
|
||||||
assert.Contains(t, string(bytes), "test")
|
assert.Contains(t, string(bytes), "test")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_IsRunning_Good(t *testing.T) {
|
func TestProcess_IsRunning(t *testing.T) {
|
||||||
t.Run("true while running", func(t *testing.T) {
|
t.Run("true while running", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
proc := startProc(t, svc, ctx, "sleep", "10")
|
proc, err := svc.Start(ctx, "sleep", "10")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(t, proc.IsRunning())
|
assert.True(t, proc.IsRunning())
|
||||||
|
assert.True(t, proc.Info().Running)
|
||||||
|
|
||||||
cancel()
|
cancel()
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
|
|
||||||
assert.False(t, proc.IsRunning())
|
assert.False(t, proc.IsRunning())
|
||||||
|
assert.False(t, proc.Info().Running)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("false after completion", func(t *testing.T) {
|
t.Run("false after completion", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "echo", "done")
|
|
||||||
|
proc, err := svc.Start(context.Background(), "echo", "done")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
|
|
||||||
assert.False(t, proc.IsRunning())
|
assert.False(t, proc.IsRunning())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_Wait_Good(t *testing.T) {
|
func TestProcess_Wait(t *testing.T) {
|
||||||
t.Run("returns nil on success", func(t *testing.T) {
|
t.Run("returns nil on success", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "echo", "ok")
|
|
||||||
err := proc.Wait()
|
proc, err := svc.Start(context.Background(), "echo", "ok")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = proc.Wait()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("returns error on failure", func(t *testing.T) {
|
t.Run("returns error on failure", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "sh", "-c", "exit 1")
|
|
||||||
err := proc.Wait()
|
proc, err := svc.Start(context.Background(), "sh", "-c", "exit 1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = proc.Wait()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_Done_Good(t *testing.T) {
|
func TestProcess_Done(t *testing.T) {
|
||||||
t.Run("channel closes on completion", func(t *testing.T) {
|
t.Run("channel closes on completion", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "echo", "test")
|
|
||||||
|
proc, err := svc.Start(context.Background(), "echo", "test")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.Done():
|
case <-proc.Done():
|
||||||
|
// Success - channel closed
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("Done channel should have closed")
|
t.Fatal("Done channel should have closed")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_Kill_Good(t *testing.T) {
|
func TestProcess_Kill(t *testing.T) {
|
||||||
t.Run("terminates running process", func(t *testing.T) {
|
t.Run("terminates running process", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
proc := startProc(t, svc, ctx, "sleep", "60")
|
proc, err := svc.Start(ctx, "sleep", "60")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.True(t, proc.IsRunning())
|
assert.True(t, proc.IsRunning())
|
||||||
|
|
||||||
err := proc.Kill()
|
err = proc.Kill()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.Done():
|
case <-proc.Done():
|
||||||
|
// Good - process terminated
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("process should have been killed")
|
t.Fatal("process should have been killed")
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, StatusKilled, proc.Status)
|
assert.Equal(t, StatusKilled, proc.Status)
|
||||||
assert.Equal(t, -1, proc.ExitCode)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("noop on completed process", func(t *testing.T) {
|
t.Run("noop on completed process", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "echo", "done")
|
|
||||||
|
proc, err := svc.Start(context.Background(), "echo", "done")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
err := proc.Kill()
|
|
||||||
|
err = proc.Kill()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_SendInput_Good(t *testing.T) {
|
func TestProcess_SendInput(t *testing.T) {
|
||||||
t.Run("writes to stdin", func(t *testing.T) {
|
t.Run("writes to stdin", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "cat")
|
|
||||||
|
|
||||||
err := proc.SendInput("hello\n")
|
// Use cat to echo back stdin
|
||||||
|
proc, err := svc.Start(context.Background(), "cat")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = proc.SendInput("hello\n")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
err = proc.CloseStdin()
|
err = proc.CloseStdin()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
|
|
||||||
assert.Contains(t, proc.Output(), "hello")
|
assert.Contains(t, proc.Output(), "hello")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error on completed process", func(t *testing.T) {
|
t.Run("error on completed process", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "echo", "done")
|
|
||||||
|
proc, err := svc.Start(context.Background(), "echo", "done")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
err := proc.SendInput("test")
|
|
||||||
|
err = proc.SendInput("test")
|
||||||
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_Signal_Good(t *testing.T) {
|
func TestProcess_Signal(t *testing.T) {
|
||||||
t.Run("sends signal to running process", func(t *testing.T) {
|
t.Run("sends signal to running process", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
proc := startProc(t, svc, ctx, "sleep", "60")
|
proc, err := svc.Start(ctx, "sleep", "60")
|
||||||
err := proc.Signal(os.Interrupt)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = proc.Signal(os.Interrupt)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.Done():
|
case <-proc.Done():
|
||||||
|
// Process terminated by signal
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("process should have been terminated by signal")
|
t.Fatal("process should have been terminated by signal")
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, StatusKilled, proc.Status)
|
assert.Equal(t, StatusKilled, proc.Status)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error on completed process", func(t *testing.T) {
|
t.Run("error on completed process", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "echo", "done")
|
|
||||||
|
proc, err := svc.Start(context.Background(), "echo", "done")
|
||||||
|
require.NoError(t, err)
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
err := proc.Signal(os.Interrupt)
|
|
||||||
|
err = proc.Signal(os.Interrupt)
|
||||||
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
assert.ErrorIs(t, err, ErrProcessNotRunning)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
func TestProcess_CloseStdin_Good(t *testing.T) {
|
t.Run("signals process group when kill group is enabled", func(t *testing.T) {
|
||||||
t.Run("closes stdin pipe", func(t *testing.T) {
|
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "cat")
|
|
||||||
err := proc.CloseStdin()
|
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
|
||||||
|
Command: "sh",
|
||||||
|
Args: []string{"-c", "trap '' INT; sh -c 'trap - INT; sleep 60' & wait"},
|
||||||
|
Detach: true,
|
||||||
|
KillGroup: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = proc.Signal(os.Interrupt)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.Done():
|
case <-proc.Done():
|
||||||
|
// Good - the whole process group responded to the signal.
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("process group should have been terminated by signal")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("signal zero only probes process group liveness", func(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
|
||||||
|
Command: "sh",
|
||||||
|
Args: []string{"-c", "sleep 60 & wait"},
|
||||||
|
Detach: true,
|
||||||
|
KillGroup: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = proc.Signal(syscall.Signal(0))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
assert.True(t, proc.IsRunning())
|
||||||
|
|
||||||
|
err = proc.Kill()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-proc.Done():
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("process group should have been killed for cleanup")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcess_CloseStdin(t *testing.T) {
|
||||||
|
t.Run("closes stdin pipe", func(t *testing.T) {
|
||||||
|
svc, _ := newTestService(t)
|
||||||
|
|
||||||
|
proc, err := svc.Start(context.Background(), "cat")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = proc.CloseStdin()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Process should exit now that stdin is closed
|
||||||
|
select {
|
||||||
|
case <-proc.Done():
|
||||||
|
// Good
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("cat should exit when stdin is closed")
|
t.Fatal("cat should exit when stdin is closed")
|
||||||
}
|
}
|
||||||
|
|
@ -202,132 +351,156 @@ func TestProcess_CloseStdin_Good(t *testing.T) {
|
||||||
|
|
||||||
t.Run("double close is safe", func(t *testing.T) {
|
t.Run("double close is safe", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
proc := startProc(t, svc, context.Background(), "cat")
|
|
||||||
err := proc.CloseStdin()
|
proc, err := svc.Start(context.Background(), "cat")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// First close
|
||||||
|
err = proc.CloseStdin()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
|
|
||||||
|
// Second close should be safe (stdin already nil)
|
||||||
err = proc.CloseStdin()
|
err = proc.CloseStdin()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_Timeout_Good(t *testing.T) {
|
func TestProcess_Timeout(t *testing.T) {
|
||||||
t.Run("kills process after timeout", func(t *testing.T) {
|
t.Run("kills process after timeout", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
||||||
|
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
|
||||||
Command: "sleep",
|
Command: "sleep",
|
||||||
Args: []string{"60"},
|
Args: []string{"60"},
|
||||||
Timeout: 200 * time.Millisecond,
|
Timeout: 200 * time.Millisecond,
|
||||||
})
|
})
|
||||||
require.True(t, r.OK)
|
require.NoError(t, err)
|
||||||
proc := r.Value.(*Process)
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.Done():
|
case <-proc.Done():
|
||||||
|
// Good — process was killed by timeout
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("process should have been killed by timeout")
|
t.Fatal("process should have been killed by timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.False(t, proc.IsRunning())
|
assert.False(t, proc.IsRunning())
|
||||||
assert.Equal(t, StatusKilled, proc.Status)
|
assert.Equal(t, StatusKilled, proc.Status)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("no timeout when zero", func(t *testing.T) {
|
t.Run("no timeout when zero", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
||||||
|
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
|
||||||
Command: "echo",
|
Command: "echo",
|
||||||
Args: []string{"fast"},
|
Args: []string{"fast"},
|
||||||
Timeout: 0,
|
Timeout: 0,
|
||||||
})
|
})
|
||||||
require.True(t, r.OK)
|
require.NoError(t, err)
|
||||||
proc := r.Value.(*Process)
|
|
||||||
<-proc.Done()
|
<-proc.Done()
|
||||||
assert.Equal(t, 0, proc.ExitCode)
|
assert.Equal(t, 0, proc.ExitCode)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_Shutdown_Good(t *testing.T) {
|
func TestProcess_Shutdown(t *testing.T) {
|
||||||
t.Run("graceful with grace period", func(t *testing.T) {
|
t.Run("graceful with grace period", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
||||||
|
// Use a process that traps SIGTERM
|
||||||
|
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
|
||||||
Command: "sleep",
|
Command: "sleep",
|
||||||
Args: []string{"60"},
|
Args: []string{"60"},
|
||||||
GracePeriod: 100 * time.Millisecond,
|
GracePeriod: 100 * time.Millisecond,
|
||||||
})
|
})
|
||||||
require.True(t, r.OK)
|
require.NoError(t, err)
|
||||||
proc := r.Value.(*Process)
|
|
||||||
|
|
||||||
assert.True(t, proc.IsRunning())
|
assert.True(t, proc.IsRunning())
|
||||||
err := proc.Shutdown()
|
|
||||||
|
err = proc.Shutdown()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.Done():
|
case <-proc.Done():
|
||||||
|
// Good
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("shutdown should have completed")
|
t.Fatal("shutdown should have completed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, StatusKilled, proc.Status)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("immediate kill without grace period", func(t *testing.T) {
|
t.Run("immediate kill without grace period", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
||||||
|
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
|
||||||
Command: "sleep",
|
Command: "sleep",
|
||||||
Args: []string{"60"},
|
Args: []string{"60"},
|
||||||
})
|
})
|
||||||
require.True(t, r.OK)
|
require.NoError(t, err)
|
||||||
proc := r.Value.(*Process)
|
|
||||||
|
|
||||||
err := proc.Shutdown()
|
err = proc.Shutdown()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.Done():
|
case <-proc.Done():
|
||||||
|
// Good
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatal("kill should be immediate")
|
t.Fatal("kill should be immediate")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_KillGroup_Good(t *testing.T) {
|
func TestProcess_KillGroup(t *testing.T) {
|
||||||
t.Run("kills child processes", func(t *testing.T) {
|
t.Run("kills child processes", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
||||||
|
// Spawn a parent that spawns a child — KillGroup should kill both
|
||||||
|
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
|
||||||
Command: "sh",
|
Command: "sh",
|
||||||
Args: []string{"-c", "sleep 60 & wait"},
|
Args: []string{"-c", "sleep 60 & wait"},
|
||||||
Detach: true,
|
Detach: true,
|
||||||
KillGroup: true,
|
KillGroup: true,
|
||||||
})
|
})
|
||||||
require.True(t, r.OK)
|
require.NoError(t, err)
|
||||||
proc := r.Value.(*Process)
|
|
||||||
|
|
||||||
|
// Give child time to spawn
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
err := proc.Kill()
|
|
||||||
|
err = proc.Kill()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.Done():
|
case <-proc.Done():
|
||||||
|
// Good — whole group killed
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("process group should have been killed")
|
t.Fatal("process group should have been killed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, StatusKilled, proc.Status)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcess_TimeoutWithGrace_Good(t *testing.T) {
|
func TestProcess_TimeoutWithGrace(t *testing.T) {
|
||||||
t.Run("timeout triggers graceful shutdown", func(t *testing.T) {
|
t.Run("timeout triggers graceful shutdown", func(t *testing.T) {
|
||||||
svc, _ := newTestService(t)
|
svc, _ := newTestService(t)
|
||||||
r := svc.StartWithOptions(context.Background(), RunOptions{
|
|
||||||
|
proc, err := svc.StartWithOptions(context.Background(), RunOptions{
|
||||||
Command: "sleep",
|
Command: "sleep",
|
||||||
Args: []string{"60"},
|
Args: []string{"60"},
|
||||||
Timeout: 200 * time.Millisecond,
|
Timeout: 200 * time.Millisecond,
|
||||||
GracePeriod: 100 * time.Millisecond,
|
GracePeriod: 100 * time.Millisecond,
|
||||||
})
|
})
|
||||||
require.True(t, r.OK)
|
require.NoError(t, err)
|
||||||
proc := r.Value.(*Process)
|
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-proc.Done():
|
case <-proc.Done():
|
||||||
|
// Good — timeout + grace triggered
|
||||||
case <-time.After(5 * time.Second):
|
case <-time.After(5 * time.Second):
|
||||||
t.Fatal("process should have been killed by timeout")
|
t.Fatal("process should have been killed by timeout")
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, StatusKilled, proc.Status)
|
assert.Equal(t, StatusKilled, proc.Status)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
program.go
71
program.go
|
|
@ -3,24 +3,36 @@ package process
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"path/filepath"
|
"os/exec"
|
||||||
"strconv"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
core "dappco.re/go/core"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrProgramNotFound is returned when Find cannot locate the binary on PATH.
|
// ErrProgramNotFound is returned when Find cannot locate the binary on PATH.
|
||||||
// Callers may use core.Is to detect this condition.
|
// Callers may use errors.Is to detect this condition.
|
||||||
var ErrProgramNotFound = core.E("", "program: binary not found in PATH", nil)
|
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.
|
// Program represents a named executable located on the system PATH.
|
||||||
// Create one with a Name, call Find to resolve its path, then Run or RunDir.
|
|
||||||
//
|
//
|
||||||
// p := &process.Program{Name: "go"}
|
// Example:
|
||||||
|
//
|
||||||
|
// git := &process.Program{Name: "git"}
|
||||||
|
// if err := git.Find(); err != nil { return err }
|
||||||
|
// out, err := git.Run(ctx, "status")
|
||||||
type Program struct {
|
type Program struct {
|
||||||
// Name is the binary name (e.g. "go", "node", "git").
|
// Name is the binary name (e.g. "go", "node", "git").
|
||||||
Name string
|
Name string
|
||||||
// Path is the absolute path resolved by Find.
|
// 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.
|
// If empty, Run and RunDir fall back to Name for OS PATH resolution.
|
||||||
Path string
|
Path string
|
||||||
}
|
}
|
||||||
|
|
@ -28,20 +40,20 @@ type Program struct {
|
||||||
// Find resolves the program's absolute path using exec.LookPath.
|
// Find resolves the program's absolute path using exec.LookPath.
|
||||||
// Returns ErrProgramNotFound (wrapped) if the binary is not on PATH.
|
// Returns ErrProgramNotFound (wrapped) if the binary is not on PATH.
|
||||||
//
|
//
|
||||||
// err := p.Find()
|
// Example:
|
||||||
|
//
|
||||||
|
// if err := p.Find(); err != nil { return err }
|
||||||
func (p *Program) Find() error {
|
func (p *Program) Find() error {
|
||||||
if p.Name == "" {
|
target := p.Path
|
||||||
return core.E("program.find", "program name is empty", nil)
|
if target == "" {
|
||||||
|
target = p.Name
|
||||||
}
|
}
|
||||||
path, err := execLookPath(p.Name)
|
if target == "" {
|
||||||
|
return coreerr.E("Program.Find", "program name is empty", nil)
|
||||||
|
}
|
||||||
|
path, err := exec.LookPath(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return core.E("program.find", core.Concat(strconv.Quote(p.Name), ": not found in PATH"), ErrProgramNotFound)
|
return coreerr.E("Program.Find", core.Sprintf("%q: not found in PATH", target), ErrProgramNotFound)
|
||||||
}
|
|
||||||
if !filepath.IsAbs(path) {
|
|
||||||
path, err = filepath.Abs(path)
|
|
||||||
if err != nil {
|
|
||||||
return core.E("program.find", core.Concat(strconv.Quote(p.Name), ": failed to resolve absolute path"), err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
p.Path = path
|
p.Path = path
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -50,7 +62,9 @@ func (p *Program) Find() error {
|
||||||
// Run executes the program with args in the current working directory.
|
// Run executes the program with args in the current working directory.
|
||||||
// Returns trimmed combined stdout+stderr output and any error.
|
// Returns trimmed combined stdout+stderr output and any error.
|
||||||
//
|
//
|
||||||
// out, err := p.Run(ctx, "version")
|
// Example:
|
||||||
|
//
|
||||||
|
// out, err := p.Run(ctx, "hello")
|
||||||
func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
|
func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
|
||||||
return p.RunDir(ctx, "", args...)
|
return p.RunDir(ctx, "", args...)
|
||||||
}
|
}
|
||||||
|
|
@ -59,18 +73,25 @@ func (p *Program) Run(ctx context.Context, args ...string) (string, error) {
|
||||||
// Returns trimmed combined stdout+stderr output and any error.
|
// Returns trimmed combined stdout+stderr output and any error.
|
||||||
// If dir is empty, the process inherits the caller's working directory.
|
// If dir is empty, the process inherits the caller's working directory.
|
||||||
//
|
//
|
||||||
// out, err := p.RunDir(ctx, "/workspace", "test", "./...")
|
// Example:
|
||||||
|
//
|
||||||
|
// out, err := p.RunDir(ctx, "/tmp", "pwd")
|
||||||
func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) {
|
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
|
binary := p.Path
|
||||||
if binary == "" {
|
if binary == "" {
|
||||||
binary = p.Name
|
binary = p.Name
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
|
||||||
ctx = context.Background()
|
if binary == "" {
|
||||||
|
return "", coreerr.E("Program.RunDir", "program name is empty", ErrProgramNameRequired)
|
||||||
}
|
}
|
||||||
|
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
cmd := execCommandContext(ctx, binary, args...)
|
cmd := exec.CommandContext(ctx, binary, args...)
|
||||||
cmd.Stdout = &out
|
cmd.Stdout = &out
|
||||||
cmd.Stderr = &out
|
cmd.Stderr = &out
|
||||||
if dir != "" {
|
if dir != "" {
|
||||||
|
|
@ -78,7 +99,7 @@ func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (strin
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
if err := cmd.Run(); err != nil {
|
||||||
return string(bytes.TrimSpace(out.Bytes())), core.E("program.run", core.Concat(strconv.Quote(p.Name), ": command failed"), err)
|
return strings.TrimRightFunc(out.String(), unicode.IsSpace), coreerr.E("Program.RunDir", core.Sprintf("%q: command failed", p.Name), err)
|
||||||
}
|
}
|
||||||
return string(bytes.TrimSpace(out.Bytes())), nil
|
return strings.TrimRightFunc(out.String(), unicode.IsSpace), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@ package process_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
|
@ -21,26 +20,47 @@ func testCtx(t *testing.T) context.Context {
|
||||||
return ctx
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProgram_Find_Good(t *testing.T) {
|
func TestProgram_Find_KnownBinary(t *testing.T) {
|
||||||
p := &process.Program{Name: "echo"}
|
p := &process.Program{Name: "echo"}
|
||||||
require.NoError(t, p.Find())
|
require.NoError(t, p.Find())
|
||||||
assert.NotEmpty(t, p.Path)
|
assert.NotEmpty(t, p.Path)
|
||||||
assert.True(t, filepath.IsAbs(p.Path))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProgram_FindUnknown_Bad(t *testing.T) {
|
func TestProgram_Find_UnknownBinary(t *testing.T) {
|
||||||
p := &process.Program{Name: "no-such-binary-xyzzy-42"}
|
p := &process.Program{Name: "no-such-binary-xyzzy-42"}
|
||||||
err := p.Find()
|
err := p.Find()
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, process.ErrProgramNotFound)
|
assert.ErrorIs(t, err, process.ErrProgramNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProgram_FindEmpty_Bad(t *testing.T) {
|
func TestProgram_Find_UsesExistingPath(t *testing.T) {
|
||||||
|
path, err := exec.LookPath("echo")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
p := &process.Program{Path: path}
|
||||||
|
require.NoError(t, p.Find())
|
||||||
|
assert.Equal(t, path, p.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgram_Find_PrefersExistingPathOverName(t *testing.T) {
|
||||||
|
path, err := exec.LookPath("echo")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
p := &process.Program{
|
||||||
|
Name: "no-such-binary-xyzzy-42",
|
||||||
|
Path: path,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, p.Find())
|
||||||
|
assert.Equal(t, path, p.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgram_Find_EmptyName(t *testing.T) {
|
||||||
p := &process.Program{}
|
p := &process.Program{}
|
||||||
require.Error(t, p.Find())
|
require.Error(t, p.Find())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProgram_Run_Good(t *testing.T) {
|
func TestProgram_Run_ReturnsOutput(t *testing.T) {
|
||||||
p := &process.Program{Name: "echo"}
|
p := &process.Program{Name: "echo"}
|
||||||
require.NoError(t, p.Find())
|
require.NoError(t, p.Find())
|
||||||
|
|
||||||
|
|
@ -49,7 +69,16 @@ func TestProgram_Run_Good(t *testing.T) {
|
||||||
assert.Equal(t, "hello", out)
|
assert.Equal(t, "hello", out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProgram_RunFallback_Good(t *testing.T) {
|
func TestProgram_Run_PreservesLeadingWhitespace(t *testing.T) {
|
||||||
|
p := &process.Program{Name: "sh"}
|
||||||
|
require.NoError(t, p.Find())
|
||||||
|
|
||||||
|
out, err := p.Run(testCtx(t), "-c", "printf ' hello \n'")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, " hello", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProgram_Run_WithoutFind_FallsBackToName(t *testing.T) {
|
||||||
// Path is empty; RunDir should fall back to Name for OS PATH resolution.
|
// Path is empty; RunDir should fall back to Name for OS PATH resolution.
|
||||||
p := &process.Program{Name: "echo"}
|
p := &process.Program{Name: "echo"}
|
||||||
|
|
||||||
|
|
@ -58,15 +87,7 @@ func TestProgram_RunFallback_Good(t *testing.T) {
|
||||||
assert.Equal(t, "fallback", out)
|
assert.Equal(t, "fallback", out)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProgram_RunNilContext_Good(t *testing.T) {
|
func TestProgram_RunDir_UsesDirectory(t *testing.T) {
|
||||||
p := &process.Program{Name: "echo"}
|
|
||||||
|
|
||||||
out, err := p.Run(nil, "nil-context")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "nil-context", out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProgram_RunDir_Good(t *testing.T) {
|
|
||||||
p := &process.Program{Name: "pwd"}
|
p := &process.Program{Name: "pwd"}
|
||||||
require.NoError(t, p.Find())
|
require.NoError(t, p.Find())
|
||||||
|
|
||||||
|
|
@ -74,17 +95,34 @@ func TestProgram_RunDir_Good(t *testing.T) {
|
||||||
|
|
||||||
out, err := p.RunDir(testCtx(t), dir)
|
out, err := p.RunDir(testCtx(t), dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
dirInfo, err := os.Stat(dir)
|
// Resolve symlinks on both sides for portability (macOS uses /private/ prefix).
|
||||||
|
canonicalDir, err := filepath.EvalSymlinks(dir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
outInfo, err := os.Stat(core.Trim(out))
|
canonicalOut, err := filepath.EvalSymlinks(out)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, os.SameFile(dirInfo, outInfo))
|
assert.Equal(t, canonicalDir, canonicalOut)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProgram_RunFailure_Bad(t *testing.T) {
|
func TestProgram_Run_FailingCommand(t *testing.T) {
|
||||||
p := &process.Program{Name: "false"}
|
p := &process.Program{Name: "false"}
|
||||||
require.NoError(t, p.Find())
|
require.NoError(t, p.Find())
|
||||||
|
|
||||||
_, err := p.Run(testCtx(t))
|
_, err := p.Run(testCtx(t))
|
||||||
require.Error(t, err)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
350
registry.go
350
registry.go
|
|
@ -1,18 +1,23 @@
|
||||||
package process
|
package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path"
|
"encoding/json"
|
||||||
"strconv"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
|
||||||
coreio "dappco.re/go/core/io"
|
coreio "dappco.re/go/core/io"
|
||||||
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DaemonEntry records a running daemon in the registry.
|
// DaemonEntry records a running daemon in the registry.
|
||||||
//
|
//
|
||||||
// entry := process.DaemonEntry{Code: "myapp", Daemon: "serve", PID: 1234}
|
// Example:
|
||||||
|
//
|
||||||
|
// entry := process.DaemonEntry{Code: "app", Daemon: "serve", PID: os.Getpid()}
|
||||||
type DaemonEntry struct {
|
type DaemonEntry struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
Daemon string `json:"daemon"`
|
Daemon string `json:"daemon"`
|
||||||
|
|
@ -24,63 +29,80 @@ type DaemonEntry struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry tracks running daemons via JSON files in a directory.
|
// Registry tracks running daemons via JSON files in a directory.
|
||||||
//
|
|
||||||
// reg := process.NewRegistry("/tmp/process-daemons")
|
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
dir string
|
dir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRegistry creates a registry backed by the given directory.
|
// NewRegistry creates a registry backed by the given directory.
|
||||||
//
|
//
|
||||||
// reg := process.NewRegistry("/tmp/process-daemons")
|
// Example:
|
||||||
|
//
|
||||||
|
// reg := process.NewRegistry("/tmp/daemons")
|
||||||
func NewRegistry(dir string) *Registry {
|
func NewRegistry(dir string) *Registry {
|
||||||
return &Registry{dir: dir}
|
return &Registry{dir: dir}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRegistry returns a registry using ~/.core/daemons/.
|
// DefaultRegistry returns a registry using ~/.core/daemons/.
|
||||||
//
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
// reg := process.DefaultRegistry()
|
// reg := process.DefaultRegistry()
|
||||||
func DefaultRegistry() *Registry {
|
func DefaultRegistry() *Registry {
|
||||||
home, err := userHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
home = tempDir()
|
home = os.TempDir()
|
||||||
}
|
}
|
||||||
return NewRegistry(path.Join(home, ".core", "daemons"))
|
return NewRegistry(filepath.Join(home, ".core", "daemons"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register writes a daemon entry to the registry directory.
|
// Register writes a daemon entry to the registry directory.
|
||||||
// If Started is zero, it is set to the current time.
|
// If Started is zero, it is set to the current time.
|
||||||
// The directory is created if it does not exist.
|
// The directory is created if it does not exist.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = reg.Register(entry)
|
||||||
func (r *Registry) Register(entry DaemonEntry) error {
|
func (r *Registry) Register(entry DaemonEntry) error {
|
||||||
if entry.Started.IsZero() {
|
if entry.Started.IsZero() {
|
||||||
entry.Started = time.Now()
|
entry.Started = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := coreio.Local.EnsureDir(r.dir); err != nil {
|
if err := coreio.Local.EnsureDir(r.dir); err != nil {
|
||||||
return core.E("registry.register", "failed to create registry directory", err)
|
return coreerr.E("Registry.Register", "failed to create registry directory", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := marshalDaemonEntry(entry)
|
data, err := json.MarshalIndent(entry, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return core.E("registry.register", "failed to marshal entry", err)
|
return coreerr.E("Registry.Register", "failed to marshal entry", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), data); err != nil {
|
if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), string(data)); err != nil {
|
||||||
return core.E("registry.register", "failed to write entry file", err)
|
return coreerr.E("Registry.Register", "failed to write entry file", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unregister removes a daemon entry from the registry.
|
// Unregister removes a daemon entry from the registry.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// _ = reg.Unregister("app", "serve")
|
||||||
func (r *Registry) Unregister(code, daemon string) error {
|
func (r *Registry) Unregister(code, daemon string) error {
|
||||||
if err := coreio.Local.Delete(r.entryPath(code, daemon)); err != nil {
|
if err := coreio.Local.Delete(r.entryPath(code, daemon)); err != nil {
|
||||||
return core.E("registry.unregister", "failed to delete entry file", err)
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return coreerr.E("Registry.Unregister", "failed to delete entry file", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get reads a single daemon entry and checks whether its process is alive.
|
// 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.
|
// 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) {
|
func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
|
||||||
path := r.entryPath(code, daemon)
|
path := r.entryPath(code, daemon)
|
||||||
|
|
||||||
|
|
@ -89,8 +111,8 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := unmarshalDaemonEntry(data)
|
var entry DaemonEntry
|
||||||
if err != nil {
|
if err := json.Unmarshal([]byte(data), &entry); err != nil {
|
||||||
_ = coreio.Local.Delete(path)
|
_ = coreio.Local.Delete(path)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
@ -104,29 +126,25 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all alive daemon entries, pruning any with dead PIDs.
|
// List returns all alive daemon entries, pruning any with dead PIDs.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// entries, err := reg.List()
|
||||||
func (r *Registry) List() ([]DaemonEntry, error) {
|
func (r *Registry) List() ([]DaemonEntry, error) {
|
||||||
if !coreio.Local.Exists(r.dir) {
|
matches, err := filepath.Glob(filepath.Join(r.dir, "*.json"))
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := coreio.Local.List(r.dir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, core.E("registry.list", "failed to list registry directory", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var alive []DaemonEntry
|
var alive []DaemonEntry
|
||||||
for _, entryFile := range entries {
|
for _, path := range matches {
|
||||||
if entryFile.IsDir() || !core.HasSuffix(entryFile.Name(), ".json") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
path := path.Join(r.dir, entryFile.Name())
|
|
||||||
data, err := coreio.Local.Read(path)
|
data, err := coreio.Local.Read(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
entry, err := unmarshalDaemonEntry(data)
|
var entry DaemonEntry
|
||||||
if err != nil {
|
if err := json.Unmarshal([]byte(data), &entry); err != nil {
|
||||||
_ = coreio.Local.Delete(path)
|
_ = coreio.Local.Delete(path)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -139,13 +157,23 @@ func (r *Registry) List() ([]DaemonEntry, error) {
|
||||||
alive = append(alive, entry)
|
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
|
return alive, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// entryPath returns the filesystem path for a daemon entry.
|
// entryPath returns the filesystem path for a daemon entry.
|
||||||
func (r *Registry) entryPath(code, daemon string) string {
|
func (r *Registry) entryPath(code, daemon string) string {
|
||||||
name := sanitizeRegistryComponent(code) + "-" + sanitizeRegistryComponent(daemon) + ".json"
|
name := strings.ReplaceAll(code, "/", "-") + "-" + strings.ReplaceAll(daemon, "/", "-") + ".json"
|
||||||
return path.Join(r.dir, name)
|
return filepath.Join(r.dir, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAlive checks whether a process with the given PID is running.
|
// isAlive checks whether a process with the given PID is running.
|
||||||
|
|
@ -153,263 +181,9 @@ func isAlive(pid int) bool {
|
||||||
if pid <= 0 {
|
if pid <= 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
proc, err := processHandle(pid)
|
proc, err := os.FindProcess(pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return proc.Signal(syscall.Signal(0)) == nil
|
return proc.Signal(syscall.Signal(0)) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeRegistryComponent(value string) string {
|
|
||||||
buf := make([]byte, len(value))
|
|
||||||
for i := 0; i < len(value); i++ {
|
|
||||||
if value[i] == '/' {
|
|
||||||
buf[i] = '-'
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
buf[i] = value[i]
|
|
||||||
}
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalDaemonEntry(entry DaemonEntry) (string, error) {
|
|
||||||
fields := []struct {
|
|
||||||
key string
|
|
||||||
value string
|
|
||||||
}{
|
|
||||||
{key: "code", value: quoteJSONString(entry.Code)},
|
|
||||||
{key: "daemon", value: quoteJSONString(entry.Daemon)},
|
|
||||||
{key: "pid", value: strconv.Itoa(entry.PID)},
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.Health != "" {
|
|
||||||
fields = append(fields, struct {
|
|
||||||
key string
|
|
||||||
value string
|
|
||||||
}{key: "health", value: quoteJSONString(entry.Health)})
|
|
||||||
}
|
|
||||||
if entry.Project != "" {
|
|
||||||
fields = append(fields, struct {
|
|
||||||
key string
|
|
||||||
value string
|
|
||||||
}{key: "project", value: quoteJSONString(entry.Project)})
|
|
||||||
}
|
|
||||||
if entry.Binary != "" {
|
|
||||||
fields = append(fields, struct {
|
|
||||||
key string
|
|
||||||
value string
|
|
||||||
}{key: "binary", value: quoteJSONString(entry.Binary)})
|
|
||||||
}
|
|
||||||
|
|
||||||
fields = append(fields, struct {
|
|
||||||
key string
|
|
||||||
value string
|
|
||||||
}{
|
|
||||||
key: "started",
|
|
||||||
value: quoteJSONString(entry.Started.Format(time.RFC3339Nano)),
|
|
||||||
})
|
|
||||||
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
builder.WriteString("{\n")
|
|
||||||
for i, field := range fields {
|
|
||||||
builder.WriteString(core.Concat(" ", quoteJSONString(field.key), ": ", field.value))
|
|
||||||
if i < len(fields)-1 {
|
|
||||||
builder.WriteString(",")
|
|
||||||
}
|
|
||||||
builder.WriteString("\n")
|
|
||||||
}
|
|
||||||
builder.WriteString("}")
|
|
||||||
return builder.String(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func unmarshalDaemonEntry(data string) (DaemonEntry, error) {
|
|
||||||
values, err := parseJSONObject(data)
|
|
||||||
if err != nil {
|
|
||||||
return DaemonEntry{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := DaemonEntry{
|
|
||||||
Code: values["code"],
|
|
||||||
Daemon: values["daemon"],
|
|
||||||
Health: values["health"],
|
|
||||||
Project: values["project"],
|
|
||||||
Binary: values["binary"],
|
|
||||||
}
|
|
||||||
|
|
||||||
pidValue, ok := values["pid"]
|
|
||||||
if !ok {
|
|
||||||
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing pid", nil)
|
|
||||||
}
|
|
||||||
entry.PID, err = strconv.Atoi(pidValue)
|
|
||||||
if err != nil {
|
|
||||||
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid pid", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
startedValue, ok := values["started"]
|
|
||||||
if !ok {
|
|
||||||
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing started", nil)
|
|
||||||
}
|
|
||||||
entry.Started, err = time.Parse(time.RFC3339Nano, startedValue)
|
|
||||||
if err != nil {
|
|
||||||
return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid started timestamp", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseJSONObject(data string) (map[string]string, error) {
|
|
||||||
trimmed := core.Trim(data)
|
|
||||||
if trimmed == "" {
|
|
||||||
return nil, core.E("Registry.parseJSONObject", "empty JSON object", nil)
|
|
||||||
}
|
|
||||||
if trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' {
|
|
||||||
return nil, core.E("Registry.parseJSONObject", "invalid JSON object", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
values := make(map[string]string)
|
|
||||||
index := skipJSONSpace(trimmed, 1)
|
|
||||||
for index < len(trimmed) {
|
|
||||||
if trimmed[index] == '}' {
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
key, next, err := parseJSONString(trimmed, index)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
index = skipJSONSpace(trimmed, next)
|
|
||||||
if index >= len(trimmed) || trimmed[index] != ':' {
|
|
||||||
return nil, core.E("Registry.parseJSONObject", "missing key separator", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
index = skipJSONSpace(trimmed, index+1)
|
|
||||||
if index >= len(trimmed) {
|
|
||||||
return nil, core.E("Registry.parseJSONObject", "missing value", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
var value string
|
|
||||||
if trimmed[index] == '"' {
|
|
||||||
value, index, err = parseJSONString(trimmed, index)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
start := index
|
|
||||||
for index < len(trimmed) && trimmed[index] != ',' && trimmed[index] != '}' {
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
value = core.Trim(trimmed[start:index])
|
|
||||||
}
|
|
||||||
values[key] = value
|
|
||||||
|
|
||||||
index = skipJSONSpace(trimmed, index)
|
|
||||||
if index >= len(trimmed) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if trimmed[index] == ',' {
|
|
||||||
index = skipJSONSpace(trimmed, index+1)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if trimmed[index] == '}' {
|
|
||||||
return values, nil
|
|
||||||
}
|
|
||||||
return nil, core.E("Registry.parseJSONObject", "invalid object separator", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, core.E("Registry.parseJSONObject", "unterminated JSON object", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseJSONString(data string, start int) (string, int, error) {
|
|
||||||
if start >= len(data) || data[start] != '"' {
|
|
||||||
return "", 0, core.E("Registry.parseJSONString", "expected quoted string", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
for index := start + 1; index < len(data); index++ {
|
|
||||||
ch := data[index]
|
|
||||||
if ch == '"' {
|
|
||||||
return builder.String(), index + 1, nil
|
|
||||||
}
|
|
||||||
if ch != '\\' {
|
|
||||||
builder.WriteByte(ch)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
index++
|
|
||||||
if index >= len(data) {
|
|
||||||
return "", 0, core.E("Registry.parseJSONString", "unterminated escape sequence", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch data[index] {
|
|
||||||
case '"', '\\', '/':
|
|
||||||
builder.WriteByte(data[index])
|
|
||||||
case 'b':
|
|
||||||
builder.WriteByte('\b')
|
|
||||||
case 'f':
|
|
||||||
builder.WriteByte('\f')
|
|
||||||
case 'n':
|
|
||||||
builder.WriteByte('\n')
|
|
||||||
case 'r':
|
|
||||||
builder.WriteByte('\r')
|
|
||||||
case 't':
|
|
||||||
builder.WriteByte('\t')
|
|
||||||
case 'u':
|
|
||||||
if index+4 >= len(data) {
|
|
||||||
return "", 0, core.E("Registry.parseJSONString", "short unicode escape", nil)
|
|
||||||
}
|
|
||||||
r, err := strconv.ParseInt(data[index+1:index+5], 16, 32)
|
|
||||||
if err != nil {
|
|
||||||
return "", 0, core.E("Registry.parseJSONString", "invalid unicode escape", err)
|
|
||||||
}
|
|
||||||
builder.WriteRune(rune(r))
|
|
||||||
index += 4
|
|
||||||
default:
|
|
||||||
return "", 0, core.E("Registry.parseJSONString", "invalid escape sequence", nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", 0, core.E("Registry.parseJSONString", "unterminated string", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func skipJSONSpace(data string, index int) int {
|
|
||||||
for index < len(data) {
|
|
||||||
switch data[index] {
|
|
||||||
case ' ', '\n', '\r', '\t':
|
|
||||||
index++
|
|
||||||
default:
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return index
|
|
||||||
}
|
|
||||||
|
|
||||||
func quoteJSONString(value string) string {
|
|
||||||
builder := core.NewBuilder()
|
|
||||||
builder.WriteByte('"')
|
|
||||||
for i := 0; i < len(value); i++ {
|
|
||||||
switch value[i] {
|
|
||||||
case '\\', '"':
|
|
||||||
builder.WriteByte('\\')
|
|
||||||
builder.WriteByte(value[i])
|
|
||||||
case '\b':
|
|
||||||
builder.WriteString(`\b`)
|
|
||||||
case '\f':
|
|
||||||
builder.WriteString(`\f`)
|
|
||||||
case '\n':
|
|
||||||
builder.WriteString(`\n`)
|
|
||||||
case '\r':
|
|
||||||
builder.WriteString(`\r`)
|
|
||||||
case '\t':
|
|
||||||
builder.WriteString(`\t`)
|
|
||||||
default:
|
|
||||||
if value[i] < 0x20 {
|
|
||||||
builder.WriteString(core.Sprintf("\\u%04x", value[i]))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
builder.WriteByte(value[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
builder.WriteByte('"')
|
|
||||||
return builder.String()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,15 @@ package process
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegistry_Register_Good(t *testing.T) {
|
func TestRegistry_RegisterAndGet(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
reg := NewRegistry(dir)
|
reg := NewRegistry(dir)
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ func TestRegistry_Register_Good(t *testing.T) {
|
||||||
assert.Equal(t, started, got.Started)
|
assert.Equal(t, started, got.Started)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_Unregister_Good(t *testing.T) {
|
func TestRegistry_Unregister(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
reg := NewRegistry(dir)
|
reg := NewRegistry(dir)
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ func TestRegistry_Unregister_Good(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// File should exist
|
// File should exist
|
||||||
path := core.JoinPath(dir, "myapp-server.json")
|
path := filepath.Join(dir, "myapp-server.json")
|
||||||
_, err = os.Stat(path)
|
_, err = os.Stat(path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -65,7 +65,15 @@ func TestRegistry_Unregister_Good(t *testing.T) {
|
||||||
assert.True(t, os.IsNotExist(err))
|
assert.True(t, os.IsNotExist(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_List_Good(t *testing.T) {
|
func TestRegistry_UnregisterMissingIsNoop(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
reg := NewRegistry(dir)
|
||||||
|
|
||||||
|
err := reg.Unregister("missing", "entry")
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRegistry_List(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
reg := NewRegistry(dir)
|
reg := NewRegistry(dir)
|
||||||
|
|
||||||
|
|
@ -76,10 +84,12 @@ func TestRegistry_List_Good(t *testing.T) {
|
||||||
|
|
||||||
entries, err := reg.List()
|
entries, err := reg.List()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, entries, 2)
|
require.Len(t, entries, 2)
|
||||||
|
assert.Equal(t, "app1", entries[0].Code)
|
||||||
|
assert.Equal(t, "app2", entries[1].Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_PruneStale_Good(t *testing.T) {
|
func TestRegistry_List_PrunesStale(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
reg := NewRegistry(dir)
|
reg := NewRegistry(dir)
|
||||||
|
|
||||||
|
|
@ -87,7 +97,7 @@ func TestRegistry_PruneStale_Good(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// File should exist before listing
|
// File should exist before listing
|
||||||
path := core.JoinPath(dir, "dead-proc.json")
|
path := filepath.Join(dir, "dead-proc.json")
|
||||||
_, err = os.Stat(path)
|
_, err = os.Stat(path)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|
@ -100,7 +110,7 @@ func TestRegistry_PruneStale_Good(t *testing.T) {
|
||||||
assert.True(t, os.IsNotExist(err))
|
assert.True(t, os.IsNotExist(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_GetMissing_Bad(t *testing.T) {
|
func TestRegistry_Get_NotFound(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
reg := NewRegistry(dir)
|
reg := NewRegistry(dir)
|
||||||
|
|
||||||
|
|
@ -109,8 +119,8 @@ func TestRegistry_GetMissing_Bad(t *testing.T) {
|
||||||
assert.False(t, ok)
|
assert.False(t, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_CreateDirectory_Good(t *testing.T) {
|
func TestRegistry_CreatesDirectory(t *testing.T) {
|
||||||
dir := core.JoinPath(t.TempDir(), "nested", "deep", "daemons")
|
dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons")
|
||||||
reg := NewRegistry(dir)
|
reg := NewRegistry(dir)
|
||||||
|
|
||||||
err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()})
|
err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()})
|
||||||
|
|
@ -121,7 +131,7 @@ func TestRegistry_CreateDirectory_Good(t *testing.T) {
|
||||||
assert.True(t, info.IsDir())
|
assert.True(t, info.IsDir())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRegistry_Default_Good(t *testing.T) {
|
func TestDefaultRegistry(t *testing.T) {
|
||||||
reg := DefaultRegistry()
|
reg := DefaultRegistry()
|
||||||
assert.NotNil(t, reg)
|
assert.NotNil(t, reg)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
195
runner.go
195
runner.go
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"dappco.re/go/core"
|
coreerr "dappco.re/go/core/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Runner orchestrates multiple processes with dependencies.
|
// Runner orchestrates multiple processes with dependencies.
|
||||||
|
|
@ -14,14 +14,31 @@ type Runner struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrRunnerNoService is returned when a runner was created without a service.
|
// ErrRunnerNoService is returned when a runner was created without a service.
|
||||||
var ErrRunnerNoService = core.E("", "runner service is nil", nil)
|
var ErrRunnerNoService = coreerr.E("", "runner service is nil", nil)
|
||||||
|
|
||||||
|
// ErrRunnerInvalidSpecName is returned when a RunSpec name is empty or duplicated.
|
||||||
|
var ErrRunnerInvalidSpecName = coreerr.E("", "runner spec names must be non-empty and unique", nil)
|
||||||
|
|
||||||
|
// ErrRunnerInvalidDependencyName is returned when a RunSpec dependency name is empty, duplicated, or self-referential.
|
||||||
|
var ErrRunnerInvalidDependencyName = coreerr.E("", "runner dependency names must be non-empty, unique, and not self-referential", nil)
|
||||||
|
|
||||||
|
// ErrRunnerContextRequired is returned when a runner method is called without a context.
|
||||||
|
var ErrRunnerContextRequired = coreerr.E("", "runner context is required", nil)
|
||||||
|
|
||||||
// NewRunner creates a runner for the given service.
|
// NewRunner creates a runner for the given service.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// runner := process.NewRunner(svc)
|
||||||
func NewRunner(svc *Service) *Runner {
|
func NewRunner(svc *Service) *Runner {
|
||||||
return &Runner{service: svc}
|
return &Runner{service: svc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunSpec defines a process to run with optional dependencies.
|
// RunSpec defines a process to run with optional dependencies.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// spec := process.RunSpec{Name: "test", Command: "go", Args: []string{"test", "./..."}}
|
||||||
type RunSpec struct {
|
type RunSpec struct {
|
||||||
// Name is a friendly identifier (e.g., "lint", "test").
|
// Name is a friendly identifier (e.g., "lint", "test").
|
||||||
Name string
|
Name string
|
||||||
|
|
@ -46,11 +63,17 @@ type RunResult struct {
|
||||||
ExitCode int
|
ExitCode int
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
Output string
|
Output string
|
||||||
Error error
|
// Error only reports start-time or orchestration failures. A started process
|
||||||
Skipped bool
|
// that exits non-zero uses ExitCode to report failure and leaves Error nil.
|
||||||
|
Error error
|
||||||
|
Skipped bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passed returns true if the process succeeded.
|
// Passed returns true if the process succeeded.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if result.Passed() { ... }
|
||||||
func (r RunResult) Passed() bool {
|
func (r RunResult) Passed() bool {
|
||||||
return !r.Skipped && r.Error == nil && r.ExitCode == 0
|
return !r.Skipped && r.Error == nil && r.ExitCode == 0
|
||||||
}
|
}
|
||||||
|
|
@ -64,24 +87,38 @@ type RunAllResult struct {
|
||||||
Skipped int
|
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 {
|
func (r RunAllResult) Success() bool {
|
||||||
return r.Failed == 0
|
return r.Failed == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunAll executes specs respecting dependencies, parallelising where possible.
|
// 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) {
|
func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error) {
|
||||||
if err := r.ensureService(); err != nil {
|
if err := r.ensureService(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := ensureRunnerContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateSpecs(specs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
// Build dependency graph
|
// Build dependency graph
|
||||||
specMap := make(map[string]RunSpec)
|
specMap := make(map[string]RunSpec)
|
||||||
indexMap := make(map[string]int)
|
indexMap := make(map[string]int, len(specs))
|
||||||
for i, spec := range specs {
|
for _, spec := range specs {
|
||||||
specMap[spec.Name] = spec
|
specMap[spec.Name] = spec
|
||||||
indexMap[spec.Name] = i
|
indexMap[spec.Name] = len(indexMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track completion
|
// Track completion
|
||||||
|
|
@ -97,6 +134,13 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
||||||
}
|
}
|
||||||
|
|
||||||
for len(remaining) > 0 {
|
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)
|
// Find specs ready to run (all dependencies satisfied)
|
||||||
ready := make([]RunSpec, 0)
|
ready := make([]RunSpec, 0)
|
||||||
for _, spec := range remaining {
|
for _, spec := range remaining {
|
||||||
|
|
@ -106,13 +150,14 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(ready) == 0 && len(remaining) > 0 {
|
if len(ready) == 0 && len(remaining) > 0 {
|
||||||
// Deadlock — circular dependency or missing specs. Mark as failed, not skipped.
|
// Deadlock - circular dependency or missing specs.
|
||||||
for name, spec := range remaining {
|
// Keep the output aligned with the input order.
|
||||||
|
for name := range remaining {
|
||||||
results[indexMap[name]] = RunResult{
|
results[indexMap[name]] = RunResult{
|
||||||
Name: name,
|
Name: name,
|
||||||
Spec: spec,
|
Spec: remaining[name],
|
||||||
ExitCode: 1,
|
Skipped: true,
|
||||||
Error: core.E("runner.run_all", "circular dependency or missing dependency", nil),
|
Error: coreerr.E("Runner.RunAll", "circular dependency or missing dependency", nil),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
|
|
@ -144,7 +189,7 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
||||||
Name: spec.Name,
|
Name: spec.Name,
|
||||||
Spec: spec,
|
Spec: spec,
|
||||||
Skipped: true,
|
Skipped: true,
|
||||||
Error: core.E("runner.run_all", "skipped due to dependency failure", nil),
|
Error: coreerr.E("Runner.RunAll", "skipped due to dependency failure", nil),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = r.runSpec(ctx, spec)
|
result = r.runSpec(ctx, spec)
|
||||||
|
|
@ -184,6 +229,13 @@ func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, er
|
||||||
return aggResult, nil
|
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.
|
// canRun checks if all dependencies are completed.
|
||||||
func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool {
|
func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool {
|
||||||
for _, dep := range spec.After {
|
for _, dep := range spec.After {
|
||||||
|
|
@ -198,17 +250,13 @@ func (r *Runner) canRun(spec RunSpec, completed map[string]*RunResult) bool {
|
||||||
func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
|
func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
sr := r.service.StartWithOptions(ctx, RunOptions{
|
proc, err := r.service.StartWithOptions(ctx, RunOptions{
|
||||||
Command: spec.Command,
|
Command: spec.Command,
|
||||||
Args: spec.Args,
|
Args: spec.Args,
|
||||||
Dir: spec.Dir,
|
Dir: spec.Dir,
|
||||||
Env: spec.Env,
|
Env: spec.Env,
|
||||||
})
|
})
|
||||||
if !sr.OK {
|
if err != nil {
|
||||||
err, _ := sr.Value.(error)
|
|
||||||
if err == nil {
|
|
||||||
err = core.E("runner.run_spec", core.Concat("failed to start: ", spec.Name), nil)
|
|
||||||
}
|
|
||||||
return RunResult{
|
return RunResult{
|
||||||
Name: spec.Name,
|
Name: spec.Name,
|
||||||
Spec: spec,
|
Spec: spec,
|
||||||
|
|
@ -217,39 +265,60 @@ func (r *Runner) runSpec(ctx context.Context, spec RunSpec) RunResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
proc := sr.Value.(*Process)
|
|
||||||
<-proc.Done()
|
<-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{
|
return RunResult{
|
||||||
Name: spec.Name,
|
Name: spec.Name,
|
||||||
Spec: spec,
|
Spec: spec,
|
||||||
ExitCode: proc.ExitCode,
|
ExitCode: proc.ExitCode,
|
||||||
Duration: proc.Duration,
|
Duration: proc.Duration,
|
||||||
Output: proc.Output(),
|
Output: proc.Output(),
|
||||||
Error: nil,
|
Error: runErr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunSequential executes specs one after another, stopping on first failure.
|
// 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) {
|
func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error) {
|
||||||
if err := r.ensureService(); err != nil {
|
if err := r.ensureService(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := ensureRunnerContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateSpecs(specs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
results := make([]RunResult, 0, len(specs))
|
results := make([]RunResult, 0, len(specs))
|
||||||
|
|
||||||
for _, spec := range 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)
|
result := r.runSpec(ctx, spec)
|
||||||
results = append(results, result)
|
results = append(results, result)
|
||||||
|
|
||||||
if !result.Passed() && !spec.AllowFailure {
|
if !result.Passed() && !spec.AllowFailure {
|
||||||
// Mark remaining as skipped
|
// Mark remaining as skipped
|
||||||
for i := len(results); i < len(specs); i++ {
|
for i := len(results); i < len(specs); i++ {
|
||||||
results = append(results, RunResult{
|
results = append(results, skippedRunResult("Runner.RunSequential", specs[i], nil))
|
||||||
Name: specs[i].Name,
|
|
||||||
Spec: specs[i],
|
|
||||||
Skipped: true,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -274,10 +343,20 @@ func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllRes
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunParallel executes all specs concurrently, regardless of dependencies.
|
// 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) {
|
func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error) {
|
||||||
if err := r.ensureService(); err != nil {
|
if err := r.ensureService(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := ensureRunnerContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateSpecs(specs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
results := make([]RunResult, len(specs))
|
results := make([]RunResult, len(specs))
|
||||||
|
|
||||||
|
|
@ -286,6 +365,10 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(i int, spec RunSpec) {
|
go func(i int, spec RunSpec) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
results[i] = cancelledRunResult("Runner.RunParallel", spec, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
results[i] = r.runSpec(ctx, spec)
|
results[i] = r.runSpec(ctx, spec)
|
||||||
}(i, spec)
|
}(i, spec)
|
||||||
}
|
}
|
||||||
|
|
@ -309,9 +392,59 @@ func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResul
|
||||||
return aggResult, nil
|
return aggResult, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) ensureService() error {
|
func validateSpecs(specs []RunSpec) error {
|
||||||
if r == nil || r.service == nil {
|
seen := make(map[string]struct{}, len(specs))
|
||||||
return ErrRunnerNoService
|
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
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
176
runner_test.go
176
runner_test.go
|
|
@ -13,12 +13,14 @@ func newTestRunner(t *testing.T) *Runner {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
c := framework.New()
|
c := framework.New()
|
||||||
r := Register(c)
|
factory := NewService(Options{})
|
||||||
require.True(t, r.OK)
|
raw, err := factory(c)
|
||||||
return NewRunner(r.Value.(*Service))
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return NewRunner(raw.(*Service))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_RunSequential_Good(t *testing.T) {
|
func TestRunner_RunSequential(t *testing.T) {
|
||||||
t.Run("all pass", func(t *testing.T) {
|
t.Run("all pass", func(t *testing.T) {
|
||||||
runner := newTestRunner(t)
|
runner := newTestRunner(t)
|
||||||
|
|
||||||
|
|
@ -49,6 +51,12 @@ func TestRunner_RunSequential_Good(t *testing.T) {
|
||||||
assert.Equal(t, 1, result.Passed)
|
assert.Equal(t, 1, result.Passed)
|
||||||
assert.Equal(t, 1, result.Failed)
|
assert.Equal(t, 1, result.Failed)
|
||||||
assert.Equal(t, 1, result.Skipped)
|
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) {
|
t.Run("allow failure continues", func(t *testing.T) {
|
||||||
|
|
@ -68,7 +76,7 @@ func TestRunner_RunSequential_Good(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_RunParallel_Good(t *testing.T) {
|
func TestRunner_RunParallel(t *testing.T) {
|
||||||
t.Run("all run concurrently", func(t *testing.T) {
|
t.Run("all run concurrently", func(t *testing.T) {
|
||||||
runner := newTestRunner(t)
|
runner := newTestRunner(t)
|
||||||
|
|
||||||
|
|
@ -100,7 +108,7 @@ func TestRunner_RunParallel_Good(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_RunAll_Good(t *testing.T) {
|
func TestRunner_RunAll(t *testing.T) {
|
||||||
t.Run("respects dependencies", func(t *testing.T) {
|
t.Run("respects dependencies", func(t *testing.T) {
|
||||||
runner := newTestRunner(t)
|
runner := newTestRunner(t)
|
||||||
|
|
||||||
|
|
@ -166,8 +174,8 @@ func TestRunner_RunAll_Good(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_CircularDeps_Bad(t *testing.T) {
|
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)
|
runner := newTestRunner(t)
|
||||||
|
|
||||||
result, err := runner.RunAll(context.Background(), []RunSpec{
|
result, err := runner.RunAll(context.Background(), []RunSpec{
|
||||||
|
|
@ -176,13 +184,85 @@ func TestRunner_CircularDeps_Bad(t *testing.T) {
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.False(t, result.Success())
|
assert.True(t, result.Success())
|
||||||
assert.Equal(t, 2, result.Failed)
|
assert.Equal(t, 0, result.Failed)
|
||||||
assert.Equal(t, 0, result.Skipped)
|
assert.Equal(t, 2, result.Skipped)
|
||||||
|
for _, res := range result.Results {
|
||||||
|
assert.True(t, res.Skipped)
|
||||||
|
assert.Equal(t, 0, res.ExitCode)
|
||||||
|
assert.Error(t, res.Error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing dependency is skipped with error", func(t *testing.T) {
|
||||||
|
runner := newTestRunner(t)
|
||||||
|
|
||||||
|
result, err := runner.RunAll(context.Background(), []RunSpec{
|
||||||
|
{Name: "a", Command: "echo", Args: []string{"a"}, After: []string{"missing"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, result.Success())
|
||||||
|
assert.Equal(t, 0, result.Failed)
|
||||||
|
assert.Equal(t, 1, result.Skipped)
|
||||||
|
require.Len(t, result.Results, 1)
|
||||||
|
assert.True(t, result.Results[0].Skipped)
|
||||||
|
assert.Equal(t, 0, result.Results[0].ExitCode)
|
||||||
|
assert.Error(t, result.Results[0].Error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunResult_Passed_Good(t *testing.T) {
|
func TestRunner_ContextCancellation(t *testing.T) {
|
||||||
|
t.Run("run sequential skips pending specs", func(t *testing.T) {
|
||||||
|
runner := newTestRunner(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
result, err := runner.RunSequential(ctx, []RunSpec{
|
||||||
|
{Name: "first", Command: "echo", Args: []string{"1"}},
|
||||||
|
{Name: "second", Command: "echo", Args: []string{"2"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, result.Passed)
|
||||||
|
assert.Equal(t, 0, result.Failed)
|
||||||
|
assert.Equal(t, 2, result.Skipped)
|
||||||
|
require.Len(t, result.Results, 2)
|
||||||
|
for _, res := range result.Results {
|
||||||
|
assert.True(t, res.Skipped)
|
||||||
|
assert.Equal(t, 1, res.ExitCode)
|
||||||
|
assert.Error(t, res.Error)
|
||||||
|
assert.Contains(t, res.Error.Error(), "context canceled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("run all skips pending specs", func(t *testing.T) {
|
||||||
|
runner := newTestRunner(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
result, err := runner.RunAll(ctx, []RunSpec{
|
||||||
|
{Name: "first", Command: "echo", Args: []string{"1"}},
|
||||||
|
{Name: "second", Command: "echo", Args: []string{"2"}, After: []string{"first"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 0, result.Passed)
|
||||||
|
assert.Equal(t, 0, result.Failed)
|
||||||
|
assert.Equal(t, 2, result.Skipped)
|
||||||
|
require.Len(t, result.Results, 2)
|
||||||
|
for _, res := range result.Results {
|
||||||
|
assert.True(t, res.Skipped)
|
||||||
|
assert.Equal(t, 1, res.ExitCode)
|
||||||
|
assert.Error(t, res.Error)
|
||||||
|
assert.Contains(t, res.Error.Error(), "context canceled")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunResult_Passed(t *testing.T) {
|
||||||
t.Run("success", func(t *testing.T) {
|
t.Run("success", func(t *testing.T) {
|
||||||
r := RunResult{ExitCode: 0}
|
r := RunResult{ExitCode: 0}
|
||||||
assert.True(t, r.Passed())
|
assert.True(t, r.Passed())
|
||||||
|
|
@ -204,7 +284,7 @@ func TestRunResult_Passed_Good(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunner_NilService_Bad(t *testing.T) {
|
func TestRunner_NilService(t *testing.T) {
|
||||||
runner := NewRunner(nil)
|
runner := NewRunner(nil)
|
||||||
|
|
||||||
_, err := runner.RunAll(context.Background(), nil)
|
_, err := runner.RunAll(context.Background(), nil)
|
||||||
|
|
@ -219,3 +299,73 @@ func TestRunner_NilService_Bad(t *testing.T) {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, ErrRunnerNoService)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
838
service.go
838
service.go
File diff suppressed because it is too large
Load diff
1184
service_test.go
1184
service_test.go
File diff suppressed because it is too large
Load diff
|
|
@ -1,29 +0,0 @@
|
||||||
# api
|
|
||||||
**Import:** `dappco.re/go/core/process/pkg/api`
|
|
||||||
**Files:** 2
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
### `ProcessProvider`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Service provider that wraps the go-process daemon registry and bundled UI entrypoint.
|
|
||||||
|
|
||||||
Exported fields:
|
|
||||||
- None.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
### Package Functions
|
|
||||||
|
|
||||||
- `func NewProvider(registry *process.Registry, hub *ws.Hub) *ProcessProvider`: Returns a `ProcessProvider` for the supplied registry and WebSocket hub. When `registry` is `nil`, it uses `process.DefaultRegistry()`.
|
|
||||||
- `func PIDAlive(pid int) bool`: Returns `false` for non-positive PIDs and otherwise reports whether `os.FindProcess(pid)` followed by signal `0` succeeds.
|
|
||||||
|
|
||||||
### `ProcessProvider` Methods
|
|
||||||
|
|
||||||
- `func (p *ProcessProvider) Name() string`: Returns `"process"`.
|
|
||||||
- `func (p *ProcessProvider) BasePath() string`: Returns `"/api/process"`.
|
|
||||||
- `func (p *ProcessProvider) Element() provider.ElementSpec`: Returns an element spec with tag `core-process-panel` and source `/assets/core-process.js`.
|
|
||||||
- `func (p *ProcessProvider) Channels() []string`: Returns `process.daemon.started`, `process.daemon.stopped`, `process.daemon.health`, `process.started`, `process.output`, `process.exited`, and `process.killed`.
|
|
||||||
- `func (p *ProcessProvider) RegisterRoutes(rg *gin.RouterGroup)`: Registers the daemon list, daemon lookup, daemon stop, and daemon health routes.
|
|
||||||
- `func (p *ProcessProvider) Describe() []api.RouteDescription`: Returns static route descriptions for the registered daemon routes.
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
# exec
|
|
||||||
**Import:** `dappco.re/go/core/process/exec`
|
|
||||||
**Files:** 3
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
### `Options`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Command execution options used by `Cmd`.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Dir string`: Working directory.
|
|
||||||
- `Env []string`: Environment entries appended to `os.Environ()` when non-empty.
|
|
||||||
- `Stdin io.Reader`: Reader assigned to command stdin.
|
|
||||||
- `Stdout io.Writer`: Writer assigned to command stdout.
|
|
||||||
- `Stderr io.Writer`: Writer assigned to command stderr.
|
|
||||||
|
|
||||||
### `Cmd`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Wrapped command with chainable configuration methods.
|
|
||||||
|
|
||||||
Exported fields:
|
|
||||||
- None.
|
|
||||||
|
|
||||||
### `Logger`
|
|
||||||
`interface`
|
|
||||||
|
|
||||||
Command-execution logger.
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
- `Debug(msg string, keyvals ...any)`: Logs a debug-level message.
|
|
||||||
- `Error(msg string, keyvals ...any)`: Logs an error-level message.
|
|
||||||
|
|
||||||
### `NopLogger`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
No-op `Logger` implementation.
|
|
||||||
|
|
||||||
Exported fields:
|
|
||||||
- None.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
### Package Functions
|
|
||||||
|
|
||||||
- `func Command(ctx context.Context, name string, args ...string) *Cmd`: Returns a `Cmd` for the supplied context, executable name, and arguments.
|
|
||||||
- `func RunQuiet(ctx context.Context, name string, args ...string) error`: Runs a command with stderr captured into a buffer and returns `core.E("exec.run_quiet", core.Trim(stderr.String()), err)` on failure.
|
|
||||||
- `func SetDefaultLogger(l Logger)`: Sets the package-level default logger. Passing `nil` replaces it with `NopLogger`.
|
|
||||||
- `func DefaultLogger() Logger`: Returns the package-level default logger.
|
|
||||||
|
|
||||||
### `Cmd` Methods
|
|
||||||
|
|
||||||
- `func (c *Cmd) WithDir(dir string) *Cmd`: Sets `Options.Dir` and returns the same command.
|
|
||||||
- `func (c *Cmd) WithEnv(env []string) *Cmd`: Sets `Options.Env` and returns the same command.
|
|
||||||
- `func (c *Cmd) WithStdin(r io.Reader) *Cmd`: Sets `Options.Stdin` and returns the same command.
|
|
||||||
- `func (c *Cmd) WithStdout(w io.Writer) *Cmd`: Sets `Options.Stdout` and returns the same command.
|
|
||||||
- `func (c *Cmd) WithStderr(w io.Writer) *Cmd`: Sets `Options.Stderr` and returns the same command.
|
|
||||||
- `func (c *Cmd) WithLogger(l Logger) *Cmd`: Sets a command-specific logger and returns the same command.
|
|
||||||
- `func (c *Cmd) Run() error`: Prepares the underlying `exec.Cmd`, logs `"executing command"`, runs it, and wraps failures with `wrapError("exec.cmd.run", ...)`.
|
|
||||||
- `func (c *Cmd) Output() ([]byte, error)`: Prepares the underlying `exec.Cmd`, logs `"executing command"`, returns stdout bytes, and wraps failures with `wrapError("exec.cmd.output", ...)`.
|
|
||||||
- `func (c *Cmd) CombinedOutput() ([]byte, error)`: Prepares the underlying `exec.Cmd`, logs `"executing command"`, returns combined stdout and stderr, and wraps failures with `wrapError("exec.cmd.combined_output", ...)`.
|
|
||||||
|
|
||||||
### `NopLogger` Methods
|
|
||||||
|
|
||||||
- `func (NopLogger) Debug(string, ...any)`: Discards the message.
|
|
||||||
- `func (NopLogger) Error(string, ...any)`: Discards the message.
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
# @core/process-ui
|
|
||||||
**Import:** `@core/process-ui`
|
|
||||||
**Files:** 8
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
### `DaemonEntry`
|
|
||||||
`interface`
|
|
||||||
|
|
||||||
Daemon-registry row returned by `ProcessApi.listDaemons` and `ProcessApi.getDaemon`.
|
|
||||||
|
|
||||||
Properties:
|
|
||||||
- `code: string`: Application or component code.
|
|
||||||
- `daemon: string`: Daemon name.
|
|
||||||
- `pid: number`: Process ID.
|
|
||||||
- `health?: string`: Optional health-endpoint address.
|
|
||||||
- `project?: string`: Optional project label.
|
|
||||||
- `binary?: string`: Optional binary label.
|
|
||||||
- `started: string`: Start timestamp string from the API.
|
|
||||||
|
|
||||||
### `HealthResult`
|
|
||||||
`interface`
|
|
||||||
|
|
||||||
Result returned by the daemon health endpoint.
|
|
||||||
|
|
||||||
Properties:
|
|
||||||
- `healthy: boolean`: Health outcome.
|
|
||||||
- `address: string`: Health endpoint address that was checked.
|
|
||||||
- `reason?: string`: Optional explanation such as the absence of a health endpoint.
|
|
||||||
|
|
||||||
### `ProcessInfo`
|
|
||||||
`interface`
|
|
||||||
|
|
||||||
Process snapshot shape used by the UI package.
|
|
||||||
|
|
||||||
Properties:
|
|
||||||
- `id: string`: Managed-process identifier.
|
|
||||||
- `command: string`: Executable name.
|
|
||||||
- `args: string[]`: Command arguments.
|
|
||||||
- `dir: string`: Working directory.
|
|
||||||
- `startedAt: string`: Start timestamp string.
|
|
||||||
- `status: 'pending' | 'running' | 'exited' | 'failed' | 'killed'`: Process status string.
|
|
||||||
- `exitCode: number`: Exit code.
|
|
||||||
- `duration: number`: Numeric duration value from the API payload.
|
|
||||||
- `pid: number`: Child PID.
|
|
||||||
|
|
||||||
### `RunResult`
|
|
||||||
`interface`
|
|
||||||
|
|
||||||
Pipeline result row used by `ProcessRunner`.
|
|
||||||
|
|
||||||
Properties:
|
|
||||||
- `name: string`: Spec name.
|
|
||||||
- `exitCode: number`: Exit code.
|
|
||||||
- `duration: number`: Numeric duration value.
|
|
||||||
- `output: string`: Captured output.
|
|
||||||
- `error?: string`: Optional error message.
|
|
||||||
- `skipped: boolean`: Whether the spec was skipped.
|
|
||||||
- `passed: boolean`: Whether the spec passed.
|
|
||||||
|
|
||||||
### `RunAllResult`
|
|
||||||
`interface`
|
|
||||||
|
|
||||||
Aggregate pipeline result consumed by `ProcessRunner`.
|
|
||||||
|
|
||||||
Properties:
|
|
||||||
- `results: RunResult[]`: Per-spec results.
|
|
||||||
- `duration: number`: Aggregate duration.
|
|
||||||
- `passed: number`: Count of passed specs.
|
|
||||||
- `failed: number`: Count of failed specs.
|
|
||||||
- `skipped: number`: Count of skipped specs.
|
|
||||||
- `success: boolean`: Aggregate success flag.
|
|
||||||
|
|
||||||
### `ProcessApi`
|
|
||||||
`class`
|
|
||||||
|
|
||||||
Typed fetch client for `/api/process/*`.
|
|
||||||
|
|
||||||
Public API:
|
|
||||||
- `new ProcessApi(baseUrl?: string)`: Stores an optional URL prefix. The default is `""`.
|
|
||||||
- `listDaemons(): Promise<DaemonEntry[]>`: Fetches `GET /api/process/daemons`.
|
|
||||||
- `getDaemon(code: string, daemon: string): Promise<DaemonEntry>`: Fetches one daemon entry.
|
|
||||||
- `stopDaemon(code: string, daemon: string): Promise<{ stopped: boolean }>`: Sends `POST /api/process/daemons/:code/:daemon/stop`.
|
|
||||||
- `healthCheck(code: string, daemon: string): Promise<HealthResult>`: Fetches `GET /api/process/daemons/:code/:daemon/health`.
|
|
||||||
|
|
||||||
### `ProcessEvent`
|
|
||||||
`interface`
|
|
||||||
|
|
||||||
Event envelope consumed by `connectProcessEvents`.
|
|
||||||
|
|
||||||
Properties:
|
|
||||||
- `type: string`: Event type.
|
|
||||||
- `channel?: string`: Optional channel name.
|
|
||||||
- `data?: any`: Event payload.
|
|
||||||
- `timestamp?: string`: Optional timestamp string.
|
|
||||||
|
|
||||||
### `ProcessPanel`
|
|
||||||
`class`
|
|
||||||
|
|
||||||
Top-level custom element registered as `<core-process-panel>`.
|
|
||||||
|
|
||||||
Public properties:
|
|
||||||
- `apiUrl: string`: Forwarded to child elements through the `api-url` attribute.
|
|
||||||
- `wsUrl: string`: WebSocket endpoint URL from the `ws-url` attribute.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Renders tabbed daemon, process, and pipeline views.
|
|
||||||
- Opens a process-event WebSocket when `wsUrl` is set.
|
|
||||||
- Shows the last received process channel or event type in the footer.
|
|
||||||
|
|
||||||
### `ProcessDaemons`
|
|
||||||
`class`
|
|
||||||
|
|
||||||
Daemon-list custom element registered as `<core-process-daemons>`.
|
|
||||||
|
|
||||||
Public properties:
|
|
||||||
- `apiUrl: string`: Base URL prefix for `ProcessApi`.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Loads daemon entries on connect.
|
|
||||||
- Can trigger per-daemon health checks and stop requests.
|
|
||||||
- Emits `daemon-stopped` after a successful stop request.
|
|
||||||
|
|
||||||
### `ProcessList`
|
|
||||||
`class`
|
|
||||||
|
|
||||||
Managed-process list custom element registered as `<core-process-list>`.
|
|
||||||
|
|
||||||
Public properties:
|
|
||||||
- `apiUrl: string`: Declared API prefix property.
|
|
||||||
- `selectedId: string`: Selected process ID, reflected from `selected-id`.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Emits `process-selected` when a row is chosen.
|
|
||||||
- Currently renders from local state only because the process REST endpoints referenced by the component are not implemented in this package.
|
|
||||||
|
|
||||||
### `ProcessOutput`
|
|
||||||
`class`
|
|
||||||
|
|
||||||
Live output custom element registered as `<core-process-output>`.
|
|
||||||
|
|
||||||
Public properties:
|
|
||||||
- `apiUrl: string`: Declared API prefix property. The current implementation does not use it.
|
|
||||||
- `wsUrl: string`: WebSocket endpoint URL.
|
|
||||||
- `processId: string`: Selected process ID from the `process-id` attribute.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Connects to the WebSocket when both `wsUrl` and `processId` are present.
|
|
||||||
- Filters for `process.output` events whose payload `data.id` matches `processId`.
|
|
||||||
- Appends output lines and auto-scrolls by default.
|
|
||||||
|
|
||||||
### `ProcessRunner`
|
|
||||||
`class`
|
|
||||||
|
|
||||||
Pipeline-results custom element registered as `<core-process-runner>`.
|
|
||||||
|
|
||||||
Public properties:
|
|
||||||
- `apiUrl: string`: Declared API prefix property.
|
|
||||||
- `result: RunAllResult | null`: Aggregate pipeline result used for rendering.
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
- Renders summary counts plus expandable per-spec output.
|
|
||||||
- Depends on the `result` property today because pipeline REST endpoints are not implemented in the package.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
### Package Functions
|
|
||||||
|
|
||||||
- `function connectProcessEvents(wsUrl: string, handler: (event: ProcessEvent) => void): WebSocket`: Opens a WebSocket, parses incoming JSON, forwards only messages whose `type` or `channel` starts with `process.`, ignores malformed payloads, and returns the `WebSocket` instance.
|
|
||||||
|
|
||||||
### `ProcessPanel` Methods
|
|
||||||
|
|
||||||
- `connectedCallback(): void`: Calls the LitElement base implementation and opens the WebSocket when `wsUrl` is set.
|
|
||||||
- `disconnectedCallback(): void`: Calls the LitElement base implementation and closes the current WebSocket.
|
|
||||||
- `render(): unknown`: Renders the header, tab strip, active child element, and connection footer.
|
|
||||||
|
|
||||||
### `ProcessDaemons` Methods
|
|
||||||
|
|
||||||
- `connectedCallback(): void`: Instantiates `ProcessApi` and loads daemon data.
|
|
||||||
- `loadDaemons(): Promise<void>`: Fetches daemon entries, stores them in component state, and records any request error message.
|
|
||||||
- `render(): unknown`: Renders the daemon list, loading state, empty state, and action buttons.
|
|
||||||
|
|
||||||
### `ProcessList` Methods
|
|
||||||
|
|
||||||
- `connectedCallback(): void`: Calls the LitElement base implementation and invokes `loadProcesses`.
|
|
||||||
- `loadProcesses(): Promise<void>`: Current placeholder implementation that clears state because the referenced process REST endpoints are not implemented yet.
|
|
||||||
- `render(): unknown`: Renders the process list or an informational empty state explaining the missing REST support.
|
|
||||||
|
|
||||||
### `ProcessOutput` Methods
|
|
||||||
|
|
||||||
- `connectedCallback(): void`: Calls the LitElement base implementation and opens the WebSocket when `wsUrl` and `processId` are both set.
|
|
||||||
- `disconnectedCallback(): void`: Calls the LitElement base implementation and closes the current WebSocket.
|
|
||||||
- `updated(changed: Map<string, unknown>): void`: Reconnects when `processId` or `wsUrl` changes, resets buffered lines on reconnection, and auto-scrolls when enabled.
|
|
||||||
- `render(): unknown`: Renders the output panel, waiting state, and accumulated stdout or stderr lines.
|
|
||||||
|
|
||||||
### `ProcessRunner` Methods
|
|
||||||
|
|
||||||
- `connectedCallback(): void`: Calls the LitElement base implementation and invokes `loadResults`.
|
|
||||||
- `loadResults(): Promise<void>`: Current placeholder method. The implementation is empty because pipeline endpoints are not present.
|
|
||||||
- `render(): unknown`: Renders the empty-state notice when `result` is absent, or the aggregate summary plus per-spec details when `result` is present.
|
|
||||||
|
|
||||||
### `ProcessApi` Methods
|
|
||||||
|
|
||||||
- `listDaemons(): Promise<DaemonEntry[]>`: Returns the `data` field from a successful daemon-list response.
|
|
||||||
- `getDaemon(code: string, daemon: string): Promise<DaemonEntry>`: Returns one daemon entry from the provider API.
|
|
||||||
- `stopDaemon(code: string, daemon: string): Promise<{ stopped: boolean }>`: Issues the stop request and returns the provider's `{ stopped }` payload.
|
|
||||||
- `healthCheck(code: string, daemon: string): Promise<HealthResult>`: Returns the daemon-health payload.
|
|
||||||
372
specs/process.md
372
specs/process.md
|
|
@ -1,372 +0,0 @@
|
||||||
# process
|
|
||||||
**Import:** `dappco.re/go/core/process`
|
|
||||||
**Files:** 11
|
|
||||||
|
|
||||||
## Types
|
|
||||||
|
|
||||||
### `ActionProcessStarted`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Broadcast payload for a managed process that has successfully started.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `ID string`: Generated managed-process identifier.
|
|
||||||
- `Command string`: Executable name passed to the service.
|
|
||||||
- `Args []string`: Argument vector used to start the process.
|
|
||||||
- `Dir string`: Working directory supplied at start time.
|
|
||||||
- `PID int`: OS process ID of the child process.
|
|
||||||
|
|
||||||
### `ActionProcessOutput`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Broadcast payload for one scanned line of process output.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `ID string`: Managed-process identifier.
|
|
||||||
- `Line string`: One line from stdout or stderr, without the trailing newline.
|
|
||||||
- `Stream Stream`: Output source, using `StreamStdout` or `StreamStderr`.
|
|
||||||
|
|
||||||
### `ActionProcessExited`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Broadcast payload emitted after the service wait goroutine finishes.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `ID string`: Managed-process identifier.
|
|
||||||
- `ExitCode int`: Process exit code.
|
|
||||||
- `Duration time.Duration`: Time elapsed since `StartedAt`.
|
|
||||||
- `Error error`: Declared error slot for exit metadata. The current `Service` emitter does not populate it.
|
|
||||||
|
|
||||||
### `ActionProcessKilled`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Broadcast payload emitted by `Service.Kill`.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `ID string`: Managed-process identifier.
|
|
||||||
- `Signal string`: Signal name reported by the service. The current implementation emits `"SIGKILL"`.
|
|
||||||
|
|
||||||
### `RingBuffer`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Fixed-size circular byte buffer used for captured process output. The implementation is mutex-protected and overwrites the oldest bytes when full.
|
|
||||||
|
|
||||||
Exported fields:
|
|
||||||
- None.
|
|
||||||
|
|
||||||
### `DaemonOptions`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Configuration for `NewDaemon`.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `PIDFile string`: PID file path. Empty disables PID-file management.
|
|
||||||
- `ShutdownTimeout time.Duration`: Grace period used by `Stop`. Zero is normalized to 30 seconds by `NewDaemon`.
|
|
||||||
- `HealthAddr string`: Listen address for the health server. Empty disables health endpoints.
|
|
||||||
- `HealthChecks []HealthCheck`: Additional `/health` checks to register on the health server.
|
|
||||||
- `Registry *Registry`: Optional daemon registry used for automatic register/unregister.
|
|
||||||
- `RegistryEntry DaemonEntry`: Base registry payload. `Start` fills in `PID`, `Health`, and `Started` behavior through `Registry.Register`.
|
|
||||||
|
|
||||||
### `Daemon`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Lifecycle wrapper around a PID file, optional health server, and optional registry entry.
|
|
||||||
|
|
||||||
Exported fields:
|
|
||||||
- None.
|
|
||||||
|
|
||||||
### `HealthCheck`
|
|
||||||
`type HealthCheck func() error`
|
|
||||||
|
|
||||||
Named function type used by `HealthServer` and `DaemonOptions`. Returning `nil` marks the check healthy; returning an error makes `/health` respond with `503`.
|
|
||||||
|
|
||||||
### `HealthServer`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
HTTP server exposing `/health` and `/ready` endpoints.
|
|
||||||
|
|
||||||
Exported fields:
|
|
||||||
- None.
|
|
||||||
|
|
||||||
### `PIDFile`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Single-instance guard backed by a PID file on disk.
|
|
||||||
|
|
||||||
Exported fields:
|
|
||||||
- None.
|
|
||||||
|
|
||||||
### `ManagedProcess`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Service-owned process record for a started child process.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `ID string`: Managed-process identifier generated by `core.ID()`.
|
|
||||||
- `Command string`: Executable name.
|
|
||||||
- `Args []string`: Command arguments.
|
|
||||||
- `Dir string`: Working directory used when starting the process.
|
|
||||||
- `Env []string`: Extra environment entries appended to the command environment.
|
|
||||||
- `StartedAt time.Time`: Timestamp recorded immediately before `cmd.Start`.
|
|
||||||
- `Status Status`: Current lifecycle state tracked by the service.
|
|
||||||
- `ExitCode int`: Exit status after completion.
|
|
||||||
- `Duration time.Duration`: Runtime duration set when the wait goroutine finishes.
|
|
||||||
|
|
||||||
### `Process`
|
|
||||||
`type alias of ManagedProcess`
|
|
||||||
|
|
||||||
Compatibility alias that exposes the same fields and methods as `ManagedProcess`.
|
|
||||||
|
|
||||||
### `Program`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Thin helper for finding and running a named executable.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Name string`: Binary name to look up or execute.
|
|
||||||
- `Path string`: Resolved absolute path populated by `Find`. When empty, `Run` and `RunDir` fall back to `Name`.
|
|
||||||
|
|
||||||
### `DaemonEntry`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Serialized daemon-registry record written as JSON.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Code string`: Application or component code.
|
|
||||||
- `Daemon string`: Daemon name within that code.
|
|
||||||
- `PID int`: Running process ID.
|
|
||||||
- `Health string`: Health endpoint address, if any.
|
|
||||||
- `Project string`: Optional project label.
|
|
||||||
- `Binary string`: Optional binary label.
|
|
||||||
- `Started time.Time`: Start timestamp persisted in RFC3339Nano format.
|
|
||||||
|
|
||||||
### `Registry`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Filesystem-backed daemon registry that stores one JSON file per daemon entry.
|
|
||||||
|
|
||||||
Exported fields:
|
|
||||||
- None.
|
|
||||||
|
|
||||||
### `Runner`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Pipeline orchestrator that starts `RunSpec` processes through a `Service`.
|
|
||||||
|
|
||||||
Exported fields:
|
|
||||||
- None.
|
|
||||||
|
|
||||||
### `RunSpec`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
One process specification for `Runner`.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Name string`: Friendly name used for dependencies and result reporting.
|
|
||||||
- `Command string`: Executable name.
|
|
||||||
- `Args []string`: Command arguments.
|
|
||||||
- `Dir string`: Working directory.
|
|
||||||
- `Env []string`: Additional environment variables.
|
|
||||||
- `After []string`: Dependency names that must complete before this spec can run in `RunAll`.
|
|
||||||
- `AllowFailure bool`: When true, downstream work is not skipped because of this spec's failure.
|
|
||||||
|
|
||||||
### `RunResult`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Per-spec runner result.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Name string`: Spec name.
|
|
||||||
- `Spec RunSpec`: Original spec payload.
|
|
||||||
- `ExitCode int`: Exit code from the managed process.
|
|
||||||
- `Duration time.Duration`: Process duration or start-attempt duration.
|
|
||||||
- `Output string`: Captured output returned from the managed process.
|
|
||||||
- `Error error`: Start or orchestration error. For a started process that exits non-zero, this remains `nil`.
|
|
||||||
- `Skipped bool`: Whether the spec was skipped instead of run.
|
|
||||||
|
|
||||||
### `RunAllResult`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Aggregate result returned by `RunAll`, `RunSequential`, and `RunParallel`.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Results []RunResult`: Collected per-spec results.
|
|
||||||
- `Duration time.Duration`: End-to-end runtime for the orchestration method.
|
|
||||||
- `Passed int`: Count of results where `Passed()` is true.
|
|
||||||
- `Failed int`: Count of non-skipped results that did not pass.
|
|
||||||
- `Skipped int`: Count of skipped results.
|
|
||||||
|
|
||||||
### `Service`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Core service that owns managed processes and registers action handlers.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `*core.ServiceRuntime[Options]`: Embedded Core runtime used for lifecycle hooks and access to `Core()`.
|
|
||||||
|
|
||||||
### `Options`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Service configuration.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `BufferSize int`: Ring-buffer capacity for captured output. `Register` currently initializes this from `DefaultBufferSize`.
|
|
||||||
|
|
||||||
### `Status`
|
|
||||||
`type Status string`
|
|
||||||
|
|
||||||
Named lifecycle-state type for a managed process.
|
|
||||||
|
|
||||||
Exported values:
|
|
||||||
- `StatusPending`: queued but not started.
|
|
||||||
- `StatusRunning`: currently executing.
|
|
||||||
- `StatusExited`: completed and waited.
|
|
||||||
- `StatusFailed`: start or wait failure state.
|
|
||||||
- `StatusKilled`: terminated by signal.
|
|
||||||
|
|
||||||
### `Stream`
|
|
||||||
`type Stream string`
|
|
||||||
|
|
||||||
Named output-stream discriminator for process output events.
|
|
||||||
|
|
||||||
Exported values:
|
|
||||||
- `StreamStdout`: stdout line.
|
|
||||||
- `StreamStderr`: stderr line.
|
|
||||||
|
|
||||||
### `RunOptions`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Execution settings accepted by `Service.StartWithOptions` and `Service.RunWithOptions`.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `Command string`: Executable name. Required by both start and run paths.
|
|
||||||
- `Args []string`: Command arguments.
|
|
||||||
- `Dir string`: Working directory.
|
|
||||||
- `Env []string`: Additional environment entries appended to the command environment.
|
|
||||||
- `DisableCapture bool`: Disables the managed-process ring buffer when true.
|
|
||||||
- `Detach bool`: Starts the child in a separate process group and replaces the parent context with `context.Background()`.
|
|
||||||
- `Timeout time.Duration`: Optional watchdog timeout that calls `Shutdown` after the duration elapses.
|
|
||||||
- `GracePeriod time.Duration`: Delay between `SIGTERM` and fallback kill in `Shutdown`.
|
|
||||||
- `KillGroup bool`: Requests process-group termination. The current service only enables this when `Detach` is also true.
|
|
||||||
|
|
||||||
### `ProcessInfo`
|
|
||||||
`struct`
|
|
||||||
|
|
||||||
Serializable snapshot returned by `ManagedProcess.Info` and `Service` action lookups.
|
|
||||||
|
|
||||||
Fields:
|
|
||||||
- `ID string`: Managed-process identifier.
|
|
||||||
- `Command string`: Executable name.
|
|
||||||
- `Args []string`: Command arguments.
|
|
||||||
- `Dir string`: Working directory.
|
|
||||||
- `StartedAt time.Time`: Start timestamp.
|
|
||||||
- `Running bool`: Convenience boolean derived from `Status`.
|
|
||||||
- `Status Status`: Current lifecycle state.
|
|
||||||
- `ExitCode int`: Exit status.
|
|
||||||
- `Duration time.Duration`: Runtime duration.
|
|
||||||
- `PID int`: Child PID, or `0` if no process handle is available.
|
|
||||||
|
|
||||||
### `Info`
|
|
||||||
`type alias of ProcessInfo`
|
|
||||||
|
|
||||||
Compatibility alias that exposes the same fields as `ProcessInfo`.
|
|
||||||
|
|
||||||
## Functions
|
|
||||||
|
|
||||||
### Package Functions
|
|
||||||
|
|
||||||
- `func Register(c *core.Core) core.Result`: Builds a `Service` with a fresh `core.Registry[*ManagedProcess]`, sets the buffer size to `DefaultBufferSize`, and returns the service in `Result.Value`.
|
|
||||||
- `func NewRingBuffer(size int) *RingBuffer`: Allocates a fixed-capacity ring buffer of exactly `size` bytes.
|
|
||||||
- `func NewDaemon(opts DaemonOptions) *Daemon`: Normalizes `ShutdownTimeout`, creates optional `PIDFile` and `HealthServer` helpers, and attaches any configured health checks.
|
|
||||||
- `func NewHealthServer(addr string) *HealthServer`: Returns a health server with the supplied listen address and readiness initialized to `true`.
|
|
||||||
- `func WaitForHealth(addr string, timeoutMs int) bool`: Polls `http://<addr>/health` every 200 ms until it gets HTTP 200 or the timeout expires.
|
|
||||||
- `func NewPIDFile(path string) *PIDFile`: Returns a PID-file manager for `path`.
|
|
||||||
- `func ReadPID(path string) (int, bool)`: Reads and parses a PID file, then uses signal `0` to report whether that PID is still alive.
|
|
||||||
- `func NewRegistry(dir string) *Registry`: Returns a daemon registry rooted at `dir`.
|
|
||||||
- `func DefaultRegistry() *Registry`: Returns a registry at `~/.core/daemons`, falling back to the OS temp directory if the home directory cannot be resolved.
|
|
||||||
- `func NewRunner(svc *Service) *Runner`: Returns a runner bound to a specific `Service`.
|
|
||||||
|
|
||||||
### `RingBuffer` Methods
|
|
||||||
|
|
||||||
- `func (rb *RingBuffer) Write(p []byte) (n int, err error)`: Appends bytes one by one, advancing the circular window and overwriting the oldest bytes when capacity is exceeded.
|
|
||||||
- `func (rb *RingBuffer) String() string`: Returns the current buffer contents in logical order as a string.
|
|
||||||
- `func (rb *RingBuffer) Bytes() []byte`: Returns a copied byte slice of the current logical contents, or `nil` when the buffer is empty.
|
|
||||||
- `func (rb *RingBuffer) Len() int`: Returns the number of bytes currently retained.
|
|
||||||
- `func (rb *RingBuffer) Cap() int`: Returns the configured capacity.
|
|
||||||
- `func (rb *RingBuffer) Reset()`: Clears the buffer indexes and full flag.
|
|
||||||
|
|
||||||
### `Daemon` Methods
|
|
||||||
|
|
||||||
- `func (d *Daemon) Start() error`: Acquires the PID file, starts the health server, marks the daemon running, and auto-registers it when `Registry` is configured. If a later step fails, it rolls back earlier setup.
|
|
||||||
- `func (d *Daemon) Run(ctx context.Context) error`: Requires a started daemon, waits for `ctx.Done()`, and then calls `Stop`.
|
|
||||||
- `func (d *Daemon) Stop() error`: Sets readiness false, shuts down the health server, releases the PID file, unregisters the daemon, and joins health or PID teardown errors with `core.ErrorJoin`.
|
|
||||||
- `func (d *Daemon) SetReady(ready bool)`: Forwards readiness changes to the health server when one exists.
|
|
||||||
- `func (d *Daemon) HealthAddr() string`: Returns the bound health-server address or `""` when health endpoints are disabled.
|
|
||||||
|
|
||||||
### `HealthServer` Methods
|
|
||||||
|
|
||||||
- `func (h *HealthServer) AddCheck(check HealthCheck)`: Appends a health-check callback under lock.
|
|
||||||
- `func (h *HealthServer) SetReady(ready bool)`: Updates the readiness flag used by `/ready`.
|
|
||||||
- `func (h *HealthServer) Start() error`: Installs `/health` and `/ready` handlers, listens on `addr`, stores the listener and `http.Server`, and serves in a goroutine.
|
|
||||||
- `func (h *HealthServer) Stop(ctx context.Context) error`: Calls `Shutdown` on the underlying `http.Server` when started; otherwise returns `nil`.
|
|
||||||
- `func (h *HealthServer) Addr() string`: Returns the actual bound listener address after `Start`, or the configured address before startup.
|
|
||||||
|
|
||||||
### `PIDFile` Methods
|
|
||||||
|
|
||||||
- `func (p *PIDFile) Acquire() error`: Rejects a live existing PID file, deletes stale state, creates the parent directory when needed, and writes the current process ID.
|
|
||||||
- `func (p *PIDFile) Release() error`: Deletes the PID file.
|
|
||||||
- `func (p *PIDFile) Path() string`: Returns the configured PID-file path.
|
|
||||||
|
|
||||||
### `ManagedProcess` Methods
|
|
||||||
|
|
||||||
- `func (p *ManagedProcess) Info() ProcessInfo`: Returns a snapshot containing public fields plus the current child PID.
|
|
||||||
- `func (p *ManagedProcess) Output() string`: Returns captured output as a string, or `""` when capture is disabled.
|
|
||||||
- `func (p *ManagedProcess) OutputBytes() []byte`: Returns captured output as bytes, or `nil` when capture is disabled.
|
|
||||||
- `func (p *ManagedProcess) IsRunning() bool`: Reports running state by checking whether the `done` channel has closed.
|
|
||||||
- `func (p *ManagedProcess) Wait() error`: Blocks for completion and then returns a wrapped error for failed-start, killed, or non-zero-exit outcomes.
|
|
||||||
- `func (p *ManagedProcess) Done() <-chan struct{}`: Returns the completion channel.
|
|
||||||
- `func (p *ManagedProcess) Kill() error`: Sends `SIGKILL` to the child, or to the entire process group when group killing is enabled.
|
|
||||||
- `func (p *ManagedProcess) Shutdown() error`: Sends `SIGTERM`, waits for the configured grace period, and falls back to `Kill`. With no grace period configured, it immediately calls `Kill`.
|
|
||||||
- `func (p *ManagedProcess) SendInput(input string) error`: Writes to the child's stdin pipe while the process is running.
|
|
||||||
- `func (p *ManagedProcess) CloseStdin() error`: Closes the stdin pipe and clears the stored handle.
|
|
||||||
- `func (p *ManagedProcess) Signal(sig os.Signal) error`: Sends an arbitrary signal while the process is in `StatusRunning`.
|
|
||||||
|
|
||||||
### `Program` Methods
|
|
||||||
|
|
||||||
- `func (p *Program) Find() error`: Resolves `Name` through `exec.LookPath`, stores the absolute path in `Path`, and wraps `ErrProgramNotFound` when lookup fails.
|
|
||||||
- `func (p *Program) Run(ctx context.Context, args ...string) (string, error)`: Executes the program in the current working directory by delegating to `RunDir("", args...)`.
|
|
||||||
- `func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error)`: Runs the program with combined stdout/stderr capture, trims the combined output, and returns that output even when the command fails.
|
|
||||||
|
|
||||||
### `Registry` Methods
|
|
||||||
|
|
||||||
- `func (r *Registry) Register(entry DaemonEntry) error`: Ensures the registry directory exists, defaults `Started` when zero, marshals the entry with the package's JSON writer, and writes one `<code>-<daemon>.json` file.
|
|
||||||
- `func (r *Registry) Unregister(code, daemon string) error`: Deletes the registry file for the supplied daemon key.
|
|
||||||
- `func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool)`: Reads one entry, prunes invalid or stale files, and returns `(nil, false)` when the daemon is missing or dead.
|
|
||||||
- `func (r *Registry) List() ([]DaemonEntry, error)`: Lists all JSON files in the registry directory, prunes invalid or stale entries, and returns only live daemons. A missing registry directory returns `nil, nil`.
|
|
||||||
|
|
||||||
### `RunResult` and `RunAllResult` Methods
|
|
||||||
|
|
||||||
- `func (r RunResult) Passed() bool`: Returns true only when the result is not skipped, has no `Error`, and has `ExitCode == 0`.
|
|
||||||
- `func (r RunAllResult) Success() bool`: Returns true when `Failed == 0`, regardless of skipped count.
|
|
||||||
|
|
||||||
### `Runner` Methods
|
|
||||||
|
|
||||||
- `func (r *Runner) RunAll(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Executes dependency-aware waves of specs, skips dependents after failing required dependencies, and marks circular or missing dependency sets as failed results with `ExitCode` 1.
|
|
||||||
- `func (r *Runner) RunSequential(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Runs specs in order and marks remaining specs skipped after the first disallowed failure.
|
|
||||||
- `func (r *Runner) RunParallel(ctx context.Context, specs []RunSpec) (*RunAllResult, error)`: Runs all specs concurrently and aggregates counts after all goroutines finish.
|
|
||||||
|
|
||||||
### `Service` Methods
|
|
||||||
|
|
||||||
- `func (s *Service) OnStartup(ctx context.Context) core.Result`: Registers the Core actions `process.run`, `process.start`, `process.kill`, `process.list`, and `process.get`.
|
|
||||||
- `func (s *Service) OnShutdown(ctx context.Context) core.Result`: Iterates all managed processes and calls `Kill` on each one.
|
|
||||||
- `func (s *Service) Start(ctx context.Context, command string, args ...string) core.Result`: Convenience wrapper that builds `RunOptions` and delegates to `StartWithOptions`.
|
|
||||||
- `func (s *Service) StartWithOptions(ctx context.Context, opts RunOptions) core.Result`: Starts a managed process, configures pipes, optional capture, detach and timeout behavior, stores it in the registry, emits `ActionProcessStarted`, streams stdout/stderr lines, and emits `ActionProcessExited` after completion.
|
|
||||||
- `func (s *Service) Get(id string) (*ManagedProcess, error)`: Returns one managed process or `ErrProcessNotFound`.
|
|
||||||
- `func (s *Service) List() []*ManagedProcess`: Returns all managed processes currently stored in the service registry.
|
|
||||||
- `func (s *Service) Running() []*ManagedProcess`: Returns only processes whose `done` channel has not closed yet.
|
|
||||||
- `func (s *Service) Kill(id string) error`: Kills the managed process by ID and emits `ActionProcessKilled`.
|
|
||||||
- `func (s *Service) Remove(id string) error`: Deletes a completed process from the registry and rejects removal while it is still running.
|
|
||||||
- `func (s *Service) Clear()`: Deletes every completed process from the registry.
|
|
||||||
- `func (s *Service) Output(id string) (string, error)`: Returns the managed process's captured output.
|
|
||||||
- `func (s *Service) Run(ctx context.Context, command string, args ...string) core.Result`: Convenience wrapper around `RunWithOptions`.
|
|
||||||
- `func (s *Service) RunWithOptions(ctx context.Context, opts RunOptions) core.Result`: Executes an unmanaged one-shot command with `CombinedOutput`. On success it returns the output string in `Value`; on failure it returns a wrapped error in `Value` and sets `OK` false.
|
|
||||||
50
types.go
50
types.go
|
|
@ -1,34 +1,49 @@
|
||||||
// Package process provides process management with Core IPC integration.
|
// 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
|
// The process package enables spawning, monitoring, and controlling external
|
||||||
// processes with output streaming via the Core ACTION system.
|
// processes with output streaming via the Core ACTION system.
|
||||||
//
|
//
|
||||||
// # Getting Started
|
// # Getting Started
|
||||||
//
|
//
|
||||||
// c := core.New(core.WithService(process.Register))
|
// // Register with Core
|
||||||
// _ = c.ServiceStartup(ctx, nil)
|
// core, _ := framework.New(
|
||||||
|
// framework.WithName("process", process.NewService(process.Options{})),
|
||||||
|
// )
|
||||||
//
|
//
|
||||||
// r := c.Process().Run(ctx, "go", "test", "./...")
|
// // Get service and run a process
|
||||||
// output := r.Value.(string)
|
// svc, err := framework.ServiceFor[*process.Service](core, "process")
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// proc, err := svc.Start(ctx, "go", "test", "./...")
|
||||||
//
|
//
|
||||||
// # Listening for Events
|
// # Listening for Events
|
||||||
//
|
//
|
||||||
// Process events are broadcast via Core.ACTION:
|
// Process events are broadcast via Core.ACTION:
|
||||||
//
|
//
|
||||||
// c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
|
// core.RegisterAction(func(c *framework.Core, msg framework.Message) error {
|
||||||
// switch m := msg.(type) {
|
// switch m := msg.(type) {
|
||||||
// case process.ActionProcessOutput:
|
// case process.ActionProcessOutput:
|
||||||
// fmt.Print(m.Line)
|
// fmt.Print(m.Line)
|
||||||
// case process.ActionProcessExited:
|
// case process.ActionProcessExited:
|
||||||
// fmt.Printf("Exit code: %d\n", m.ExitCode)
|
// fmt.Printf("Exit code: %d\n", m.ExitCode)
|
||||||
// }
|
// }
|
||||||
// return core.Result{OK: true}
|
// return nil
|
||||||
// })
|
// })
|
||||||
package process
|
package process
|
||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Status represents the process lifecycle state.
|
// Status represents the process lifecycle state.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if proc.Status == process.StatusKilled { return }
|
||||||
type Status string
|
type Status string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -45,6 +60,10 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Stream identifies the output source.
|
// Stream identifies the output source.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// if event.Stream == process.StreamStdout { ... }
|
||||||
type Stream string
|
type Stream string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -55,6 +74,13 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunOptions configures process execution.
|
// RunOptions configures process execution.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// opts := process.RunOptions{
|
||||||
|
// Command: "go",
|
||||||
|
// Args: []string{"test", "./..."},
|
||||||
|
// }
|
||||||
type RunOptions struct {
|
type RunOptions struct {
|
||||||
// Command is the executable to run.
|
// Command is the executable to run.
|
||||||
Command string
|
Command string
|
||||||
|
|
@ -85,8 +111,13 @@ type RunOptions struct {
|
||||||
KillGroup bool
|
KillGroup bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessInfo provides a snapshot of process state without internal fields.
|
// Info provides a snapshot of process state without internal fields.
|
||||||
type ProcessInfo struct {
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// info := proc.Info()
|
||||||
|
// fmt.Println(info.PID)
|
||||||
|
type Info struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Args []string `json:"args"`
|
Args []string `json:"args"`
|
||||||
|
|
@ -98,6 +129,3 @@ type ProcessInfo struct {
|
||||||
Duration time.Duration `json:"duration"`
|
Duration time.Duration `json:"duration"`
|
||||||
PID int `json:"pid"`
|
PID int `json:"pid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info is kept as a compatibility alias for ProcessInfo.
|
|
||||||
type Info = ProcessInfo
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
|
import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
|
||||||
import type { ProcessInfo } from './shared/api.js';
|
import { ProcessApi, type ProcessInfo } from './shared/api.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <core-process-list> — Running processes with status and actions.
|
* <core-process-list> — Running processes with status and actions.
|
||||||
|
|
@ -14,9 +14,8 @@ import type { ProcessInfo } from './shared/api.js';
|
||||||
* Emits `process-selected` event when a process row is clicked, carrying
|
* Emits `process-selected` event when a process row is clicked, carrying
|
||||||
* the process ID for the output viewer.
|
* the process ID for the output viewer.
|
||||||
*
|
*
|
||||||
* Note: Requires process-level REST endpoints (GET /processes, POST /processes/:id/kill)
|
* The list is seeded from the REST API and then kept in sync with the live
|
||||||
* that are not yet in the provider. The element renders from WS events and local state
|
* process event stream when a WebSocket URL is configured.
|
||||||
* until those endpoints are available.
|
|
||||||
*/
|
*/
|
||||||
@customElement('core-process-list')
|
@customElement('core-process-list')
|
||||||
export class ProcessList extends LitElement {
|
export class ProcessList extends LitElement {
|
||||||
|
|
@ -193,11 +192,14 @@ export class ProcessList extends LitElement {
|
||||||
@state() private loading = false;
|
@state() private loading = false;
|
||||||
@state() private error = '';
|
@state() private error = '';
|
||||||
@state() private connected = false;
|
@state() private connected = false;
|
||||||
|
@state() private killing = new Set<string>();
|
||||||
|
|
||||||
|
private api!: ProcessApi;
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
this.api = new ProcessApi(this.apiUrl);
|
||||||
this.loadProcesses();
|
this.loadProcesses();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,24 +209,30 @@ export class ProcessList extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changed: Map<string, unknown>) {
|
updated(changed: Map<string, unknown>) {
|
||||||
if (changed.has('wsUrl')) {
|
if (changed.has('apiUrl')) {
|
||||||
|
this.api = new ProcessApi(this.apiUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed.has('wsUrl') || changed.has('apiUrl')) {
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
this.processes = [];
|
void this.loadProcesses();
|
||||||
this.loadProcesses();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadProcesses() {
|
async loadProcesses() {
|
||||||
// The process list is built from the shared process event stream.
|
this.loading = true;
|
||||||
this.error = '';
|
this.error = '';
|
||||||
this.loading = false;
|
try {
|
||||||
|
this.processes = await this.api.listProcesses();
|
||||||
if (!this.wsUrl) {
|
if (this.wsUrl) {
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.error = e.message ?? 'Failed to load processes';
|
||||||
this.processes = [];
|
this.processes = [];
|
||||||
return;
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.connect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSelect(proc: ProcessInfo) {
|
private handleSelect(proc: ProcessInfo) {
|
||||||
|
|
@ -237,21 +245,25 @@ export class ProcessList extends LitElement {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatUptime(started: string): string {
|
private async handleKill(proc: ProcessInfo) {
|
||||||
|
this.killing = new Set([...this.killing, proc.id]);
|
||||||
try {
|
try {
|
||||||
const ms = Date.now() - new Date(started).getTime();
|
await this.api.killProcess(proc.id);
|
||||||
const seconds = Math.floor(ms / 1000);
|
await this.loadProcesses();
|
||||||
if (seconds < 60) return `${seconds}s`;
|
} catch (e: any) {
|
||||||
const minutes = Math.floor(seconds / 60);
|
this.error = e.message ?? 'Failed to kill process';
|
||||||
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
} finally {
|
||||||
const hours = Math.floor(minutes / 60);
|
const next = new Set(this.killing);
|
||||||
return `${hours}h ${minutes % 60}m`;
|
next.delete(proc.id);
|
||||||
} catch {
|
this.killing = next;
|
||||||
return 'unknown';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private connect() {
|
private connect() {
|
||||||
|
if (!this.wsUrl || this.ws) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
|
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
|
||||||
this.applyEvent(event);
|
this.applyEvent(event);
|
||||||
});
|
});
|
||||||
|
|
@ -274,10 +286,7 @@ export class ProcessList extends LitElement {
|
||||||
|
|
||||||
private applyEvent(event: ProcessEvent) {
|
private applyEvent(event: ProcessEvent) {
|
||||||
const channel = event.channel ?? event.type ?? '';
|
const channel = event.channel ?? event.type ?? '';
|
||||||
const data = (event.data ?? {}) as Partial<ProcessInfo> & {
|
const data = (event.data ?? {}) as Partial<ProcessInfo> & { id?: string };
|
||||||
id?: string;
|
|
||||||
signal?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data.id) {
|
if (!data.id) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -286,36 +295,36 @@ export class ProcessList extends LitElement {
|
||||||
const next = new Map(this.processes.map((proc) => [proc.id, proc] as const));
|
const next = new Map(this.processes.map((proc) => [proc.id, proc] as const));
|
||||||
const current = next.get(data.id);
|
const current = next.get(data.id);
|
||||||
|
|
||||||
if (channel === 'process.started') {
|
switch (channel) {
|
||||||
next.set(data.id, this.normalizeProcess(data, current, 'running'));
|
case 'process.started':
|
||||||
this.processes = this.sortProcesses(next);
|
next.set(data.id, this.normalizeProcess(data, current, 'running'));
|
||||||
return;
|
break;
|
||||||
|
case 'process.exited':
|
||||||
|
next.set(data.id, this.normalizeProcess(data, current, data.exitCode === -1 && data.error ? 'failed' : 'exited'));
|
||||||
|
break;
|
||||||
|
case 'process.killed':
|
||||||
|
next.set(data.id, this.normalizeProcess(data, current, 'killed'));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel === 'process.exited') {
|
this.processes = this.sortProcesses(next);
|
||||||
next.set(data.id, this.normalizeProcess(data, current, 'exited'));
|
|
||||||
this.processes = this.sortProcesses(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (channel === 'process.killed') {
|
|
||||||
next.set(data.id, this.normalizeProcess(data, current, 'killed'));
|
|
||||||
this.processes = this.sortProcesses(next);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeProcess(
|
private normalizeProcess(
|
||||||
data: Partial<ProcessInfo> & { id: string; signal?: string },
|
data: Partial<ProcessInfo> & { id: string; error?: unknown },
|
||||||
current: ProcessInfo | undefined,
|
current: ProcessInfo | undefined,
|
||||||
status: ProcessInfo['status'],
|
status: ProcessInfo['status'],
|
||||||
): ProcessInfo {
|
): ProcessInfo {
|
||||||
|
const startedAt = data.startedAt ?? current?.startedAt ?? new Date().toISOString();
|
||||||
return {
|
return {
|
||||||
id: data.id,
|
id: data.id,
|
||||||
command: data.command ?? current?.command ?? '',
|
command: data.command ?? current?.command ?? '',
|
||||||
args: data.args ?? current?.args ?? [],
|
args: data.args ?? current?.args ?? [],
|
||||||
dir: data.dir ?? current?.dir ?? '',
|
dir: data.dir ?? current?.dir ?? '',
|
||||||
startedAt: data.startedAt ?? current?.startedAt ?? new Date().toISOString(),
|
startedAt,
|
||||||
|
running: status === 'running',
|
||||||
status,
|
status,
|
||||||
exitCode: data.exitCode ?? current?.exitCode ?? (status === 'killed' ? -1 : 0),
|
exitCode: data.exitCode ?? current?.exitCode ?? (status === 'killed' ? -1 : 0),
|
||||||
duration: data.duration ?? current?.duration ?? 0,
|
duration: data.duration ?? current?.duration ?? 0,
|
||||||
|
|
@ -324,9 +333,28 @@ export class ProcessList extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private sortProcesses(processes: Map<string, ProcessInfo>): ProcessInfo[] {
|
private sortProcesses(processes: Map<string, ProcessInfo>): ProcessInfo[] {
|
||||||
return [...processes.values()].sort(
|
return [...processes.values()].sort((a, b) => {
|
||||||
(a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime(),
|
const aStarted = new Date(a.startedAt).getTime();
|
||||||
);
|
const bStarted = new Date(b.startedAt).getTime();
|
||||||
|
if (aStarted === bStarted) {
|
||||||
|
return a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
return aStarted - bStarted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatUptime(started: string): string {
|
||||||
|
try {
|
||||||
|
const ms = Date.now() - new Date(started).getTime();
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours}h ${minutes % 60}m`;
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
@ -341,9 +369,9 @@ export class ProcessList extends LitElement {
|
||||||
<div class="info-notice">
|
<div class="info-notice">
|
||||||
${this.wsUrl
|
${this.wsUrl
|
||||||
? this.connected
|
? this.connected
|
||||||
? 'Waiting for process events from the WebSocket feed.'
|
? 'Receiving live process updates.'
|
||||||
: 'Connecting to the process event stream...'
|
: 'Connecting to the process event stream...'
|
||||||
: 'Set a WebSocket URL to receive live process events.'}
|
: 'Managed processes are loaded from the process REST API.'}
|
||||||
</div>
|
</div>
|
||||||
<div class="empty">No managed processes.</div>
|
<div class="empty">No managed processes.</div>
|
||||||
`
|
`
|
||||||
|
|
@ -379,12 +407,13 @@ export class ProcessList extends LitElement {
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<button
|
<button
|
||||||
class="kill-btn"
|
class="kill-btn"
|
||||||
disabled
|
?disabled=${this.killing.has(proc.id)}
|
||||||
@click=${(e: Event) => {
|
@click=${(e: Event) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
void this.handleKill(proc);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Live only
|
${this.killing.has(proc.id) ? 'Killing\u2026' : 'Kill'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, property, state } from 'lit/decorators.js';
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
|
import { connectProcessEvents, type ProcessEvent } from './shared/events.js';
|
||||||
|
import { ProcessApi } from './shared/api.js';
|
||||||
|
|
||||||
interface OutputLine {
|
interface OutputLine {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -131,14 +132,15 @@ export class ProcessOutput extends LitElement {
|
||||||
@state() private lines: OutputLine[] = [];
|
@state() private lines: OutputLine[] = [];
|
||||||
@state() private autoScroll = true;
|
@state() private autoScroll = true;
|
||||||
@state() private connected = false;
|
@state() private connected = false;
|
||||||
|
@state() private loadingSnapshot = false;
|
||||||
|
|
||||||
private ws: WebSocket | null = null;
|
private ws: WebSocket | null = null;
|
||||||
|
private api = new ProcessApi(this.apiUrl);
|
||||||
|
private syncToken = 0;
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
if (this.wsUrl && this.processId) {
|
this.syncSources();
|
||||||
this.connect();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
|
|
@ -147,12 +149,12 @@ export class ProcessOutput extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
updated(changed: Map<string, unknown>) {
|
updated(changed: Map<string, unknown>) {
|
||||||
if (changed.has('processId') || changed.has('wsUrl')) {
|
if (changed.has('apiUrl')) {
|
||||||
this.disconnect();
|
this.api = new ProcessApi(this.apiUrl);
|
||||||
this.lines = [];
|
}
|
||||||
if (this.wsUrl && this.processId) {
|
|
||||||
this.connect();
|
if (changed.has('processId') || changed.has('wsUrl') || changed.has('apiUrl')) {
|
||||||
}
|
this.syncSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.autoScroll) {
|
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() {
|
private connect() {
|
||||||
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
|
this.ws = connectProcessEvents(this.wsUrl, (event: ProcessEvent) => {
|
||||||
const data = event.data;
|
const data = event.data;
|
||||||
|
|
@ -231,7 +293,9 @@ export class ProcessOutput extends LitElement {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="output-body">
|
<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>`
|
? html`<div class="waiting">Waiting for output\u2026</div>`
|
||||||
: this.lines.map(
|
: this.lines.map(
|
||||||
(line) => html`
|
(line) => html`
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,6 @@ import type { RunResult, RunAllResult } from './shared/api.js';
|
||||||
*
|
*
|
||||||
* Shows RunSpec execution results with pass/fail/skip badges, duration,
|
* Shows RunSpec execution results with pass/fail/skip badges, duration,
|
||||||
* dependency chains, and aggregate summary.
|
* 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')
|
@customElement('core-process-runner')
|
||||||
export class ProcessRunner extends LitElement {
|
export class ProcessRunner extends LitElement {
|
||||||
|
|
@ -223,8 +219,9 @@ export class ProcessRunner extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadResults() {
|
async loadResults() {
|
||||||
// Pipeline runner REST endpoints are not yet available.
|
// Results are supplied via the `result` property. The REST API can be
|
||||||
// Results can be passed in via the `result` property.
|
// used by the surrounding application to execute a pipeline and then
|
||||||
|
// assign the returned data here.
|
||||||
}
|
}
|
||||||
|
|
||||||
private toggleOutput(name: string) {
|
private toggleOutput(name: string) {
|
||||||
|
|
@ -253,9 +250,7 @@ export class ProcessRunner extends LitElement {
|
||||||
if (!this.result) {
|
if (!this.result) {
|
||||||
return html`
|
return html`
|
||||||
<div class="info-notice">
|
<div class="info-notice">
|
||||||
Pipeline runner endpoints are pending. Pass pipeline results via the
|
Pass pipeline results via the <code>result</code> property.
|
||||||
<code>result</code> property, or results will appear here once the REST
|
|
||||||
API for pipeline execution is available.
|
|
||||||
</div>
|
</div>
|
||||||
<div class="empty">No pipeline results.</div>
|
<div class="empty">No pipeline results.</div>
|
||||||
`;
|
`;
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,26 @@ export interface ProcessInfo {
|
||||||
args: string[];
|
args: string[];
|
||||||
dir: string;
|
dir: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
|
running: boolean;
|
||||||
status: 'pending' | 'running' | 'exited' | 'failed' | 'killed';
|
status: 'pending' | 'running' | 'exited' | 'failed' | 'killed';
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
pid: 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.
|
* Pipeline run result for a single spec.
|
||||||
*/
|
*/
|
||||||
|
|
@ -62,6 +76,21 @@ export interface RunAllResult {
|
||||||
success: boolean;
|
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.
|
* 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> {
|
healthCheck(code: string, daemon: string): Promise<HealthResult> {
|
||||||
return this.request<HealthResult>(`/daemons/${code}/${daemon}/health`);
|
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