返回 DeepSeek-Reasonix
host_rpc.go
根目录 / desktop / host_rpc.go
1 package main
2
3 import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "crypto/subtle"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "io"
13 "log/slog"
14 "net"
15 "net/http"
16 "os"
17 "os/signal"
18 "path/filepath"
19 "runtime/debug"
20 "slices"
21 "strings"
22 "syscall"
23 "time"
24
25 "reasonix/desktop/internal/hostrpc"
26 "reasonix/desktop/internal/instanceidentity"
27 "reasonix/internal/config"
28 "reasonix/internal/extension/rpcwire"
29 )
30
31 const (
32 hostRPCFlag = "--host-rpc"
33 emitContractFlag = "-emit-contract"
34 contractTSFile = "desktopContract.generated.ts"
35 contractJSONFile = "desktopContract.generated.json"
36
37 desktopWindowMinWidth = 760
38 desktopWindowMinHeight = 480
39 hostDetachedShutdownTimeout = 15 * time.Second
40 )
41
42 type detachedShutdownResult struct {
43 status shutdownStatus
44 err error
45 }
46
47 func hostRPCRequested(args []string) bool { return slices.Contains(args, hostRPCFlag) }
48
49 // exitIfHostLaunchMode runs -emit-contract or --host-rpc and exits with its
50 // code; any other launch returns to the caller.
51 func exitIfHostLaunchMode(args []string) {
52 if dir, ok := emitContractDir(args); ok {
53 os.Exit(runEmitContract(dir))
54 }
55 if hostRPCRequested(args) {
56 os.Exit(runHostRPC(NewApp(), os.Stdin, os.Stdout))
57 }
58 }
59
60 // emitContractDir returns the directory named by -emit-contract <dir> or
61 // -emit-contract=<dir>; a leading double dash is accepted too.
62 func emitContractDir(args []string) (string, bool) {
63 for i, arg := range args {
64 name := "-" + strings.TrimLeft(arg, "-")
65 if name == emitContractFlag && i+1 < len(args) {
66 return args[i+1], true
67 }
68 if dir, ok := strings.CutPrefix(name, emitContractFlag+"="); ok && dir != "" {
69 return dir, true
70 }
71 }
72 return "", false
73 }
74
75 // emitContract writes the TypeScript and JSON contract files into dir from
76 // the App type alone; no App instance or runtime is constructed.
77 func emitContract(dir string) error {
78 return emitHostContract(dir, dir)
79 }
80
81 func emitHostContract(dir, ownersDir string) error {
82 owners, err := hostrpc.SourceOwnership(".", "App")
83 if err != nil {
84 return err
85 }
86 registry, err := hostrpc.NewRegistryWithOwners((*App)(nil), nil, owners)
87 if err != nil {
88 return err
89 }
90 contract := hostrpc.Build(registry, hostEventNames)
91 if err := os.MkdirAll(dir, 0o755); err != nil {
92 return err
93 }
94 var ts, js bytes.Buffer
95 if err := hostrpc.WriteTypeScript(&ts, contract); err != nil {
96 return err
97 }
98 if err := hostrpc.WriteJSON(&js, contract); err != nil {
99 return err
100 }
101 ownerJSON, err := json.MarshalIndent(owners, "", " ")
102 if err != nil {
103 return err
104 }
105 if err := os.WriteFile(filepath.Join(ownersDir, hostCommandOwnersFile), append(ownerJSON, '\n'), 0o644); err != nil {
106 return err
107 }
108 if err := os.WriteFile(filepath.Join(dir, contractTSFile), ts.Bytes(), 0o644); err != nil {
109 return err
110 }
111 return os.WriteFile(filepath.Join(dir, contractJSONFile), js.Bytes(), 0o644)
112 }
113
114 func runEmitContract(dir string) int {
115 if err := emitHostContract(dir, "."); err != nil {
116 fmt.Fprintln(os.Stderr, "emit-contract:", err)
117 return 1
118 }
119 return 0
120 }
121
122 // runHostRPC serves the desktop host protocol over stdin/stdout until the
123 // shell closes stdin or acknowledges desktop/shutdown. It returns the
124 // process exit code.
125 func runHostRPC(app *App, stdin io.Reader, stdout io.Writer) int {
126 stopEndpoint, err := startUpdateEndpoint()
127 if err != nil {
128 slog.Error("desktop host: update endpoint", "err", err)
129 return 2
130 }
131 defer stopEndpoint()
132 registry, err := newDesktopRegistry(app)
133 if err != nil {
134 slog.Error("desktop host: contract registry", "err", err)
135 return 2
136 }
137 // Lifecycle evidence and the probationary-update identity are claimed
138 // before any request, before the shell connects.
139 prepareDesktopDiagnostics(app)
140 capturePendingUpdateHealthIdentity(app)
141 defer app.releaseDesktopDiagnosticsOwnership()
142 shutdownAttempted := false
143 defer func() {
144 if shutdownAttempted || app.shutdownStatus("").Completed {
145 return
146 }
147 status, shutdownErr := requestDetachedShutdown(app, shutdownRequest{
148 RequestID: newDesktopLifecycleRunID(), Reason: shutdownReasonStartupFailure,
149 }, hostDetachedShutdownTimeout)
150 if shutdownErr != nil || !status.Completed {
151 slog.Error("desktop host: startup-failure cleanup failed", "err", shutdownErr, "phase", status.Phase)
152 }
153 }()
154 generation, err := randomHex(8)
155 if err != nil {
156 slog.Error("desktop host: runtime generation", "err", err)
157 return 2
158 }
159 token, err := randomHex(32)
160 if err != nil {
161 slog.Error("desktop host: resource token", "err", err)
162 return 2
163 }
164 origin, stopOrigin, err := startResourceOrigin(app, token)
165 if err != nil {
166 slog.Error("desktop host: resource origin", "err", err)
167 return 2
168 }
169 defer stopOrigin()
170
171 appCtx, cancel := context.WithCancel(context.Background())
172 defer cancel()
173 hostCtx, stopSignals := signal.NotifyContext(appCtx, os.Interrupt, syscall.SIGTERM)
174 defer stopSignals()
175 conn := rpcwire.NewConn(stdin, stdout, rpcwire.Options{
176 StrictJSONRPC: true,
177 MaxInboundBytes: 64 << 20,
178 MaxOutboundBytes: 64 << 20,
179 MaxConcurrentHandlers: 512,
180 MaxWriteStall: 30 * time.Second,
181 Name: "desktop-host",
182 })
183 bridge := &hostShellBridge{app: app}
184 server := hostrpc.NewServer(conn, hostrpc.ServerConfig{
185 Registry: registry,
186 Contract: hostrpc.Build(registry, hostEventNames),
187 Hooks: hostRPCHooks(appCtx, app, bridge, hostrpc.Resources{Origin: origin, Token: token}),
188 Identity: hostIdentity(),
189 Generation: "g-" + generation,
190 })
191 bridge.server = server
192 app.hostShell = bridge
193 app.setNativeHost(rpcNativeHost{server: server})
194 runtimeEventsEmitFallback = func(_ context.Context, name string, payload ...any) {
195 server.Emit(name, payload...)
196 }
197 if err := server.Serve(hostCtx); err != nil {
198 reason := shutdownReasonConnectionLost
199 if hostCtx.Err() != nil && appCtx.Err() == nil {
200 reason = shutdownReasonSystemSignal
201 }
202 slog.Error("desktop host: connection ended", "err", err)
203 shutdownAttempted = true
204 status, shutdownErr := requestDetachedShutdown(app, shutdownRequest{
205 RequestID: newDesktopLifecycleRunID(), Reason: reason,
206 }, hostDetachedShutdownTimeout)
207 if shutdownErr != nil || !status.Completed {
208 slog.Error("desktop host: connection-loss cleanup failed", "err", shutdownErr, "phase", status.Phase)
209 }
210 return 1
211 }
212 if !app.shutdownStatus("").Completed {
213 shutdownAttempted = true
214 status, shutdownErr := requestDetachedShutdown(app, shutdownRequest{
215 RequestID: newDesktopLifecycleRunID(), Reason: shutdownReasonConnectionLost,
216 }, hostDetachedShutdownTimeout)
217 if shutdownErr != nil || !status.Completed {
218 slog.Error("desktop host: EOF cleanup failed", "err", shutdownErr, "phase", status.Phase)
219 return 1
220 }
221 }
222 return 0
223 }
224
225 // requestDetachedShutdown bounds cleanup only after the shell transport or OS
226 // signal is already gone. Explicit user shutdown remains unbounded and
227 // retryable because its window is still available to surface a save failure.
228 func requestDetachedShutdown(app *App, request shutdownRequest, timeout time.Duration) (shutdownStatus, error) {
229 result := make(chan detachedShutdownResult, 1)
230 go func() {
231 status, err := app.requestShutdown(context.Background(), request)
232 result <- detachedShutdownResult{status: status, err: err}
233 }()
234 timer := time.NewTimer(timeout)
235 defer timer.Stop()
236 select {
237 case completed := <-result:
238 return completed.status, completed.err
239 case <-timer.C:
240 status := app.shutdownStatus("")
241 reason := status.Reason
242 if reason == "" {
243 reason = normalizeShutdownReason(request.Reason)
244 }
245 app.lifecycle.tracker.markShutdown(reason, status.Phase, "interrupted")
246 return status, fmt.Errorf("detached shutdown timed out after %s: %w", timeout, context.DeadlineExceeded)
247 }
248 }
249
250 // hostRPCHooks binds the shell's lifecycle requests to the App hooks, always
251 // with the service-lifetime context the App stores.
252 func hostRPCHooks(ctx context.Context, app *App, bridge *hostShellBridge, resources hostrpc.Resources) hostrpc.Hooks {
253 return hostrpc.Hooks{
254 Hello: func(hostrpc.HelloParams) (hostrpc.HelloResult, error) {
255 runID := ""
256 if app.lifecycle.tracker != nil {
257 runID = app.lifecycle.tracker.state.RunID
258 }
259 return hostrpc.HelloResult{
260 Resources: resources, Window: initialDesktopWindowGeometry(),
261 RunID: runID, IncidentID: app.lifecycle.tracker.incidentID(),
262 DiagnosticsEnabled: app.diagnosticsTelemetry,
263 }, nil
264 },
265 Start: func(context.Context) error { app.startup(ctx); return nil },
266 DOMReady: func(context.Context) error { app.domReady(ctx); return nil },
267 RendererAttached: func(context.Context, int) error {
268 app.ReportDesktopWebViewReady()
269 return nil
270 },
271 BeforeClose: func(_ context.Context, reason string) bool { return bridge.beforeClose(ctx, reason) },
272 Shutdown: func(requestCtx context.Context, params hostrpc.ShutdownParams) (hostrpc.ShutdownResult, error) {
273 status, err := app.requestShutdown(requestCtx, shutdownRequest{RequestID: params.RequestID, Reason: params.Reason})
274 return hostRPCShutdownResult(status), err
275 },
276 ShutdownStatus: func(_ context.Context, params hostrpc.ShutdownStatusParams) (hostrpc.ShutdownResult, error) {
277 return hostRPCShutdownResult(app.shutdownStatus(params.RequestID)), nil
278 },
279 HostEvent: bridge.handleHostEvent,
280 BrowserControl: func(_ context.Context, enabled bool) error {
281 app.setBrowserControlEnabled(enabled)
282 return nil
283 },
284 }
285 }
286
287 func hostRPCShutdownResult(status shutdownStatus) hostrpc.ShutdownResult {
288 return hostrpc.ShutdownResult{
289 RequestID: status.RequestID, Reason: status.Reason, Phase: status.Phase, Outcome: status.Outcome,
290 Completed: status.Completed, Retryable: status.Retryable, ErrorCode: status.ErrorCode,
291 Error: status.Error, UpdatedAt: status.UpdatedAt,
292 }
293 }
294
295 func hostIdentity() hostrpc.Identity {
296 return hostrpc.Identity{
297 Version: version,
298 Channel: channel,
299 Commit: buildCommit(),
300 Home: instanceidentity.AccessHome(config.ReasonixHomeDir()),
301 }
302 }
303
304 func buildCommit() string {
305 info, ok := debug.ReadBuildInfo()
306 if !ok {
307 return ""
308 }
309 for _, setting := range info.Settings {
310 if setting.Key == "vcs.revision" {
311 return setting.Value
312 }
313 }
314 return ""
315 }
316
317 // startResourceOrigin serves the authorised asset middlewares on a loopback
318 // port behind a bearer token; anything they do not claim is a 404.
319 func startResourceOrigin(app *App, token string) (origin string, stop func(), err error) {
320 listener, err := net.Listen("tcp", "127.0.0.1:0")
321 if err != nil {
322 return "", nil, err
323 }
324 handler := http.NotFoundHandler()
325 chain := []func(http.Handler) http.Handler{
326 app.jsProfilingMiddleware(),
327 app.remoteMarkdownImageMiddleware(),
328 app.workspaceMediaMiddleware(),
329 app.themeAssetMiddleware(),
330 }
331 for _, middleware := range slices.Backward(chain) {
332 handler = middleware(handler)
333 }
334 server := &http.Server{Handler: bearerAuth(token, handler), ReadHeaderTimeout: 10 * time.Second}
335 go func() {
336 if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
337 slog.Warn("desktop host: resource origin stopped", "err", err)
338 }
339 }()
340 stop = func() {
341 ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
342 defer cancel()
343 _ = server.Shutdown(ctx)
344 }
345 return "http://" + listener.Addr().String(), stop, nil
346 }
347
348 func bearerAuth(token string, next http.Handler) http.Handler {
349 want := []byte("Bearer " + token)
350 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
351 if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), want) != 1 {
352 w.Header().Set("WWW-Authenticate", "Bearer")
353 http.Error(w, "unauthorized", http.StatusUnauthorized)
354 return
355 }
356 next.ServeHTTP(w, r)
357 })
358 }
359
360 func randomHex(n int) (string, error) {
361 buf := make([]byte, n)
362 if _, err := rand.Read(buf); err != nil {
363 return "", err
364 }
365 return hex.EncodeToString(buf), nil
366 }
367
367 lines GO