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}