detached ece7682

web_daemon.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close