Implement self-update recovery (T-403)
This commit is contained in:
@@ -19,6 +19,12 @@ import (
|
||||
const applicationEventCapacity = 32
|
||||
|
||||
func main() {
|
||||
if handled, err := acknowledgeInternalUpdateHealth(os.Args[1:]); handled {
|
||||
if err != nil {
|
||||
log.Printf("%s internal update health failed: %v", core.ProductName, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
if err := run(); err != nil {
|
||||
log.Printf("%s Legacy stopped: %v", core.ProductName, err)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"softbox.local/app-win7/platform/windows"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func acknowledgeInternalUpdateHealth(arguments []string) (bool, error) {
|
||||
if len(arguments) == 0 || arguments[0] != updater.InternalHealthFlag {
|
||||
return false, nil
|
||||
}
|
||||
if len(arguments) != 2 {
|
||||
return true, fmt.Errorf("%s requires exactly one internal request ID", updater.InternalHealthFlag)
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("locate current executable: %w", err)
|
||||
}
|
||||
if err := updater.AcknowledgeHealthFromExecutable(executable, arguments[1], windows.New()); err != nil {
|
||||
return true, fmt.Errorf("acknowledge self-update health: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"softbox.local/app-win7/platform/windows"
|
||||
"softbox.local/core/updater"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(arguments []string) error {
|
||||
for _, option := range []string{"--pid", "--staging", "--target"} {
|
||||
if err := requireOneOption(arguments, option); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flags := flag.NewFlagSet("SoftBoxUpdater", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
pid := flags.Int("pid", 0, "main SoftBox PID")
|
||||
staging := flags.String("staging", "", "prepared staging directory")
|
||||
target := flags.String("target", "", "fixed app target directory")
|
||||
if err := flags.Parse(arguments); err != nil {
|
||||
return fmt.Errorf("parse updater arguments: %w", err)
|
||||
}
|
||||
if flags.NArg() != 0 || *pid <= 0 || *staging == "" || *target == "" || !filepath.IsAbs(*staging) || !filepath.IsAbs(*target) {
|
||||
return fmt.Errorf("usage: SoftBoxUpdater --pid <positive PID> --staging <absolute staging directory> --target <absolute root/app>")
|
||||
}
|
||||
requestID := filepath.Base(filepath.Clean(*staging))
|
||||
platform := windows.New()
|
||||
service := updater.NewService(platform, platform, platform, updater.FileHealthWaiter{}, updater.Timeouts{
|
||||
ParentExit: 2 * time.Minute,
|
||||
Health: 45 * time.Second,
|
||||
})
|
||||
return service.Update(context.Background(), updater.Request{
|
||||
ParentPID: *pid, StagingDir: *staging, TargetDir: *target, RequestID: requestID,
|
||||
})
|
||||
}
|
||||
|
||||
func requireOneOption(arguments []string, option string) error {
|
||||
count := 0
|
||||
for _, argument := range arguments {
|
||||
if argument == option || strings.HasPrefix(argument, option+"=") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
if count != 1 {
|
||||
return fmt.Errorf("%s must appear exactly once", option)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRunRejectsIncompleteAndDuplicateArguments(t *testing.T) {
|
||||
if err := run(nil); err == nil {
|
||||
t.Fatal("run(nil) succeeded")
|
||||
}
|
||||
if err := run([]string{
|
||||
"--pid", "1", "--pid", "2", "--staging", "/root/staging/update-1234", "--target", "/root/app",
|
||||
}); err == nil {
|
||||
t.Fatal("run() accepted duplicate --pid")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
Reference in New Issue
Block a user