Compare commits

..

98 commits
dev ... main

Author SHA1 Message Date
Virgil
1e536f1a7c feat(ui): expose process control client methods 2026-04-04 08:11:19 +00:00
Virgil
3dd65af0a5 feat(api): add process start and run endpoints 2026-04-04 08:02:33 +00:00
Virgil
a7cde26b9b feat(api): allow pid targeting for process controls 2026-04-04 07:52:42 +00:00
Virgil
56bc171add feat(process): add running-only process listing 2026-04-04 07:48:31 +00:00
Virgil
f9537fb24d feat(api): add process signal endpoint 2026-04-04 07:44:37 +00:00
Virgil
cf9291d095 feat(process): add wait API endpoint 2026-04-04 07:41:05 +00:00
Virgil
720104babc feat(process): validate runner dependencies 2026-04-04 07:37:50 +00:00
Virgil
bc2cb6ae9d fix(process): keep runner exit errors nil 2026-04-04 07:33:50 +00:00
Virgil
f4da274ce6 fix(process): keep signal zero as liveness probe
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 07:29:00 +00:00
Virgil
b74ee080a2 feat(process): add service error helper
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 07:24:12 +00:00
Virgil
429675ca29 feat(process): add package register helper
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 07:19:56 +00:00
Virgil
588f4e173b fix(exec): guard default logger access
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-04 07:15:04 +00:00
Virgil
3ac213a058 feat(process): preserve wait task snapshots on failure 2026-04-04 07:08:54 +00:00
Virgil
e1f5b0ff40 fix(process): harden health server snapshots
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-04 07:00:45 +00:00
Virgil
ac5a938b70 feat(process): add readiness polling helpers 2026-04-04 06:55:42 +00:00
Virgil
1398c4b8ea feat(process): kill unmanaged pids forcefully 2026-04-04 06:51:14 +00:00
Virgil
2461466f55 Handle nil contexts in runner and daemon 2026-04-04 06:48:18 +00:00
Virgil
208dac3c82 feat(api): expose process stdin control
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 06:44:01 +00:00
Virgil
8d8267543d feat(process): include exit errors in action payloads 2026-04-04 06:39:22 +00:00
Virgil
9b3dd1ec49 feat(process): emit daemon started discovery events
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 06:12:49 +00:00
Virgil
040500f3e1 feat(process): broadcast provider process ws events 2026-04-04 06:09:01 +00:00
Virgil
c7542939c7 fix(process): count skipped runner results as success
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 06:00:52 +00:00
Virgil
dcf20c78c8 feat(process): add cleanup tasks to core service 2026-04-04 05:49:58 +00:00
Virgil
f717fc66c3 feat(process): add stdin service helpers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 04:28:06 +00:00
Virgil
dec0231938 fix(process): leave exit action errors unset
Align ActionProcessExited with the documented contract by keeping the reserved Error field nil for both start failures and normal exits.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 04:23:28 +00:00
Virgil
3930aed49a feat(process): allow zero-value task signals
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 04:19:17 +00:00
Virgil
8d1a0d0655 fix(process): retain failed starts in service state
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 03:37:26 +00:00
Virgil
85cd6dd7c8 feat(process): add wait task surface
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-04 03:33:29 +00:00
Virgil
79e2ffa6ed feat(process): add signal task surface
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-04 03:28:28 +00:00
Virgil
04543700bc fix(process): skip unresolved runner dependencies
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 03:24:33 +00:00
Virgil
86f5fadff7 fix(process): treat unresolved runner specs as failures 2026-04-04 03:21:01 +00:00
Virgil
c31f3faa2b Tighten process package API contracts 2026-04-04 03:17:30 +00:00
Virgil
e85abe1ee6 feat(process): add ManagedProcess alias 2026-04-04 03:14:25 +00:00
Virgil
4974b0fd08 fix(process): prefer resolved program path 2026-04-04 03:10:39 +00:00
Virgil
c9deb8fdfd fix(process): let Program.Find validate existing paths 2026-04-04 03:07:13 +00:00
Virgil
f43e8a6e38 feat(process): add global remove and clear helpers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 03:04:00 +00:00
Virgil
02e2b3611c fix(process): reorder daemon shutdown teardown
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 03:00:09 +00:00
Virgil
d34ab22ad3 feat(process): add global output helper 2026-04-04 02:57:25 +00:00
Virgil
a8c193d07c feat(process): report live duration snapshots 2026-04-04 02:53:12 +00:00
Virgil
155f216a7c feat(process): add stdin close task 2026-04-04 02:49:37 +00:00
Virgil
227739638b feat(process): add Core stdin task 2026-04-04 02:45:56 +00:00
Virgil
ceea10fc7a feat(process): add async process start task 2026-04-04 02:42:45 +00:00
Virgil
6c1d53a237 fix(process): preserve leading whitespace in Program output 2026-04-04 02:39:59 +00:00
Virgil
cffe06631b feat(process): add process output task 2026-04-04 02:29:52 +00:00
Virgil
ec2a6838b8 Propagate process exit errors 2026-04-04 02:24:32 +00:00
Virgil
98fe626d8e feat(process): add process get core task 2026-04-04 02:21:01 +00:00
Virgil
87da81ffeb fix(process): leave exit action errors unset 2026-04-04 02:14:58 +00:00
Virgil
26af69d87b fix(process): kill process groups on shutdown 2026-04-04 02:10:35 +00:00
Virgil
38a9f034a7 fix(process): handle zero-capacity ring buffers 2026-04-04 02:07:23 +00:00
Virgil
73b0ffecc0 fix(process): reject nil start context 2026-04-04 02:03:49 +00:00
Virgil
6f35954ac2 feat(process-ui): stream live process list from websocket 2026-04-04 01:59:58 +00:00
Virgil
66d5b0a15e fix(process): make registry unregister idempotent
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:55:10 +00:00
Virgil
945e760542 fix(process): unregister daemon before health shutdown
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:51:39 +00:00
Virgil
b097e0ef0e fix(process): mark daemon not-ready before shutdown
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:48:07 +00:00
Virgil
911abb6ee8 feat(process): add process output snapshot endpoint 2026-04-04 01:45:09 +00:00
Virgil
c5adc8066e fix(process): terminate unmanaged pids with sigterm 2026-04-04 01:40:53 +00:00
Virgil
4b1013a023 feat(process-ui): wire process REST API 2026-04-04 01:37:09 +00:00
Virgil
1028e31ae5 Fix process group signal escalation 2026-04-04 01:32:43 +00:00
Virgil
ba4b0f1166 Force kill unmanaged PIDs 2026-04-04 01:29:54 +00:00
Virgil
2e5ac4208b feat(process): auto-populate daemon registry metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:26:42 +00:00
Virgil
ab02432543 fix(process): unify pid kill handling 2026-04-04 01:22:58 +00:00
Virgil
498137fa8e refactor(process): align Program with AX helpers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:19:59 +00:00
Virgil
16e5c57fd4 feat(process): skip pending runner specs on cancellation
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:16:58 +00:00
Virgil
82e85a99fd feat(process): report kill errors in exit actions 2026-04-04 01:13:33 +00:00
Virgil
31be7280a6 feat(process): honor pending lifecycle 2026-04-04 01:11:03 +00:00
Virgil
1ccc61848b fix(process): kill running processes on shutdown
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:07:16 +00:00
Virgil
90ce26a1b7 feat(api): expose managed process routes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 01:03:42 +00:00
Virgil
eb6a7819e7 feat(process): emit kill action immediately 2026-04-04 01:00:27 +00:00
Virgil
dfa97f2112 fix(process): allow standalone service usage 2026-04-04 00:55:49 +00:00
Virgil
0e299e5349 feat(process): add process list core task 2026-04-04 00:52:58 +00:00
Virgil
d565e3539e feat(process): add pipeline execution API 2026-04-04 00:49:35 +00:00
Virgil
686f1053b3 feat(process): fix daemon stop order 2026-04-04 00:45:40 +00:00
Virgil
2255ade57e feat(process): harden process group signalling 2026-04-04 00:42:46 +00:00
Virgil
cdc8bfe502 feat(process): add readiness accessors and AX examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 00:39:27 +00:00
Virgil
eeca66240a feat(process): make listings deterministic 2026-04-04 00:32:12 +00:00
Virgil
24f853631d Add PID-based process kill support 2026-04-04 00:28:15 +00:00
Virgil
ce2a4db6cb fix(process): reject empty start command 2026-04-04 00:24:52 +00:00
Virgil
f98bbad5ac fix(process): reject duplicate runner spec names 2026-04-04 00:21:22 +00:00
Virgil
fa79e4eee7 fix(process): guard program run inputs 2026-04-04 00:18:14 +00:00
Virgil
214cf4cfa8 feat(process): expose health probe failure reasons
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 00:15:20 +00:00
Virgil
5142114e89 feat(process): rollback daemon startup on registry failure 2026-04-04 00:11:21 +00:00
Virgil
84d07daf19 feat(process): relax runner success semantics 2026-04-04 00:07:48 +00:00
Virgil
2bc6eb70d7 fix(process): copy info slices defensively 2026-04-04 00:04:54 +00:00
Virgil
f5a940facd feat(process): add running flag to process info 2026-04-04 00:01:22 +00:00
Virgil
9b536f08c6 feat(exec): require command context 2026-04-03 23:56:56 +00:00
Virgil
e58f376e4c feat(process): signal process groups 2026-04-03 23:53:19 +00:00
Virgil
252f68db64 feat(process): forward task run options 2026-04-03 23:50:52 +00:00
Virgil
1b7431e3a0 feat(process): skip unresolved pipeline specs 2026-04-03 23:47:38 +00:00
Virgil
6fda03d64d feat(process): fail invalid runner dependencies 2026-04-03 23:45:06 +00:00
Virgil
9457694e46 feat(process): preserve runner result order 2026-04-03 23:41:36 +00:00
Virgil
dcf058047e feat(process): emit exit actions consistently 2026-04-03 23:37:48 +00:00
Virgil
f70e301631 feat(process): validate KillGroup requires Detach 2026-04-03 23:34:16 +00:00
Virgil
87bebd7fa6 feat(exec): add background command support 2026-04-03 23:27:27 +00:00
Virgil
62e7bd7814 Fix runner deadlock handling 2026-04-03 23:22:43 +00:00
Virgil
b6530cf85d feat(process): track killed process lifecycle 2026-04-03 23:20:28 +00:00
Virgil
0546b42ce3 feat(process): add Core task for run execution 2026-04-03 23:17:11 +00:00
206b9a1f52 Merge pull request 'chore: update dependencies to dappco.re tagged versions' (#8) from dev into main
Reviewed-on: #8
2026-03-23 20:35:28 +00:00
Claude
61867e56bb
chore: update dependencies to dappco.re tagged versions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 01:10:19 +00:00
47 changed files with 6563 additions and 3066 deletions

View file

@ -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)

