Implement self-update recovery (T-403)

This commit is contained in:
ila
2026-07-19 22:00:38 +08:00
parent 2900deba4f
commit 13004218cf
35 changed files with 1754 additions and 16 deletions
+4
View File
@@ -6,6 +6,7 @@ import (
"time"
"softbox.local/core/application/launch"
"softbox.local/core/updater"
)
// Edition identifies the application build channel shown by the UI.
@@ -26,6 +27,9 @@ type Platform interface {
IsRunning(appID, entrypoint string) (bool, error)
WaitForExit(ctx context.Context, appID, entrypoint string, timeout time.Duration) error
Start(command launch.Command) (int, error)
WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error
StartSelfUpdate(command updater.StartCommand) (int, error)
SyncDirectory(path string) error
}
// New returns the platform implementation selected by build tags.
@@ -8,6 +8,7 @@ import (
"time"
"softbox.local/core/application/launch"
"softbox.local/core/updater"
)
type platformStub struct{}
@@ -39,3 +40,15 @@ func (platformStub) WaitForExit(context.Context, string, string, time.Duration)
func (platformStub) Start(launch.Command) (int, error) {
return 0, ErrUnsupported
}
func (platformStub) WaitForProcessExit(context.Context, int, time.Duration) error {
return ErrUnsupported
}
func (platformStub) StartSelfUpdate(updater.StartCommand) (int, error) {
return 0, ErrUnsupported
}
func (platformStub) SyncDirectory(string) error {
return ErrUnsupported
}
@@ -9,6 +9,7 @@ import (
"time"
"softbox.local/core/application/launch"
"softbox.local/core/updater"
)
func TestPlatformStubFailsClosed(t *testing.T) {
@@ -25,4 +26,13 @@ func TestPlatformStubFailsClosed(t *testing.T) {
if _, err := platform.Start(launch.Command{}); !errors.Is(err, ErrUnsupported) {
t.Fatalf("Start() error = %v, want ErrUnsupported", err)
}
if err := platform.WaitForProcessExit(context.Background(), 1, time.Second); !errors.Is(err, ErrUnsupported) {
t.Fatalf("WaitForProcessExit() error = %v, want ErrUnsupported", err)
}
if _, err := platform.StartSelfUpdate(updater.StartCommand{}); !errors.Is(err, ErrUnsupported) {
t.Fatalf("StartSelfUpdate() error = %v, want ErrUnsupported", err)
}
if err := platform.SyncDirectory("/tmp"); !errors.Is(err, ErrUnsupported) {
t.Fatalf("SyncDirectory() error = %v, want ErrUnsupported", err)
}
}
+49
View File
@@ -0,0 +1,49 @@
package windows
import (
"context"
"fmt"
"time"
)
type pidWaitHandle interface {
Wait(time.Duration) (bool, error)
Close() error
}
type pidOpener func(int) (pidWaitHandle, error)
func waitForProcessExit(ctx context.Context, pid int, timeout time.Duration, open pidOpener) error {
if pid <= 0 {
return fmt.Errorf("process PID must be positive")
}
if timeout <= 0 {
return fmt.Errorf("process wait timeout must be positive")
}
handle, err := open(pid)
if err != nil {
return fmt.Errorf("open process %d: %w", pid, err)
}
defer handle.Close()
deadline := time.NewTimer(timeout)
defer deadline.Stop()
for {
if err := ctx.Err(); err != nil {
return err
}
exited, err := handle.Wait(250 * time.Millisecond)
if err != nil {
return fmt.Errorf("wait for process %d: %w", pid, err)
}
if exited {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-deadline.C:
return context.DeadlineExceeded
default:
}
}
}
@@ -0,0 +1,49 @@
package windows
import (
"context"
"errors"
"testing"
"time"
)
type fakePIDHandle struct {
exited bool
waitErr error
closed bool
}
func (handle *fakePIDHandle) Wait(time.Duration) (bool, error) { return handle.exited, handle.waitErr }
func (handle *fakePIDHandle) Close() error { handle.closed = true; return nil }
func TestWaitForProcessExitReturnsOnlyWhenHandleSignals(t *testing.T) {
handle := &fakePIDHandle{exited: true}
err := waitForProcessExit(context.Background(), 9, time.Second, func(pid int) (pidWaitHandle, error) {
if pid != 9 {
t.Fatalf("PID = %d, want 9", pid)
}
return handle, nil
})
if err != nil || !handle.closed {
t.Fatalf("wait error = %v, closed = %v", err, handle.closed)
}
}
func TestWaitForProcessExitPropagatesOpenWaitCancelAndTimeout(t *testing.T) {
openErr := errors.New("access denied")
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return nil, openErr }); !errors.Is(err, openErr) {
t.Fatalf("open error = %v", err)
}
waitErr := errors.New("wait failed")
if err := waitForProcessExit(context.Background(), 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{waitErr: waitErr}, nil }); !errors.Is(err, waitErr) {
t.Fatalf("wait error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := waitForProcessExit(ctx, 3, time.Second, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.Canceled) {
t.Fatalf("cancel error = %v", err)
}
if err := waitForProcessExit(context.Background(), 3, time.Millisecond, func(int) (pidWaitHandle, error) { return &fakePIDHandle{}, nil }); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("timeout error = %v", err)
}
}
@@ -0,0 +1,88 @@
//go:build windows
package windows
import (
"context"
"fmt"
"os/exec"
"path/filepath"
"syscall"
"time"
"golang.org/x/sys/windows"
"softbox.local/core/updater"
)
func (platform) WaitForProcessExit(ctx context.Context, pid int, timeout time.Duration) error {
return waitForProcessExit(ctx, pid, timeout, openWindowsProcess)
}
func (platform) StartSelfUpdate(command updater.StartCommand) (int, error) {
if !filepath.IsAbs(command.Entrypoint) || !filepath.IsAbs(command.WorkingDirectory) {
return 0, fmt.Errorf("self-update launch paths must be absolute")
}
if filepath.Base(command.Entrypoint) != updater.ProductExecutableName ||
filepath.Dir(command.Entrypoint) != filepath.Clean(command.WorkingDirectory) ||
command.HealthRequestID == "" {
return 0, fmt.Errorf("invalid fixed self-update launch command")
}
commandLine := exec.Command(command.Entrypoint, updater.InternalHealthFlag, command.HealthRequestID)
commandLine.Dir = command.WorkingDirectory
if err := commandLine.Start(); err != nil {
return 0, err
}
return commandLine.Process.Pid, nil
}
func (platform) SyncDirectory(path string) error {
pathPointer, err := syscall.UTF16PtrFromString(path)
if err != nil {
return fmt.Errorf("encode directory path: %w", err)
}
handle, err := syscall.CreateFile(pathPointer, syscall.GENERIC_READ|syscall.GENERIC_WRITE,
syscall.FILE_SHARE_READ|syscall.FILE_SHARE_WRITE|syscall.FILE_SHARE_DELETE, nil,
syscall.OPEN_EXISTING, syscall.FILE_FLAG_BACKUP_SEMANTICS, 0)
if err != nil {
return fmt.Errorf("open directory handle: %w", err)
}
if err := syscall.FlushFileBuffers(handle); err != nil {
_ = syscall.CloseHandle(handle)
return fmt.Errorf("flush directory handle: %w", err)
}
if err := syscall.CloseHandle(handle); err != nil {
return fmt.Errorf("close directory handle: %w", err)
}
return nil
}
type windowsPIDHandle struct{ handle windows.Handle }
func openWindowsProcess(pid int) (pidWaitHandle, error) {
handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid))
if err != nil {
return nil, err
}
return windowsPIDHandle{handle: handle}, nil
}
func (handle windowsPIDHandle) Wait(timeout time.Duration) (bool, error) {
milliseconds := uint32(timeout / time.Millisecond)
if milliseconds == 0 {
milliseconds = 1
}
result, err := windows.WaitForSingleObject(handle.handle, milliseconds)
if err != nil {
return false, err
}
switch result {
case windows.WAIT_OBJECT_0:
return true, nil
case uint32(windows.WAIT_TIMEOUT):
return false, nil
default:
return false, fmt.Errorf("WaitForSingleObject returned %d", result)
}
}
func (handle windowsPIDHandle) Close() error { return windows.CloseHandle(handle.handle) }