From 85cd6dd7c81ce8ef0a991d604b4efcee5e123d05 Mon Sep 17 00:00:00 2001 From: Virgil Date: Sat, 4 Apr 2026 03:33:29 +0000 Subject: [PATCH] feat(process): add wait task surface Co-authored-by: Virgil --- actions.go | 10 +++++++++ global_test.go | 20 ++++++++++++++++++ process_global.go | 13 ++++++++++++ service.go | 29 +++++++++++++++++++++++++ service_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+) diff --git a/actions.go b/actions.go index ecc7239..1ca89c5 100644 --- a/actions.go +++ b/actions.go @@ -89,6 +89,16 @@ type TaskProcessGet struct { ID string } +// TaskProcessWait waits for a managed process to finish through Core.PERFORM. +// +// Example: +// +// c.PERFORM(process.TaskProcessWait{ID: "proc-1"}) +type TaskProcessWait struct { + // ID identifies a managed process started by this service. + ID string +} + // TaskProcessOutput requests the captured output of a managed process through Core.PERFORM. // // Example: diff --git a/global_test.go b/global_test.go index 8abd570..55f88d3 100644 --- a/global_test.go +++ b/global_test.go @@ -273,6 +273,26 @@ func TestGlobal_Output(t *testing.T) { assert.Contains(t, output, "global-output") } +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) diff --git a/process_global.go b/process_global.go index 0f76f71..bde259a 100644 --- a/process_global.go +++ b/process_global.go @@ -114,6 +114,19 @@ func Output(id string) (string, error) { return svc.Output(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: diff --git a/service.go b/service.go index 5b3ae31..1c3c5b4 100644 --- a/service.go +++ b/service.go @@ -529,6 +529,24 @@ func (s *Service) Output(id string) (string, error) { return proc.Output(), nil } +// Wait blocks until a managed process exits and returns its final snapshot. +// +// Example: +// +// info, err := svc.Wait("proc-1") +func (s *Service) Wait(id string) (Info, error) { + proc, err := s.Get(id) + if err != nil { + return Info{}, err + } + + if err := proc.Wait(); err != nil { + return proc.Info(), err + } + + return proc.Info(), nil +} + // findByPID locates a managed process by operating-system PID. func (s *Service) findByPID(pid int) *Process { s.mu.RLock() @@ -672,6 +690,17 @@ func (s *Service) handleTask(c *core.Core, task core.Task) core.Result { } return core.Result{Value: proc.Info(), OK: true} + case TaskProcessWait: + if m.ID == "" { + return core.Result{Value: coreerr.E("Service.handleTask", "task process wait requires an id", nil), OK: false} + } + + info, err := s.Wait(m.ID) + if err != nil { + return core.Result{Value: err, OK: false} + } + + return core.Result{Value: info, OK: true} case TaskProcessOutput: if m.ID == "" { return core.Result{Value: coreerr.E("Service.handleTask", "task process output requires an id", nil), OK: false} diff --git a/service_test.go b/service_test.go index 46c0985..3390e71 100644 --- a/service_test.go +++ b/service_test.go @@ -597,6 +597,41 @@ func TestService_Output(t *testing.T) { }) } +func TestService_Wait(t *testing.T) { + t.Run("returns final info on success", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "echo", "waited") + require.NoError(t, err) + + info, err := svc.Wait(proc.ID) + require.NoError(t, err) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 0, info.ExitCode) + }) + + t.Run("returns error on unknown id", func(t *testing.T) { + svc, _ := newTestService(t) + + _, err := svc.Wait("nonexistent") + assert.ErrorIs(t, err, ErrProcessNotFound) + }) + + t.Run("returns info alongside failure", func(t *testing.T) { + svc, _ := newTestService(t) + + proc, err := svc.Start(context.Background(), "sh", "-c", "exit 7") + require.NoError(t, err) + + info, err := svc.Wait(proc.ID) + require.Error(t, err) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 7, info.ExitCode) + }) +} + func TestService_OnShutdown(t *testing.T) { t.Run("kills all running processes", func(t *testing.T) { svc, _ := newTestService(t) @@ -770,6 +805,25 @@ func TestService_OnStartup(t *testing.T) { assert.Equal(t, StatusKilled, proc.Status) }) + t.Run("registers process.wait task", func(t *testing.T) { + svc, c := newTestService(t) + + err := svc.OnStartup(context.Background()) + require.NoError(t, err) + + proc, err := svc.Start(context.Background(), "echo", "action-wait") + require.NoError(t, err) + + result := c.PERFORM(TaskProcessWait{ID: proc.ID}) + require.True(t, result.OK) + + info, ok := result.Value.(Info) + require.True(t, ok) + assert.Equal(t, proc.ID, info.ID) + assert.Equal(t, StatusExited, info.Status) + assert.Equal(t, 0, info.ExitCode) + }) + t.Run("registers process.list task", func(t *testing.T) { svc, c := newTestService(t)