From 62623ce0d6602ddf4cc436b4b06920389dff067b Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 26 Mar 2026 18:28:10 +0000 Subject: [PATCH] fix(ax): finish v0.8.0 polish pass Co-Authored-By: Virgil --- buffer.go | 4 + buffer_test.go | 2 +- daemon.go | 6 + daemon_test.go | 20 +-- exec/doc.go | 3 + exec/exec.go | 31 +++-- exec/exec_test.go | 33 ++--- exec/logger.go | 8 ++ global_test.go | 8 +- health.go | 8 ++ health_test.go | 4 +- internal/jsonx/jsonx.go | 17 --- pidfile_test.go | 22 ++-- pkg/api/provider_test.go | 11 +- program.go | 10 +- program_test.go | 24 ++-- registry.go | 264 +++++++++++++++++++++++++++++++++++++-- registry_test.go | 16 +-- runner_test.go | 2 +- service_test.go | 5 +- 20 files changed, 388 insertions(+), 110 deletions(-) delete mode 100644 internal/jsonx/jsonx.go diff --git a/buffer.go b/buffer.go index bf02f59..7694b79 100644 --- a/buffer.go +++ b/buffer.go @@ -4,6 +4,8 @@ import "sync" // RingBuffer is a fixed-size circular buffer that overwrites old data. // Thread-safe for concurrent reads and writes. +// +// rb := process.NewRingBuffer(1024) type RingBuffer struct { data []byte size int @@ -14,6 +16,8 @@ type RingBuffer struct { } // NewRingBuffer creates a ring buffer with the given capacity. +// +// rb := process.NewRingBuffer(256) func NewRingBuffer(size int) *RingBuffer { return &RingBuffer{ data: make([]byte, size), diff --git a/buffer_test.go b/buffer_test.go index 65a5076..2c54cbd 100644 --- a/buffer_test.go +++ b/buffer_test.go @@ -6,7 +6,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestRingBuffer_Good(t *testing.T) { +func TestRingBuffer_Basics_Good(t *testing.T) { t.Run("write and read", func(t *testing.T) { rb := NewRingBuffer(10) diff --git a/daemon.go b/daemon.go index e3f30a5..c06e8db 100644 --- a/daemon.go +++ b/daemon.go @@ -9,6 +9,8 @@ import ( ) // DaemonOptions configures daemon mode execution. +// +// opts := process.DaemonOptions{PIDFile: "/tmp/process.pid", HealthAddr: "127.0.0.1:0"} type DaemonOptions struct { // PIDFile path for single-instance enforcement. // Leave empty to skip PID file management. @@ -35,6 +37,8 @@ type DaemonOptions struct { } // Daemon manages daemon lifecycle: PID file, health server, graceful shutdown. +// +// daemon := process.NewDaemon(process.DaemonOptions{HealthAddr: "127.0.0.1:0"}) type Daemon struct { opts DaemonOptions pid *PIDFile @@ -44,6 +48,8 @@ type Daemon struct { } // NewDaemon creates a daemon runner with the given options. +// +// daemon := process.NewDaemon(process.DaemonOptions{PIDFile: "/tmp/process.pid"}) func NewDaemon(opts DaemonOptions) *Daemon { if opts.ShutdownTimeout == 0 { opts.ShutdownTimeout = 30 * time.Second diff --git a/daemon_test.go b/daemon_test.go index 9433dfa..0bfb27d 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -4,16 +4,16 @@ import ( "context" "net/http" "os" - "path/filepath" "testing" "time" + "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestDaemon_Lifecycle_Good(t *testing.T) { - pidPath := filepath.Join(t.TempDir(), "test.pid") + pidPath := core.JoinPath(t.TempDir(), "test.pid") d := NewDaemon(DaemonOptions{ PIDFile: pidPath, @@ -36,7 +36,7 @@ func TestDaemon_Lifecycle_Good(t *testing.T) { require.NoError(t, err) } -func TestDaemon_Start_Bad_AlreadyRunning(t *testing.T) { +func TestDaemon_AlreadyRunning_Bad(t *testing.T) { d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", }) @@ -50,7 +50,7 @@ func TestDaemon_Start_Bad_AlreadyRunning(t *testing.T) { assert.Contains(t, err.Error(), "already running") } -func TestDaemon_Run_Bad_NotStarted(t *testing.T) { +func TestDaemon_RunUnstarted_Bad(t *testing.T) { d := NewDaemon(DaemonOptions{}) ctx, cancel := context.WithCancel(context.Background()) @@ -83,17 +83,17 @@ func TestDaemon_SetReady_Good(t *testing.T) { _ = resp.Body.Close() } -func TestDaemon_HealthAddr_Good_EmptyWhenDisabled(t *testing.T) { +func TestDaemon_HealthAddrDisabled_Good(t *testing.T) { d := NewDaemon(DaemonOptions{}) assert.Empty(t, d.HealthAddr()) } -func TestDaemon_ShutdownTimeout_Good_Default(t *testing.T) { +func TestDaemon_DefaultTimeout_Good(t *testing.T) { d := NewDaemon(DaemonOptions{}) assert.Equal(t, 30*time.Second, d.opts.ShutdownTimeout) } -func TestDaemon_Run_Good_BlocksUntilCancelled(t *testing.T) { +func TestDaemon_RunBlocking_Good(t *testing.T) { d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", }) @@ -126,7 +126,7 @@ func TestDaemon_Run_Good_BlocksUntilCancelled(t *testing.T) { } } -func TestDaemon_Stop_Good_Idempotent(t *testing.T) { +func TestDaemon_StopIdempotent_Good(t *testing.T) { d := NewDaemon(DaemonOptions{}) // Stop without Start should be a no-op @@ -134,9 +134,9 @@ func TestDaemon_Stop_Good_Idempotent(t *testing.T) { assert.NoError(t, err) } -func TestDaemon_Registry_Good_AutoRegisters(t *testing.T) { +func TestDaemon_AutoRegister_Good(t *testing.T) { dir := t.TempDir() - reg := NewRegistry(filepath.Join(dir, "daemons")) + reg := NewRegistry(core.JoinPath(dir, "daemons")) d := NewDaemon(DaemonOptions{ HealthAddr: "127.0.0.1:0", diff --git a/exec/doc.go b/exec/doc.go index 4875ce8..b43ef6a 100644 --- a/exec/doc.go +++ b/exec/doc.go @@ -1,3 +1,6 @@ // 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 diff --git a/exec/exec.go b/exec/exec.go index ce2b29b..bc96542 100644 --- a/exec/exec.go +++ b/exec/exec.go @@ -3,16 +3,16 @@ package exec import ( "bytes" "context" - "fmt" "io" "os" "os/exec" - "strings" - coreerr "dappco.re/go/core/log" + "dappco.re/go/core" ) // Options configures command execution. +// +// opts := exec.Options{Dir: "/workspace", Env: []string{"CI=1"}} type Options struct { Dir string Env []string @@ -24,6 +24,8 @@ type Options struct { } // Command wraps `os/exec.Command` with logging and context. +// +// cmd := exec.Command(ctx, "git", "status").WithDir("/workspace") func Command(ctx context.Context, name string, args ...string) *Cmd { return &Cmd{ name: name, @@ -144,22 +146,23 @@ func (c *Cmd) prepare() { // RunQuiet executes the command suppressing stdout unless there is an error. // Useful for internal commands. +// +// _ = exec.RunQuiet(ctx, "go", "test", "./...") func RunQuiet(ctx context.Context, name string, args ...string) error { var stderr bytes.Buffer cmd := Command(ctx, name, args...).WithStderr(&stderr) if err := cmd.Run(); err != nil { - // Include stderr in error message - return coreerr.E("RunQuiet", strings.TrimSpace(stderr.String()), err) + return core.E("RunQuiet", core.Trim(stderr.String()), err) } return nil } func wrapError(caller string, err error, name string, args []string) error { - cmdStr := name + " " + strings.Join(args, " ") + cmdStr := commandString(name, args) if exitErr, ok := err.(*exec.ExitError); ok { - return coreerr.E(caller, fmt.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err) + return core.E(caller, core.Sprintf("command %q failed with exit code %d", cmdStr, exitErr.ExitCode()), err) } - return coreerr.E(caller, fmt.Sprintf("failed to execute %q", cmdStr), err) + return core.E(caller, core.Sprintf("failed to execute %q", cmdStr), err) } func (c *Cmd) getLogger() Logger { @@ -170,9 +173,17 @@ func (c *Cmd) getLogger() Logger { } func (c *Cmd) logDebug(msg string) { - c.getLogger().Debug(msg, "cmd", c.name, "args", strings.Join(c.args, " ")) + c.getLogger().Debug(msg, "cmd", c.name, "args", core.Join(" ", c.args...)) } func (c *Cmd) logError(msg string, err error) { - c.getLogger().Error(msg, "cmd", c.name, "args", strings.Join(c.args, " "), "err", err) + c.getLogger().Error(msg, "cmd", c.name, "args", core.Join(" ", c.args...), "err", err) +} + +func commandString(name string, args []string) string { + if len(args) == 0 { + return name + } + parts := append([]string{name}, args...) + return core.Join(" ", parts...) } diff --git a/exec/exec_test.go b/exec/exec_test.go index b48f045..105cbcc 100644 --- a/exec/exec_test.go +++ b/exec/exec_test.go @@ -2,9 +2,9 @@ package exec_test import ( "context" - "strings" "testing" + "dappco.re/go/core" "dappco.re/go/core/process/exec" ) @@ -27,7 +27,7 @@ func (m *mockLogger) Error(msg string, keyvals ...any) { m.errorCalls = append(m.errorCalls, logCall{msg, keyvals}) } -func TestCommand_Run_Good_LogsDebug(t *testing.T) { +func TestCommand_Run_Good(t *testing.T) { logger := &mockLogger{} ctx := context.Background() @@ -49,7 +49,7 @@ func TestCommand_Run_Good_LogsDebug(t *testing.T) { } } -func TestCommand_Run_Bad_LogsError(t *testing.T) { +func TestCommand_Run_Bad(t *testing.T) { logger := &mockLogger{} ctx := context.Background() @@ -81,7 +81,7 @@ func TestCommand_Output_Good(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if strings.TrimSpace(string(out)) != "test" { + if core.Trim(string(out)) != "test" { t.Errorf("expected 'test', got %q", string(out)) } if len(logger.debugCalls) != 1 { @@ -99,7 +99,7 @@ func TestCommand_CombinedOutput_Good(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if strings.TrimSpace(string(out)) != "combined" { + if core.Trim(string(out)) != "combined" { t.Errorf("expected 'combined', got %q", string(out)) } if len(logger.debugCalls) != 1 { @@ -107,14 +107,14 @@ func TestCommand_CombinedOutput_Good(t *testing.T) { } } -func TestNopLogger_Good(t *testing.T) { +func TestNopLogger_Methods_Good(t *testing.T) { // Verify NopLogger doesn't panic var nop exec.NopLogger nop.Debug("msg", "key", "val") nop.Error("msg", "key", "val") } -func TestSetDefaultLogger_Good(t *testing.T) { +func TestLogger_SetDefault_Good(t *testing.T) { original := exec.DefaultLogger() defer exec.SetDefaultLogger(original) @@ -156,7 +156,7 @@ func TestCommand_WithDir_Good(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - trimmed := strings.TrimSpace(string(out)) + trimmed := core.Trim(string(out)) if trimmed != "/tmp" && trimmed != "/private/tmp" { t.Errorf("expected /tmp or /private/tmp, got %q", trimmed) } @@ -171,31 +171,32 @@ func TestCommand_WithEnv_Good(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if strings.TrimSpace(string(out)) != "exec_val" { + if core.Trim(string(out)) != "exec_val" { t.Errorf("expected 'exec_val', got %q", string(out)) } } func TestCommand_WithStdinStdoutStderr_Good(t *testing.T) { ctx := context.Background() - input := strings.NewReader("piped input\n") - var stdout, stderr strings.Builder + input := core.NewReader("piped input\n") + stdout := core.NewBuilder() + stderr := core.NewBuilder() err := exec.Command(ctx, "cat"). WithStdin(input). - WithStdout(&stdout). - WithStderr(&stderr). + WithStdout(stdout). + WithStderr(stderr). WithLogger(&mockLogger{}). Run() if err != nil { t.Fatalf("unexpected error: %v", err) } - if strings.TrimSpace(stdout.String()) != "piped input" { + if core.Trim(stdout.String()) != "piped input" { t.Errorf("expected 'piped input', got %q", stdout.String()) } } -func TestRunQuiet_Good(t *testing.T) { +func TestRunQuiet_Command_Good(t *testing.T) { ctx := context.Background() err := exec.RunQuiet(ctx, "echo", "quiet") if err != nil { @@ -203,7 +204,7 @@ func TestRunQuiet_Good(t *testing.T) { } } -func TestRunQuiet_Bad(t *testing.T) { +func TestRunQuiet_Command_Bad(t *testing.T) { ctx := context.Background() err := exec.RunQuiet(ctx, "sh", "-c", "echo fail >&2; exit 1") if err == nil { diff --git a/exec/logger.go b/exec/logger.go index e8f5a6b..ba9713e 100644 --- a/exec/logger.go +++ b/exec/logger.go @@ -2,6 +2,8 @@ package exec // Logger interface for command execution logging. // Compatible with pkg/log.Logger and other structured loggers. +// +// exec.SetDefaultLogger(myLogger) type Logger interface { // Debug logs a debug-level message with optional key-value pairs. Debug(msg string, keyvals ...any) @@ -10,6 +12,8 @@ type Logger interface { } // NopLogger is a no-op logger that discards all messages. +// +// var logger exec.NopLogger type NopLogger struct{} // Debug discards the message (no-op implementation). @@ -22,6 +26,8 @@ var defaultLogger Logger = NopLogger{} // SetDefaultLogger sets the package-level default logger. // Commands without an explicit logger will use this. +// +// exec.SetDefaultLogger(myLogger) func SetDefaultLogger(l Logger) { if l == nil { l = NopLogger{} @@ -30,6 +36,8 @@ func SetDefaultLogger(l Logger) { } // DefaultLogger returns the current default logger. +// +// logger := exec.DefaultLogger() func DefaultLogger() Logger { return defaultLogger } diff --git a/global_test.go b/global_test.go index 038e397..d1bd44a 100644 --- a/global_test.go +++ b/global_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestGlobal_Default_Bad_NotInitialized(t *testing.T) { +func TestGlobal_NotInitialized_Bad(t *testing.T) { old := defaultService.Swap(nil) defer func() { if old != nil { @@ -72,7 +72,7 @@ func TestGlobal_SetDefault_Good(t *testing.T) { }) } -func TestGlobal_Default_Good_Concurrent(t *testing.T) { +func TestGlobal_DefaultConcurrent_Good(t *testing.T) { old := defaultService.Swap(nil) defer func() { if old != nil { @@ -97,7 +97,7 @@ func TestGlobal_Default_Good_Concurrent(t *testing.T) { wg.Wait() } -func TestGlobal_SetDefault_Good_Concurrent(t *testing.T) { +func TestGlobal_SetDefaultConcurrent_Good(t *testing.T) { old := defaultService.Swap(nil) defer func() { if old != nil { @@ -134,7 +134,7 @@ func TestGlobal_SetDefault_Good_Concurrent(t *testing.T) { assert.True(t, found, "Default should be one of the set services") } -func TestGlobal_Operations_Good_Concurrent(t *testing.T) { +func TestGlobal_ConcurrentOps_Good(t *testing.T) { old := defaultService.Swap(nil) defer func() { if old != nil { diff --git a/health.go b/health.go index 5022234..e52dd94 100644 --- a/health.go +++ b/health.go @@ -12,9 +12,13 @@ import ( ) // HealthCheck is a function that returns nil if healthy. +// +// check := process.HealthCheck(func() error { return nil }) type HealthCheck func() error // HealthServer provides HTTP /health and /ready endpoints for process monitoring. +// +// hs := process.NewHealthServer("127.0.0.1:0") type HealthServer struct { addr string server *http.Server @@ -25,6 +29,8 @@ type HealthServer struct { } // NewHealthServer creates a health check server on the given address. +// +// hs := process.NewHealthServer("127.0.0.1:0") func NewHealthServer(addr string) *HealthServer { return &HealthServer{ addr: addr, @@ -115,6 +121,8 @@ func (h *HealthServer) Addr() string { // WaitForHealth polls a health endpoint until it responds 200 or the timeout // (in milliseconds) expires. Returns true if healthy, false on timeout. +// +// ok := process.WaitForHealth("127.0.0.1:9000", 2_000) func WaitForHealth(addr string, timeoutMs int) bool { deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) url := core.Concat("http://", addr, "/health") diff --git a/health_test.go b/health_test.go index 3d952a7..32760d2 100644 --- a/health_test.go +++ b/health_test.go @@ -66,7 +66,7 @@ func TestHealthServer_WithChecks_Good(t *testing.T) { _ = resp.Body.Close() } -func TestWaitForHealth_Good_Reachable(t *testing.T) { +func TestWaitForHealth_Reachable_Good(t *testing.T) { hs := NewHealthServer("127.0.0.1:0") require.NoError(t, hs.Start()) defer func() { _ = hs.Stop(context.Background()) }() @@ -75,7 +75,7 @@ func TestWaitForHealth_Good_Reachable(t *testing.T) { assert.True(t, ok) } -func TestWaitForHealth_Bad_Unreachable(t *testing.T) { +func TestWaitForHealth_Unreachable_Bad(t *testing.T) { ok := WaitForHealth("127.0.0.1:19999", 500) assert.False(t, ok) } diff --git a/internal/jsonx/jsonx.go b/internal/jsonx/jsonx.go deleted file mode 100644 index cb6b885..0000000 --- a/internal/jsonx/jsonx.go +++ /dev/null @@ -1,17 +0,0 @@ -package jsonx - -import "encoding/json" - -// MarshalIndent marshals v as indented JSON and returns the string form. -func MarshalIndent(v any) (string, error) { - data, err := json.MarshalIndent(v, "", " ") - if err != nil { - return "", err - } - return string(data), nil -} - -// Unmarshal unmarshals a JSON string into v. -func Unmarshal(data string, v any) error { - return json.Unmarshal([]byte(data), v) -} diff --git a/pidfile_test.go b/pidfile_test.go index 1ec0afe..abdfa29 100644 --- a/pidfile_test.go +++ b/pidfile_test.go @@ -2,15 +2,15 @@ package process import ( "os" - "path/filepath" "testing" + "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestPIDFile_Acquire_Good(t *testing.T) { - pidPath := filepath.Join(t.TempDir(), "test.pid") + pidPath := core.JoinPath(t.TempDir(), "test.pid") pid := NewPIDFile(pidPath) err := pid.Acquire() require.NoError(t, err) @@ -23,8 +23,8 @@ func TestPIDFile_Acquire_Good(t *testing.T) { assert.True(t, os.IsNotExist(err)) } -func TestPIDFile_Acquire_Good_StalePID(t *testing.T) { - pidPath := filepath.Join(t.TempDir(), "stale.pid") +func TestPIDFile_AcquireStale_Good(t *testing.T) { + pidPath := core.JoinPath(t.TempDir(), "stale.pid") require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644)) pid := NewPIDFile(pidPath) err := pid.Acquire() @@ -33,8 +33,8 @@ func TestPIDFile_Acquire_Good_StalePID(t *testing.T) { require.NoError(t, err) } -func TestPIDFile_Acquire_Good_CreatesParentDirectory(t *testing.T) { - pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid") +func TestPIDFile_CreateDirectory_Good(t *testing.T) { + pidPath := core.JoinPath(t.TempDir(), "subdir", "nested", "test.pid") pid := NewPIDFile(pidPath) err := pid.Acquire() require.NoError(t, err) @@ -47,22 +47,22 @@ func TestPIDFile_Path_Good(t *testing.T) { assert.Equal(t, "/tmp/test.pid", pid.Path()) } -func TestReadPID_Bad_Missing(t *testing.T) { +func TestReadPID_Missing_Bad(t *testing.T) { pid, running := ReadPID("/nonexistent/path.pid") assert.Equal(t, 0, pid) assert.False(t, running) } -func TestReadPID_Bad_InvalidContent(t *testing.T) { - path := filepath.Join(t.TempDir(), "bad.pid") +func TestReadPID_Invalid_Bad(t *testing.T) { + path := core.JoinPath(t.TempDir(), "bad.pid") require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644)) pid, running := ReadPID(path) assert.Equal(t, 0, pid) assert.False(t, running) } -func TestReadPID_Bad_StalePID(t *testing.T) { - path := filepath.Join(t.TempDir(), "stale.pid") +func TestReadPID_Stale_Bad(t *testing.T) { + path := core.JoinPath(t.TempDir(), "stale.pid") require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644)) pid, running := ReadPID(path) assert.Equal(t, 999999999, pid) diff --git a/pkg/api/provider_test.go b/pkg/api/provider_test.go index a068943..aa92075 100644 --- a/pkg/api/provider_test.go +++ b/pkg/api/provider_test.go @@ -3,14 +3,13 @@ package api_test import ( - "encoding/json" "net/http" "net/http/httptest" "testing" - goapi "forge.lthn.ai/core/api" process "dappco.re/go/core/process" processapi "dappco.re/go/core/process/pkg/api" + goapi "forge.lthn.ai/core/api" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -65,10 +64,8 @@ func TestProcessProvider_ListDaemons_Good(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) - var resp goapi.Response[[]any] - err := json.Unmarshal(w.Body.Bytes(), &resp) - require.NoError(t, err) - assert.True(t, resp.Success) + body := w.Body.String() + assert.NotEmpty(t, body) } func TestProcessProvider_GetDaemon_Bad(t *testing.T) { @@ -95,7 +92,7 @@ func TestProcessProvider_RegistersAsRouteGroup_Good(t *testing.T) { assert.Equal(t, "process", engine.Groups()[0].Name()) } -func TestProcessProvider_Channels_RegisterAsStreamGroup_Good(t *testing.T) { +func TestProcessProvider_StreamGroup_Good(t *testing.T) { p := processapi.NewProvider(nil, nil) engine, err := goapi.New() diff --git a/program.go b/program.go index 8ba96f0..97ce842 100644 --- a/program.go +++ b/program.go @@ -9,11 +9,13 @@ import ( ) // ErrProgramNotFound is returned when Find cannot locate the binary on PATH. -// Callers may use errors.Is to detect this condition. +// Callers may use core.Is to detect this condition. var ErrProgramNotFound = coreerr.E("", "program: binary not found in PATH", nil) // Program represents a named executable located on the system PATH. // Create one with a Name, call Find to resolve its path, then Run or RunDir. +// +// p := &process.Program{Name: "go"} type Program struct { // Name is the binary name (e.g. "go", "node", "git"). Name string @@ -24,6 +26,8 @@ type Program struct { // Find resolves the program's absolute path using exec.LookPath. // Returns ErrProgramNotFound (wrapped) if the binary is not on PATH. +// +// err := p.Find() func (p *Program) Find() error { if p.Name == "" { return coreerr.E("Program.Find", "program name is empty", nil) @@ -38,6 +42,8 @@ func (p *Program) Find() error { // Run executes the program with args in the current working directory. // Returns trimmed combined stdout+stderr output and any error. +// +// out, err := p.Run(ctx, "version") func (p *Program) Run(ctx context.Context, args ...string) (string, error) { return p.RunDir(ctx, "", args...) } @@ -45,6 +51,8 @@ func (p *Program) Run(ctx context.Context, args ...string) (string, error) { // RunDir executes the program with args in dir. // Returns trimmed combined stdout+stderr output and any error. // If dir is empty, the process inherits the caller's working directory. +// +// out, err := p.RunDir(ctx, "/workspace", "test", "./...") func (p *Program) RunDir(ctx context.Context, dir string, args ...string) (string, error) { binary := p.Path if binary == "" { diff --git a/program_test.go b/program_test.go index 5c729a3..67e6410 100644 --- a/program_test.go +++ b/program_test.go @@ -2,10 +2,11 @@ package process_test import ( "context" - "path/filepath" + "os" "testing" "time" + "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -19,25 +20,25 @@ func testCtx(t *testing.T) context.Context { return ctx } -func TestProgram_Find_Good_KnownBinary(t *testing.T) { +func TestProgram_Find_Good(t *testing.T) { p := &process.Program{Name: "echo"} require.NoError(t, p.Find()) assert.NotEmpty(t, p.Path) } -func TestProgram_Find_Bad_UnknownBinary(t *testing.T) { +func TestProgram_FindUnknown_Bad(t *testing.T) { p := &process.Program{Name: "no-such-binary-xyzzy-42"} err := p.Find() require.Error(t, err) assert.ErrorIs(t, err, process.ErrProgramNotFound) } -func TestProgram_Find_Bad_EmptyName(t *testing.T) { +func TestProgram_FindEmpty_Bad(t *testing.T) { p := &process.Program{} require.Error(t, p.Find()) } -func TestProgram_Run_Good_ReturnsOutput(t *testing.T) { +func TestProgram_Run_Good(t *testing.T) { p := &process.Program{Name: "echo"} require.NoError(t, p.Find()) @@ -46,7 +47,7 @@ func TestProgram_Run_Good_ReturnsOutput(t *testing.T) { assert.Equal(t, "hello", out) } -func TestProgram_Run_Good_FallsBackToName(t *testing.T) { +func TestProgram_RunFallback_Good(t *testing.T) { // Path is empty; RunDir should fall back to Name for OS PATH resolution. p := &process.Program{Name: "echo"} @@ -55,7 +56,7 @@ func TestProgram_Run_Good_FallsBackToName(t *testing.T) { assert.Equal(t, "fallback", out) } -func TestProgram_RunDir_Good_UsesDirectory(t *testing.T) { +func TestProgram_RunDir_Good(t *testing.T) { p := &process.Program{Name: "pwd"} require.NoError(t, p.Find()) @@ -63,15 +64,14 @@ func TestProgram_RunDir_Good_UsesDirectory(t *testing.T) { out, err := p.RunDir(testCtx(t), dir) require.NoError(t, err) - // Resolve symlinks on both sides for portability (macOS uses /private/ prefix). - canonicalDir, err := filepath.EvalSymlinks(dir) + dirInfo, err := os.Stat(dir) require.NoError(t, err) - canonicalOut, err := filepath.EvalSymlinks(out) + outInfo, err := os.Stat(core.Trim(out)) require.NoError(t, err) - assert.Equal(t, canonicalDir, canonicalOut) + assert.True(t, os.SameFile(dirInfo, outInfo)) } -func TestProgram_Run_Bad_FailingCommand(t *testing.T) { +func TestProgram_RunFailure_Bad(t *testing.T) { p := &process.Program{Name: "false"} require.NoError(t, p.Find()) diff --git a/registry.go b/registry.go index a6aa98a..8740a89 100644 --- a/registry.go +++ b/registry.go @@ -2,16 +2,18 @@ package process import ( "path" + "strconv" "syscall" "time" "dappco.re/go/core" coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" - "dappco.re/go/core/process/internal/jsonx" ) // DaemonEntry records a running daemon in the registry. +// +// entry := process.DaemonEntry{Code: "myapp", Daemon: "serve", PID: 1234} type DaemonEntry struct { Code string `json:"code"` Daemon string `json:"daemon"` @@ -23,16 +25,22 @@ type DaemonEntry struct { } // Registry tracks running daemons via JSON files in a directory. +// +// reg := process.NewRegistry("/tmp/process-daemons") type Registry struct { dir string } // NewRegistry creates a registry backed by the given directory. +// +// reg := process.NewRegistry("/tmp/process-daemons") func NewRegistry(dir string) *Registry { return &Registry{dir: dir} } // DefaultRegistry returns a registry using ~/.core/daemons/. +// +// reg := process.DefaultRegistry() func DefaultRegistry() *Registry { home, err := userHomeDir() if err != nil { @@ -53,12 +61,12 @@ func (r *Registry) Register(entry DaemonEntry) error { return coreerr.E("Registry.Register", "failed to create registry directory", err) } - data, err := jsonx.MarshalIndent(entry) + data, err := marshalDaemonEntry(entry) if err != nil { return coreerr.E("Registry.Register", "failed to marshal entry", err) } - if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), string(data)); err != nil { + if err := coreio.Local.Write(r.entryPath(entry.Code, entry.Daemon), data); err != nil { return coreerr.E("Registry.Register", "failed to write entry file", err) } return nil @@ -82,8 +90,8 @@ func (r *Registry) Get(code, daemon string) (*DaemonEntry, bool) { return nil, false } - var entry DaemonEntry - if err := jsonx.Unmarshal(data, &entry); err != nil { + entry, err := unmarshalDaemonEntry(data) + if err != nil { _ = coreio.Local.Delete(path) return nil, false } @@ -118,8 +126,8 @@ func (r *Registry) List() ([]DaemonEntry, error) { continue } - var entry DaemonEntry - if err := jsonx.Unmarshal(data, &entry); err != nil { + entry, err := unmarshalDaemonEntry(data) + if err != nil { _ = coreio.Local.Delete(path) continue } @@ -164,3 +172,245 @@ func sanitizeRegistryComponent(value string) string { } return string(buf) } + +func marshalDaemonEntry(entry DaemonEntry) (string, error) { + fields := []struct { + key string + value string + }{ + {key: "code", value: quoteJSONString(entry.Code)}, + {key: "daemon", value: quoteJSONString(entry.Daemon)}, + {key: "pid", value: strconv.Itoa(entry.PID)}, + } + + if entry.Health != "" { + fields = append(fields, struct { + key string + value string + }{key: "health", value: quoteJSONString(entry.Health)}) + } + if entry.Project != "" { + fields = append(fields, struct { + key string + value string + }{key: "project", value: quoteJSONString(entry.Project)}) + } + if entry.Binary != "" { + fields = append(fields, struct { + key string + value string + }{key: "binary", value: quoteJSONString(entry.Binary)}) + } + + fields = append(fields, struct { + key string + value string + }{ + key: "started", + value: quoteJSONString(entry.Started.Format(time.RFC3339Nano)), + }) + + builder := core.NewBuilder() + builder.WriteString("{\n") + for i, field := range fields { + builder.WriteString(core.Concat(" ", quoteJSONString(field.key), ": ", field.value)) + if i < len(fields)-1 { + builder.WriteString(",") + } + builder.WriteString("\n") + } + builder.WriteString("}") + return builder.String(), nil +} + +func unmarshalDaemonEntry(data string) (DaemonEntry, error) { + values, err := parseJSONObject(data) + if err != nil { + return DaemonEntry{}, err + } + + entry := DaemonEntry{ + Code: values["code"], + Daemon: values["daemon"], + Health: values["health"], + Project: values["project"], + Binary: values["binary"], + } + + pidValue, ok := values["pid"] + if !ok { + return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing pid", nil) + } + entry.PID, err = strconv.Atoi(pidValue) + if err != nil { + return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid pid", err) + } + + startedValue, ok := values["started"] + if !ok { + return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "missing started", nil) + } + entry.Started, err = time.Parse(time.RFC3339Nano, startedValue) + if err != nil { + return DaemonEntry{}, core.E("Registry.unmarshalDaemonEntry", "invalid started timestamp", err) + } + + return entry, nil +} + +func parseJSONObject(data string) (map[string]string, error) { + trimmed := core.Trim(data) + if trimmed == "" { + return nil, core.E("Registry.parseJSONObject", "empty JSON object", nil) + } + if trimmed[0] != '{' || trimmed[len(trimmed)-1] != '}' { + return nil, core.E("Registry.parseJSONObject", "invalid JSON object", nil) + } + + values := make(map[string]string) + index := skipJSONSpace(trimmed, 1) + for index < len(trimmed) { + if trimmed[index] == '}' { + return values, nil + } + + key, next, err := parseJSONString(trimmed, index) + if err != nil { + return nil, err + } + + index = skipJSONSpace(trimmed, next) + if index >= len(trimmed) || trimmed[index] != ':' { + return nil, core.E("Registry.parseJSONObject", "missing key separator", nil) + } + + index = skipJSONSpace(trimmed, index+1) + if index >= len(trimmed) { + return nil, core.E("Registry.parseJSONObject", "missing value", nil) + } + + var value string + if trimmed[index] == '"' { + value, index, err = parseJSONString(trimmed, index) + if err != nil { + return nil, err + } + } else { + start := index + for index < len(trimmed) && trimmed[index] != ',' && trimmed[index] != '}' { + index++ + } + value = core.Trim(trimmed[start:index]) + } + values[key] = value + + index = skipJSONSpace(trimmed, index) + if index >= len(trimmed) { + break + } + if trimmed[index] == ',' { + index = skipJSONSpace(trimmed, index+1) + continue + } + if trimmed[index] == '}' { + return values, nil + } + return nil, core.E("Registry.parseJSONObject", "invalid object separator", nil) + } + + return nil, core.E("Registry.parseJSONObject", "unterminated JSON object", nil) +} + +func parseJSONString(data string, start int) (string, int, error) { + if start >= len(data) || data[start] != '"' { + return "", 0, core.E("Registry.parseJSONString", "expected quoted string", nil) + } + + builder := core.NewBuilder() + for index := start + 1; index < len(data); index++ { + ch := data[index] + if ch == '"' { + return builder.String(), index + 1, nil + } + if ch != '\\' { + builder.WriteByte(ch) + continue + } + + index++ + if index >= len(data) { + return "", 0, core.E("Registry.parseJSONString", "unterminated escape sequence", nil) + } + + switch data[index] { + case '"', '\\', '/': + builder.WriteByte(data[index]) + case 'b': + builder.WriteByte('\b') + case 'f': + builder.WriteByte('\f') + case 'n': + builder.WriteByte('\n') + case 'r': + builder.WriteByte('\r') + case 't': + builder.WriteByte('\t') + case 'u': + if index+4 >= len(data) { + return "", 0, core.E("Registry.parseJSONString", "short unicode escape", nil) + } + r, err := strconv.ParseInt(data[index+1:index+5], 16, 32) + if err != nil { + return "", 0, core.E("Registry.parseJSONString", "invalid unicode escape", err) + } + builder.WriteRune(rune(r)) + index += 4 + default: + return "", 0, core.E("Registry.parseJSONString", "invalid escape sequence", nil) + } + } + + return "", 0, core.E("Registry.parseJSONString", "unterminated string", nil) +} + +func skipJSONSpace(data string, index int) int { + for index < len(data) { + switch data[index] { + case ' ', '\n', '\r', '\t': + index++ + default: + return index + } + } + return index +} + +func quoteJSONString(value string) string { + builder := core.NewBuilder() + builder.WriteByte('"') + for i := 0; i < len(value); i++ { + switch value[i] { + case '\\', '"': + builder.WriteByte('\\') + builder.WriteByte(value[i]) + case '\b': + builder.WriteString(`\b`) + case '\f': + builder.WriteString(`\f`) + case '\n': + builder.WriteString(`\n`) + case '\r': + builder.WriteString(`\r`) + case '\t': + builder.WriteString(`\t`) + default: + if value[i] < 0x20 { + builder.WriteString(core.Sprintf("\\u%04x", value[i])) + continue + } + builder.WriteByte(value[i]) + } + } + builder.WriteByte('"') + return builder.String() +} diff --git a/registry_test.go b/registry_test.go index 1f549ad..bf0883e 100644 --- a/registry_test.go +++ b/registry_test.go @@ -2,10 +2,10 @@ package process import ( "os" - "path/filepath" "testing" "time" + "dappco.re/go/core" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -53,7 +53,7 @@ func TestRegistry_Unregister_Good(t *testing.T) { require.NoError(t, err) // File should exist - path := filepath.Join(dir, "myapp-server.json") + path := core.JoinPath(dir, "myapp-server.json") _, err = os.Stat(path) require.NoError(t, err) @@ -79,7 +79,7 @@ func TestRegistry_List_Good(t *testing.T) { assert.Len(t, entries, 2) } -func TestRegistry_List_Good_PrunesStale(t *testing.T) { +func TestRegistry_PruneStale_Good(t *testing.T) { dir := t.TempDir() reg := NewRegistry(dir) @@ -87,7 +87,7 @@ func TestRegistry_List_Good_PrunesStale(t *testing.T) { require.NoError(t, err) // File should exist before listing - path := filepath.Join(dir, "dead-proc.json") + path := core.JoinPath(dir, "dead-proc.json") _, err = os.Stat(path) require.NoError(t, err) @@ -100,7 +100,7 @@ func TestRegistry_List_Good_PrunesStale(t *testing.T) { assert.True(t, os.IsNotExist(err)) } -func TestRegistry_Get_Bad_NotFound(t *testing.T) { +func TestRegistry_GetMissing_Bad(t *testing.T) { dir := t.TempDir() reg := NewRegistry(dir) @@ -109,8 +109,8 @@ func TestRegistry_Get_Bad_NotFound(t *testing.T) { assert.False(t, ok) } -func TestRegistry_Register_Good_CreatesDirectory(t *testing.T) { - dir := filepath.Join(t.TempDir(), "nested", "deep", "daemons") +func TestRegistry_CreateDirectory_Good(t *testing.T) { + dir := core.JoinPath(t.TempDir(), "nested", "deep", "daemons") reg := NewRegistry(dir) err := reg.Register(DaemonEntry{Code: "app", Daemon: "srv", PID: os.Getpid()}) @@ -121,7 +121,7 @@ func TestRegistry_Register_Good_CreatesDirectory(t *testing.T) { assert.True(t, info.IsDir()) } -func TestDefaultRegistry_Good(t *testing.T) { +func TestRegistry_Default_Good(t *testing.T) { reg := DefaultRegistry() assert.NotNil(t, reg) } diff --git a/runner_test.go b/runner_test.go index a54b061..efdde44 100644 --- a/runner_test.go +++ b/runner_test.go @@ -150,7 +150,7 @@ func TestRunner_RunAll_Good(t *testing.T) { }) } -func TestRunner_RunAll_Bad_CircularDeps(t *testing.T) { +func TestRunner_CircularDeps_Bad(t *testing.T) { t.Run("circular dependency counts as failed", func(t *testing.T) { runner := newTestRunner(t) diff --git a/service_test.go b/service_test.go index eb9b1e0..7bd12b8 100644 --- a/service_test.go +++ b/service_test.go @@ -2,7 +2,6 @@ package process import ( "context" - "strings" "sync" "testing" "time" @@ -78,7 +77,7 @@ func TestService_Start_Good(t *testing.T) { <-proc.Done() - output := strings.TrimSpace(proc.Output()) + output := framework.Trim(proc.Output()) assert.True(t, output == "/tmp" || output == "/private/tmp", "got: %s", output) }) @@ -213,7 +212,7 @@ func TestService_Actions_Good(t *testing.T) { assert.NotEmpty(t, outputs) foundTest := false for _, o := range outputs { - if strings.Contains(o.Line, "test") { + if framework.Contains(o.Line, "test") { foundTest = true break }