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}