v0.4.0 b6be3a8

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

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