Implement app update orchestration (T-402)

This commit is contained in:
ila
2026-07-19 21:39:33 +08:00
parent fda52a57ca
commit 339beaa9b3
19 changed files with 1095 additions and 11 deletions
+3
View File
@@ -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")
+64
View File
@@ -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
}
}
}