View file

@ -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
}
}

View file

@ -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

View file

@ -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
View file

@ -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)

View file

@ -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())
}

View file

@ -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.

View file

@ -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", "./...").

View file

@ -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

View file

@ -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

View file

@ -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
View 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
View 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)
}

View file

@ -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

View file

@ -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...)
} }

View file

@ -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 {

View file

@ -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
View 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
View file

@ -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
View file

@ -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
View file

@ -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
} }

View file

@ -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())
}

View file

@ -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
} }

View file

@ -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)

View file

@ -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,
}
}

View file

@ -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
}

File diff suppressed because it is too large Load diff

View file

@ -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
View 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)
)

View file

@ -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)
}) })
} }

View file

@ -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
} }

View file

@ -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)
}

View file

@ -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()
}

View file

@ -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)
} }

189
runner.go
View file

@ -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 only reports start-time or orchestration failures. A started process
// that exits non-zero uses ExitCode to report failure and leaves Error nil.
Error error Error error
Skipped bool 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
}

View file

@ -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)
})
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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,25 +209,31 @@ 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.processes = [];
return;
}
this.connect(); this.connect();
} }
} catch (e: any) {
this.error = e.message ?? 'Failed to load processes';
this.processes = [];
} finally {
this.loading = false;
}
}
private handleSelect(proc: ProcessInfo) { private handleSelect(proc: ProcessInfo) {
this.dispatchEvent( this.dispatchEvent(
@ -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) {
case 'process.started':
next.set(data.id, this.normalizeProcess(data, current, 'running')); next.set(data.id, this.normalizeProcess(data, current, 'running'));
this.processes = this.sortProcesses(next); break;
return; case 'process.exited':
} next.set(data.id, this.normalizeProcess(data, current, data.exitCode === -1 && data.error ? 'failed' : 'exited'));
break;
if (channel === 'process.exited') { case 'process.killed':
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')); next.set(data.id, this.normalizeProcess(data, current, 'killed'));
this.processes = this.sortProcesses(next); break;
default:
return; return;
} }
this.processes = this.sortProcesses(next);
} }
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>
` `

View file

@ -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`

View file

@ -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>
`; `;

View file

@ -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 }),
});
}
} }