1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net"
8 "net/http"
9 "net/netip"
10 "os"
11 "os/exec"
12 "os/signal"
13 "strings"
14 "syscall"
15 "time"
16
17 "github.com/bnema/vev/internal/adapters/clock"
18 "github.com/bnema/vev/internal/adapters/webterm"
19 "github.com/bnema/vev/internal/logging"
20 "github.com/bnema/vev/internal/ports"
21)
22
23const webStartupTimeout = 10 * time.Second
24
25func webReachable(ctx context.Context, client *http.Client, access webAccess, expected webterm.Settings) bool {
26 if access.Settings != expected {
27 return false
28 }
29 request, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+access.Settings.ProbeAddress()+"/health", nil)
30 if err != nil {
31 return false
32 }
33 request.Host = access.Settings.Host()
34 request.AddCookie(&http.Cookie{Name: "vev-web-session", Value: access.Token})
35 response, err := client.Do(request)
36 if err != nil {
37 return false
38 }
39 defer response.Body.Close()
40 return response.StatusCode == http.StatusNoContent
41}
42
43func printWebLink(access webAccess) {
44 fmt.Printf("vev web terminal: %s/#token=%s\nKeep this access link private.\n", access.Settings.Origin, access.Token)
45}
46
47func renewWebToken(ctx context.Context) error {
48 token, err := webControlRequest(ctx, true)
49 if err != nil {
50 return fmt.Errorf("renewing web token: %w", err)
51 }
52 printWebLink(token)
53 return nil
54}
55
56func launchWebDaemon(ctx context.Context, options webOptions) error {
57 settings, warnings, err := loadWebSettings(options)
58 if err != nil {
59 return err
60 }
61 for _, warning := range warnings {
62 if warning.Line > 0 {
63 fmt.Fprintf(os.Stderr, "vev: config warning (line %d): %s\n", warning.Line, warning.Msg)
64 continue
65 }
66 fmt.Fprintf(os.Stderr, "vev: config warning: %s\n", warning.Msg)
67 }
68 // One shared client for every readiness probe. Proxy stays disabled so
69 // probes only ever contact the local listener.
70 client := &http.Client{Timeout: time.Second, Transport: &http.Transport{Proxy: nil}, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
71 defer client.CloseIdleConnections()
72 if token, err := webControlRequest(ctx, false); err == nil {
73 if token.Settings != settings {
74 return errors.New("web gateway is running with different settings; stop and restart it to apply web.listen/web.origin")
75 }
76 readyCtx, cancel := context.WithTimeout(ctx, webStartupTimeout)
77 defer cancel()
78 ticker := time.NewTicker(50 * time.Millisecond)
79 defer ticker.Stop()
80 for !webReachable(readyCtx, client, token, settings) {
81 select {
82 case <-readyCtx.Done():
83 return fmt.Errorf("existing web gateway not ready: %w", readyCtx.Err())
84 case <-ticker.C:
85 }
86 }
87 printWebLink(token)
88 return nil
89 }
90 listener, err := listenWeb(settings.Listen)
91 if err != nil {
92 return fmt.Errorf("web port unavailable: %w", err)
93 }
94 defer listener.Close()
95 inherited, ok := listener.(*net.TCPListener)
96 if !ok {
97 return fmt.Errorf("web listener is not TCP")
98 }
99 inheritedFile, err := inherited.File()
100 if err != nil {
101 return err
102 }
103 defer inheritedFile.Close()
104 executable, err := selfExePath()
105 if err != nil {
106 return err
107 }
108 null, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
109 if err != nil {
110 return err
111 }
112 defer null.Close()
113 child := exec.Command(executable, "--web-serve", "--web-listen", settings.Listen, "--web-origin", settings.Origin)
114 // Browser capabilities do not depend on the launcher's TTY.
115 child.Env = append(withoutPerformanceTraceEnv(os.Environ()), "TERM=xterm-256color", "COLORTERM=truecolor")
116 child.Stdin, child.Stdout, child.Stderr = null, null, null
117 child.ExtraFiles = []*os.File{inheritedFile}
118 child.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
119 if err := child.Start(); err != nil {
120 return err
121 }
122 done := make(chan error, 1)
123 go func() { done <- child.Wait() }()
124 readyCtx, cancel := context.WithTimeout(ctx, webStartupTimeout)
125 defer cancel()
126 ticker := time.NewTicker(50 * time.Millisecond)
127 defer ticker.Stop()
128 for {
129 token, err := webControlRequest(readyCtx, false)
130 if err == nil && webReachable(readyCtx, client, token, settings) {
131 printWebLink(token)
132 return nil
133 }
134 select {
135 case err := <-done:
136 if err != nil {
137 return fmt.Errorf("web process exited before readiness: %w", err)
138 }
139 return errors.New("web process exited before readiness")
140 case <-readyCtx.Done():
141 _ = child.Process.Kill()
142 <-done
143 return fmt.Errorf("web startup: %w", readyCtx.Err())
144 case <-ticker.C:
145 }
146 }
147}
148
149// webTerminalConnector is the composition-root seam of one browser attachment's
150// broker connector. Production composes the lazy per-user connector, which
151// connect-or-spawns the broker on demand inside Connect; a test substitutes a
152// connector over its own private sandbox.
153var webTerminalConnector = newProductionBrokerConnector
154
155// webTerminal is the terminal of one browser attachment: the adapter's virtual
156// terminal plus the optional UI output transaction the shared supervisor
157// expects. The browser gateway has no UI observation service — the adapter
158// renders from its own VT mirror and change signal — so the transaction is the
159// honest "observation channel unavailable" shape: begin/end bracket the write
160// with no published snapshot, and PublishContext reports ports.ErrUIUnavailable,
161// which the supervisor's foreground deliberately tolerates so the frame is
162// still written and flushed. This keeps the browser run on the same code path
163// as every other frontend without inventing a second renderer or a parallel
164// presentation owner.
165type webTerminal struct {
166 *webterm.Terminal
167}
168
169var _ ports.UIOutputTransaction = webTerminal{}
170
171func (webTerminal) BeginOutput(ports.UIContext) {}
172
173// EndOutput is a no-op: no publication was staged, so nothing is committed or
174// rolled back.
175func (webTerminal) EndOutput(bool) {}
176
177// PublishContext reports the unavailable observation channel. The supervisor
178// tolerates it and still writes the frame; a picker paint does the same.
179func (webTerminal) PublishContext(ports.UIContext) error { return ports.ErrUIUnavailable }
180
181// runWebTerminalClient composes exactly one browser attachment over the shared
182// broker client. Every authenticated WebSocket owns one call, and therefore one
183// client.Picker, one client.Supervisor, one broker service, and one session
184// stream; the virtual webterm.Terminal is simply that run's terminal, so the
185// picker, connecting, and attached presentations and their bounded failures are
186// the same ones the ordinary terminal path presents.
187//
188// It dials no daemon, selects no transport, and keeps no host registry, remote
189// factory, launch configuration, or attachment cache: the broker owns every
190// carriage, and the no-argument product intent is expressed as the closed
191// initial-navigation union, exactly like the sandbox harness. Cancelling the
192// run's context ends only this supervisor and its logical stream — the broker,
193// its daemons, and every other tab stay alive.
194func runWebTerminalClient(ctx context.Context, terminal *webterm.Terminal) error {
195 callbacks := terminalBrokerCallbacks()
196 sessionEnv := terminalSessionEnvironment()
197 attachmentEnv := virtualTerminalAttachmentEnvironment()
198 attachmentEnv.Cwd = sessionEnv.Cwd
199 return runBrokerClient(ctx, brokerClientConfig{
200 Connector: webTerminalConnector(),
201 Terminal: webTerminal{Terminal: terminal},
202 Clock: clock.New(),
203 InitialNavigation: localEphemeralNavigation(),
204 AttachmentEnvironment: attachmentEnv,
205 SessionEnvironment: sessionEnv,
206 OnState: callbacks.OnState,
207 OnFailure: callbacks.OnFailure,
208 OnLifecycle: callbacks.OnLifecycle,
209 })
210}
211
212// Preserve the requested address family, including IPv4 wildcard listeners.
213func listenWeb(address string) (net.Listener, error) {
214 addr, err := netip.ParseAddrPort(address)
215 if err != nil {
216 return nil, err
217 }
218 network := "tcp6"
219 if addr.Addr().Is4() {
220 network = "tcp4"
221 }
222 return net.Listen(network, address)
223}
224
225func runWebDaemon(parent context.Context, options webOptions) error {
226 // The launcher passes resolved settings; do not reload a changing config.
227 if options.listen == "" || options.origin == "" {
228 return errors.New("web server requires its launcher settings")
229 }
230 settings, err := webterm.ParseSettings(options.listen, options.origin)
231 if err != nil {
232 return err
233 }
234 ctx, cancel := signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM)
235 defer cancel()
236 inherited := os.NewFile(3, "web-listener")
237 if inherited == nil {
238 return errors.New("web server requires its launcher")
239 }
240 listener, err := net.FileListener(inherited)
241 _ = inherited.Close()
242 if err != nil {
243 return fmt.Errorf("inherited web listener: %w", err)
244 }
245 defer listener.Close()
246 if listener.Addr().String() != settings.Listen {
247 return errors.New("inherited web listener does not match web.listen")
248 }
249 token, err := webterm.NewToken()
250 if err != nil {
251 return err
252 }
253 log, closer, err := configureLogging(logging.Client, false)
254 if err != nil {
255 return err
256 }
257 defer closer.Close()
258 handler, err := webterm.NewServer(ctx, settings, token, func(ctx context.Context, terminal *webterm.Terminal) error {
259 err := runWebTerminalClient(ctx, terminal)
260 if err != nil && ctx.Err() == nil {
261 log.Warn("web attachment ended", "error", strings.TrimSpace(err.Error()))
262 }
263 return err
264 })
265 if err != nil {
266 return err
267 }
268 control, err := startWebControl(ctx, handler, settings)
269 if err != nil {
270 return err
271 }
272 defer control.Close()
273 server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 30 * time.Second, MaxHeaderBytes: 16 << 10}
274 done := make(chan struct{})
275 go func() {
276 defer close(done)
277 <-ctx.Done()
278 shutdownCtx, stop := context.WithTimeout(context.Background(), 5*time.Second)
279 defer stop()
280 _ = server.Shutdown(shutdownCtx)
281 }()
282 err = server.Serve(listener)
283 cancel()
284 <-done
285 handler.Wait()
286 if errors.Is(err, http.ErrServerClosed) {
287 return nil
288 }
289 return err
290}