| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | goruntime "runtime" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "testing" |
| 13 | "time" |
| 14 | |
| 15 | "reasonix/desktop/internal/hostrpc" |
| 16 | "reasonix/internal/config" |
| 17 | "reasonix/internal/control" |
| 18 | "reasonix/internal/extension/rpcwire" |
| 19 | ) |
| 20 | |
| 21 | // hostRPCShell runs runHostRPC over pipes and returns the fake shell, the |
| 22 | // shell's end of the service stdin, and a waiter for the exit code. |
| 23 | func hostRPCShell(t *testing.T) (*rpcwire.Conn, *io.PipeWriter, func() (int, bool)) { |
| 24 | t.Helper() |
| 25 | previous := runtimeEventsEmitFallback |
| 26 | stdinR, stdinW := io.Pipe() |
| 27 | stdoutR, stdoutW := io.Pipe() |
| 28 | exit := make(chan int, 1) |
| 29 | go func() { exit <- runHostRPC(NewApp(), stdinR, stdoutW) }() |
| 30 | shell := rpcwire.NewConn(stdoutR, stdinW, rpcwire.Options{StrictJSONRPC: true, Name: "shell"}) |
| 31 | go func() { _ = shell.Serve(t.Context()) }() |
| 32 | var once sync.Once |
| 33 | code, returned := 0, false |
| 34 | wait := func() (int, bool) { |
| 35 | once.Do(func() { |
| 36 | select { |
| 37 | case code = <-exit: |
| 38 | returned = true |
| 39 | case <-time.After(10 * time.Second): |
| 40 | } |
| 41 | }) |
| 42 | return code, returned |
| 43 | } |
| 44 | t.Cleanup(func() { |
| 45 | stdinW.Close() |
| 46 | stdoutW.Close() |
| 47 | if _, ok := wait(); !ok { |
| 48 | t.Error("runHostRPC did not return after the pipes closed") |
| 49 | } |
| 50 | runtimeEventsEmitFallback = previous |
| 51 | }) |
| 52 | return shell, stdinW, wait |
| 53 | } |
| 54 | |
| 55 | func hostRPCHello(t *testing.T) hostrpc.HelloParams { |
| 56 | t.Helper() |
| 57 | registry, err := newDesktopRegistry((*App)(nil)) |
| 58 | if err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | return hostrpc.HelloParams{ |
| 62 | ProtocolVersion: hostrpc.ProtocolVersion, |
| 63 | ContractDigest: hostrpc.Build(registry, hostEventNames).Digest(), |
| 64 | Build: hostrpc.BuildInfo{Version: version, Channel: channel}, |
| 65 | Host: hostrpc.HostInfo{Name: "electron", Platform: goruntime.GOOS}, |
| 66 | Instance: hostrpc.HelloInstance{Home: config.ReasonixHomeDir()}, |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func invokeThroughShell(t *testing.T, shell *rpcwire.Conn, method string, args ...any) (json.RawMessage, error) { |
| 71 | t.Helper() |
| 72 | if args == nil { |
| 73 | args = []any{} |
| 74 | } |
| 75 | return shell.Request(t.Context(), "desktop/invoke", map[string]any{"method": method, "args": args}) |
| 76 | } |
| 77 | |
| 78 | func TestHostRPCHelloThenInvoke(t *testing.T) { |
| 79 | shell, _, _ := hostRPCShell(t) |
| 80 | ctx := t.Context() |
| 81 | |
| 82 | _, err := invokeThroughShell(t, shell, "Platform") |
| 83 | var re *rpcwire.ResponseError |
| 84 | if !errors.As(err, &re) || re.Code != hostrpc.CodeNotReady { |
| 85 | t.Fatalf("invoke before hello = %v, want code %d", err, hostrpc.CodeNotReady) |
| 86 | } |
| 87 | |
| 88 | raw, err := shell.Request(ctx, "desktop/hello", hostRPCHello(t)) |
| 89 | if err != nil { |
| 90 | t.Fatalf("hello: %v", err) |
| 91 | } |
| 92 | var hello hostrpc.HelloResult |
| 93 | if err := json.Unmarshal(raw, &hello); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | if !strings.HasPrefix(hello.RuntimeGeneration, "g-") || len(hello.RuntimeGeneration) != len("g-")+16 { |
| 97 | t.Fatalf("runtimeGeneration = %q", hello.RuntimeGeneration) |
| 98 | } |
| 99 | if !strings.HasPrefix(hello.Resources.Origin, "http://127.0.0.1:") || len(hello.Resources.Token) != 64 { |
| 100 | t.Fatalf("resources = %+v", hello.Resources) |
| 101 | } |
| 102 | if hello.Window == nil || hello.Window.MinWidth != desktopWindowMinWidth || hello.Window.Width <= 0 || hello.Window.ZoomFactor <= 0 { |
| 103 | t.Fatalf("window = %+v", hello.Window) |
| 104 | } |
| 105 | if hello.Service.Version != version || hello.Service.Channel != channel || hello.Service.PID <= 0 { |
| 106 | t.Fatalf("service = %+v", hello.Service) |
| 107 | } |
| 108 | |
| 109 | platform, err := invokeThroughShell(t, shell, "Platform") |
| 110 | if want, _ := json.Marshal(goruntime.GOOS); err != nil || string(platform) != string(want) { |
| 111 | t.Fatalf("Platform = %s, %v", platform, err) |
| 112 | } |
| 113 | ver, err := invokeThroughShell(t, shell, "Version") |
| 114 | if want, _ := json.Marshal(version); err != nil || string(ver) != string(want) { |
| 115 | t.Fatalf("Version = %s, %v", ver, err) |
| 116 | } |
| 117 | _, err = invokeThroughShell(t, shell, "NoSuchMethod") |
| 118 | if !errors.As(err, &re) || re.Code != rpcwire.ErrMethodNotFound { |
| 119 | t.Fatalf("unknown method = %v", err) |
| 120 | } |
| 121 | |
| 122 | assertResourceStatus(t, hello.Resources.Origin+"/nope", "", http.StatusUnauthorized) |
| 123 | assertResourceStatus(t, hello.Resources.Origin+"/nope", hello.Resources.Token, http.StatusNotFound) |
| 124 | } |
| 125 | |
| 126 | func TestHostRPCReturnsWhenStdinCloses(t *testing.T) { |
| 127 | shell, stdinW, wait := hostRPCShell(t) |
| 128 | if _, err := shell.Request(t.Context(), "desktop/hello", hostRPCHello(t)); err != nil { |
| 129 | t.Fatalf("hello: %v", err) |
| 130 | } |
| 131 | stdinW.Close() |
| 132 | code, returned := wait() |
| 133 | if !returned { |
| 134 | t.Fatal("runHostRPC did not return after stdin closed") |
| 135 | } |
| 136 | if code != 0 { |
| 137 | t.Fatalf("exit code = %d", code) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | func TestDetachedShutdownTimeoutLeavesInterruptedEvidence(t *testing.T) { |
| 142 | isolateDesktopUserDirs(t) |
| 143 | release := make(chan struct{}) |
| 144 | ctrl := &shutdownSnapshotController{SessionAPI: control.New(control.Options{Label: "blocked"})} |
| 145 | ctrl.shutdown = func() error { |
| 146 | <-release |
| 147 | return nil |
| 148 | } |
| 149 | app := NewApp() |
| 150 | app.tabs["blocked"] = &WorkspaceTab{ID: "blocked", Ctrl: ctrl} |
| 151 | app.tabOrder = []string{"blocked"} |
| 152 | tracker := lifecycleTrackerForTest(t, t.TempDir(), 4242, "detached-timeout") |
| 153 | if err := tracker.start(); err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | app.lifecycle.tracker = tracker |
| 157 | |
| 158 | status, err := requestDetachedShutdown(app, shutdownRequest{ |
| 159 | RequestID: "detached-timeout", Reason: shutdownReasonConnectionLost, |
| 160 | }, 250*time.Millisecond) |
| 161 | if !errors.Is(err, context.DeadlineExceeded) || status.Phase != "saving" { |
| 162 | t.Fatalf("detached shutdown = %+v, %v", status, err) |
| 163 | } |
| 164 | state, readErr := readDesktopLifecycleState(tracker.path) |
| 165 | if readErr != nil { |
| 166 | t.Fatal(readErr) |
| 167 | } |
| 168 | if state.TerminationReason != shutdownReasonConnectionLost || state.CleanupOutcome != "interrupted" || state.Phase != "saving" { |
| 169 | t.Fatalf("timeout evidence = %+v", state) |
| 170 | } |
| 171 | |
| 172 | close(release) |
| 173 | deadline := time.Now().Add(5 * time.Second) |
| 174 | for !app.shutdownStatus("").Completed { |
| 175 | if time.Now().After(deadline) { |
| 176 | t.Fatal("detached shutdown did not finish after the blocked save was released") |
| 177 | } |
| 178 | time.Sleep(time.Millisecond) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | func assertResourceStatus(t *testing.T, url, token string, want int) { |
| 183 | t.Helper() |
| 184 | req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) |
| 185 | if err != nil { |
| 186 | t.Fatal(err) |
| 187 | } |
| 188 | if token != "" { |
| 189 | req.Header.Set("Authorization", "Bearer "+token) |
| 190 | } |
| 191 | resp, err := http.DefaultClient.Do(req) |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | resp.Body.Close() |
| 196 | if resp.StatusCode != want { |
| 197 | t.Fatalf("GET %s (token=%v) = %d, want %d", url, token != "", resp.StatusCode, want) |
| 198 | } |
| 199 | } |
| 200 |