Implement app update orchestration (T-402)
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
@@ -22,6 +24,7 @@ type Platform interface {
|
||||
Edition() Edition
|
||||
IsCompatible(minOS string) (bool, error)
|
||||
IsRunning(appID, entrypoint string) (bool, error)
|
||||
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
|
||||
Start(command launch.Command) (int, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
@@ -30,6 +32,10 @@ func (platformStub) IsRunning(string, string) (bool, error) {
|
||||
return false, ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) WaitForExit(context.Context, string, string, time.Duration) error {
|
||||
return ErrUnsupported
|
||||
}
|
||||
|
||||
func (platformStub) Start(launch.Command) (int, error) {
|
||||
return 0, ErrUnsupported
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/launch"
|
||||
)
|
||||
@@ -17,6 +19,9 @@ func TestPlatformStubFailsClosed(t *testing.T) {
|
||||
if _, err := platform.IsCompatible("windows-10"); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("IsCompatible() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if err := platform.WaitForExit(context.Background(), "test-app", "C:/test/App.exe", time.Second); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("WaitForExit() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
|
||||
t.Fatalf("Start() error = %v, want ErrUnsupported", err)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPlatformStubContract(t *testing.T) {
|
||||
@@ -78,6 +80,71 @@ func TestVersionSupports(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForExit(t *testing.T) {
|
||||
initial := time.Date(2026, 7, 19, 0, 0, 0, 0, time.UTC)
|
||||
errSnapshot := errors.New("snapshot failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
running []bool
|
||||
runErr error
|
||||
timeout time.Duration
|
||||
wantErr error
|
||||
wantSleeps int
|
||||
}{
|
||||
{name: "already stopped", ctx: context.Background(), running: []bool{false}, timeout: time.Second},
|
||||
{name: "stops after one poll", ctx: context.Background(), running: []bool{true, false}, timeout: time.Second, wantSleeps: 1},
|
||||
{name: "timeout", ctx: context.Background(), running: []bool{true, true, true, true, true}, timeout: time.Second, wantErr: context.DeadlineExceeded, wantSleeps: 4},
|
||||
{name: "snapshot failure", ctx: context.Background(), runErr: errSnapshot, timeout: time.Second, wantErr: errSnapshot},
|
||||
{name: "canceled", ctx: canceledWaitContext(), running: []bool{true}, timeout: time.Second, wantErr: context.Canceled},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
clock := &fakeExitWaitClock{now: initial}
|
||||
index := 0
|
||||
err := waitForExit(test.ctx, test.timeout, func() (bool, error) {
|
||||
if test.runErr != nil {
|
||||
return false, test.runErr
|
||||
}
|
||||
if index >= len(test.running) {
|
||||
return test.running[len(test.running)-1], nil
|
||||
}
|
||||
running := test.running[index]
|
||||
index++
|
||||
return running, nil
|
||||
}, clock)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("waitForExit() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if clock.sleeps != test.wantSleeps {
|
||||
t.Fatalf("sleeps = %d, want %d", clock.sleeps, test.wantSleeps)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func canceledWaitContext() context.Context {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
return ctx
|
||||
}
|
||||
|
||||
type fakeExitWaitClock struct {
|
||||
now time.Time
|
||||
sleeps int
|
||||
}
|
||||
|
||||
func (clock *fakeExitWaitClock) Now() time.Time { return clock.now }
|
||||
|
||||
func (clock *fakeExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
clock.sleeps++
|
||||
clock.now = clock.now.Add(duration)
|
||||
return nil
|
||||
}
|
||||
|
||||
type snapshotItem struct {
|
||||
path string
|
||||
err error
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
@@ -49,6 +51,12 @@ func (platform) IsRunning(_ string, entrypoint string) (bool, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func (target platform) WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||
return waitForExit(ctx, timeout, func() (bool, error) {
|
||||
return target.IsRunning(appID, entrypoint)
|
||||
}, systemExitWaitClock{})
|
||||
}
|
||||
|
||||
func (platform) Start(command launch.Command) (int, error) {
|
||||
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
|
||||
return 0, fmt.Errorf("launch command must contain absolute paths")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package windows
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const exitPollInterval = 250 * time.Millisecond
|
||||
|
||||
type exitWaitClock interface {
|
||||
Now() time.Time
|
||||
Wait(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
type systemExitWaitClock struct{}
|
||||
|
||||
func (systemExitWaitClock) Now() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (systemExitWaitClock) Wait(ctx context.Context, duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitForExit(ctx context.Context, timeout time.Duration, running func() (bool, error), clock exitWaitClock) error {
|
||||
if ctx == nil {
|
||||
return fmt.Errorf("wait context is required")
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return fmt.Errorf("exit timeout must be positive")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := clock.Now().Add(timeout)
|
||||
for {
|
||||
isRunning, err := running()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isRunning {
|
||||
return nil
|
||||
}
|
||||
remaining := deadline.Sub(clock.Now())
|
||||
if remaining <= 0 {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
interval := exitPollInterval
|
||||
if remaining < interval {
|
||||
interval = remaining
|
||||
}
|
||||
if err := clock.Wait(ctx, interval); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user