返回 DeepSeek-Reasonix
host_rpc_test.go
根目录 / desktop / host_rpc_test.go
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
200 lines GO