main f2d166b

run.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"errors"
6	"flag"
7	"fmt"
8	"io"
9	"net"
10	"net/http"
11	"net/http/pprof"
12	"os"
13	"os/signal"
14	"runtime/debug"
15	"strings"
16	"syscall"
17	"time"
18
19	"github.com/bnema/neferwl/internal/adapters/config"
20	"github.com/bnema/neferwl/internal/adapters/logging"
21	"github.com/bnema/neferwl/internal/app"
22	"golang.org/x/term"
23)
24
25// runFlags are the root command flags.
26type runFlags struct {
27	backend, screenshot, size, input, debug, config, pprof            string
28	noTerminal, noXwayland, headlessHDR, screenshotRaw, session, dbus bool
29	timeout                                                           time.Duration
30}
31
32func (f *runFlags) register(fs *flag.FlagSet) {
33	fs.StringVar(&f.backend, "backend", "drm", "drm or headless")
34	fs.StringVar(&f.screenshot, "screenshot", "", "write PNG frames to directory")
35	fs.StringVar(&f.size, "size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs")
36	fs.StringVar(&f.input, "input", "", "headless input script path (- for stdin)")
37	fs.BoolVar(&f.noTerminal, "no-terminal", false, "skip initial terminal")
38	fs.BoolVar(&f.noXwayland, "no-xwayland", false, "no X11 display for X11 apps")
39	fs.BoolVar(&f.headlessHDR, "headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
40	fs.BoolVar(&f.screenshotRaw, "screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
41	fs.DurationVar(&f.timeout, "timeout", 0, "duration before exit (0 disables timeout)")
42	fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion, input-keys, wayland-damage and render-damage, which log every flip, pointer motion, key, client commit or frame)")
43	fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")
44	fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
45	fs.BoolVar(&f.dbus, "dbus", false, "headless: serve the session D-Bus services; for tests under dbus-run-session")
46	fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
47}
48
49// validate checks the flag combinations and parses --size.
50func (f *runFlags) validate(args []string) ([][2]int, error) {
51	headless := f.backend == "headless"
52	switch {
53	case f.backend != "drm" && !headless:
54		return nil, usagef("invalid backend %q", f.backend)
55	case f.dbus && !headless:
56		return nil, usagef("--dbus requires --backend=headless")
57	case f.headlessHDR && !headless:
58		return nil, usagef("--headless-hdr requires --backend=headless")
59	case f.screenshot != "" && !headless:
60		return nil, usagef("--screenshot requires --backend=headless")
61	case f.screenshotRaw && (f.screenshot == "" || !f.headlessHDR):
62		return nil, usagef("--screenshot-raw requires --screenshot and --headless-hdr")
63	case f.input != "" && !headless:
64		return nil, usagef("--input requires --backend=headless")
65	case f.timeout < 0:
66		return nil, usagef("negative timeout")
67	case len(args) != 0:
68		return nil, usagef("unexpected arguments: %v", args)
69	}
70	// The config file checks its own log.debug names.
71	if _, err := logging.ParseDebug(f.debug); err != nil {
72		return nil, usageError{fmt.Errorf("--debug: %w", err)}
73	}
74	return parseSizes(f.size)
75}
76
77func parseSizes(value string) ([][2]int, error) {
78	sizes := make([][2]int, 0, strings.Count(value, ",")+1)
79	for s := range strings.SplitSeq(value, ",") {
80		w, h, _, err := config.ParseMode(strings.TrimSpace(s))
81		if err != nil {
82			return nil, usagef("--size: %w", err)
83		}
84		if w == 0 {
85			return nil, usagef("--size: need WxH, got %q", s)
86		}
87		sizes = append(sizes, [2]int{w, h})
88	}
89	return sizes, nil
90}
91
92func setupRun(fs *flag.FlagSet) Runner {
93	var f runFlags
94	f.register(fs)
95	return func(ctx context.Context, _ Env, args []string) error {
96		sizes, err := f.validate(args)
97		if err != nil {
98			return err
99		}
100		return f.run(ctx, sizes)
101	}
102}
103
104// run starts the compositor once the flags are valid. SIGINT and SIGTERM
105// stop it; other commands keep the default signal behaviour.
106func (f *runFlags) run(ctx context.Context, sizes [][2]int) error {
107	ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
108	defer stop()
109	if f.screenshot != "" {
110		if err := os.MkdirAll(f.screenshot, 0o755); err != nil {
111			return err
112		}
113	}
114	path, cfg, warnings, err := loadConfig(f.config)
115	if err != nil {
116		return err
117	}
118	script, closeScript, err := openScript(f.input)
119	if err != nil {
120		return err
121	}
122	// app.Run closes the script on shutdown; this covers the returns before it.
123	defer closeScript()
124	tuneGC()
125	ctx, closeLog, err := logging.Open(ctx, f.backend, cfg.Log.Level, mergeDebug(cfg.Log.Debug, f.debug))
126	if err != nil {
127		return err
128	}
129	defer closeLog(false) // no-op after the panic path closed it
130	log := logging.For(ctx, "app")
131	defer func() {
132		if p := recover(); p != nil {
133			log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit")
134			_ = closeLog(true) // keep the crash file for the re-panic
135			panic(p)
136		}
137	}()
138	if f.pprof != "" {
139		if err := servePprof(ctx, f.pprof); err != nil {
140			return err
141		}
142		log.Info().Str("addr", f.pprof).Msg("pprof")
143	}
144	start := time.Now()
145	log.Info().Strs("args", os.Args[1:]).Str("backend", f.backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
146	configLog := logging.For(ctx, "config")
147	configLog.Info().Str("path", path).Msg("loaded config")
148	for _, w := range warnings {
149		configLog.Warn().Int("line", w.Line).Msg(w.Msg)
150	}
151	err = app.Run(ctx, app.Options{
152		Backend:       f.backend,
153		Config:        cfg,
154		ConfigPath:    path,
155		Timeout:       f.timeout,
156		NoTerminal:    f.noTerminal,
157		NoXwayland:    f.noXwayland,
158		HeadlessHDR:   f.headlessHDR,
159		Session:       f.session,
160		DBus:          f.dbus,
161		ScreenshotDir: f.screenshot,
162		ScreenshotRaw: f.screenshotRaw,
163		Sizes:         sizes,
164		Script:        script,
165	})
166	// SIGINT and SIGTERM cancel the context and are clean exits.
167	if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
168		err = nil
169	}
170	reason := "clean"
171	switch {
172	case err != nil:
173		reason = "error"
174	case ctx.Err() != nil:
175		reason = "signal"
176	case f.timeout > 0 && time.Since(start) >= f.timeout:
177		reason = "timeout"
178	}
179	log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
180	// On a terminal the run log already went to stderr (logging.Open).
181	if err != nil && term.IsTerminal(int(os.Stderr.Fd())) {
182		return loggedError{err}
183	}
184	return err
185}
186
187// loggedError is an error the run log already printed on stderr; exitCode
188// does not print it again.
189type loggedError struct{ error }
190
191func (e loggedError) Unwrap() error { return e.error }
192
193// mergeDebug joins the configured debug categories and the --debug value
194// in at most one allocation.
195func mergeDebug(configured []string, cli string) string {
196	switch {
197	case len(configured) == 0:
198		return cli
199	case len(configured) == 1 && cli == "":
200		return configured[0]
201	}
202	n := len(cli) + len(configured)
203	for _, c := range configured {
204		n += len(c)
205	}
206	var b strings.Builder
207	b.Grow(n)
208	for i, c := range configured {
209		if i > 0 {
210			b.WriteByte(',')
211		}
212		b.WriteString(c)
213	}
214	if cli != "" {
215		b.WriteByte(',')
216		b.WriteString(cli)
217	}
218	return b.String()
219}
220
221// openScript opens the --input script. It returns a nil interface, not a
222// typed-nil *os.File, when no script was requested: app.Run tests the
223// interface against nil to decide whether to read a script. closeScript
224// closes what openScript opened; it does nothing for stdin and for no script.
225func openScript(path string) (script io.ReadCloser, closeScript func(), err error) {
226	switch path {
227	case "":
228		return nil, func() {}, nil
229	case "-":
230		return os.Stdin, func() {}, nil
231	}
232	f, err := os.Open(path)
233	if err != nil {
234		return nil, func() {}, err
235	}
236	return f, func() { _ = f.Close() }, nil
237}
238
239// tuneGC bounds the heap of a process that runs for days. The Go live heap
240// is small (tens of MB; buffers live in GPU and shared memory), so the
241// default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13
242// collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB
243// more heap. A soft limit makes the collector work harder before the
244// process gets large. GOGC and GOMEMLIMIT set in the environment win.
245func tuneGC() {
246	if os.Getenv("GOMEMLIMIT") == "" {
247		debug.SetMemoryLimit(256 << 20)
248	}
249}
250
251// servePprof exposes the runtime profiles under /debug/pprof/ until ctx is
252// done. It binds before returning so a bad address fails at startup.
253func servePprof(ctx context.Context, addr string) error {
254	ln, err := net.Listen("tcp", addr)
255	if err != nil {
256		return fmt.Errorf("--pprof: %w", err)
257	}
258	mux := http.NewServeMux()
259	mux.HandleFunc("/debug/pprof/", pprof.Index)
260	mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
261	mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
262	mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
263	mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
264	srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
265	go func() { _ = srv.Serve(ln) }()
266	go func() {
267		<-ctx.Done()
268		_ = srv.Close()
269	}()
270	return nil
271}

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 25

Tags 10

↑↓ moveenter openesc close