refactor(cli): move the command line from main.go into internal/cli (#131)
* refactor(cli): move the command line from main.go into internal/cli main.go only calls cli.Main. A small wrapper over the standard flag package selects the command, prints errors and usage once and maps them to exit codes. validate-config, state and version are Command values; the root command keeps its flags, validation and compositor startup. * fix(cli): print compositor errors once and validate --debug before startup A run error is already in the run log, which goes to stderr on a terminal, so exitCode no longer prints it again. --debug is checked with the other flags, so a bad name in the config file is no longer reported as a usage error. tuneGC runs right before logging starts, and mergeDebug returns a single configured category without copying it. The tests check stdout and stderr for every command, including state and validate-config warnings. * fix(cli): keep run errors on stderr when it is not a terminal The run log only reaches stderr on a terminal, so exitCode skips printing a run error only there; under systemd or a greeter the error still reaches stderr. The command tests also require empty stdout on error paths.
- commit
- c90098690d17c497313e915c361989e3f2040ca8
- author
- bnema · · 2026-10-04 15:12 UTC
- committer
- GitHub · · 2026-10-04 15:12 UTC
- parent
- 3b84a9a
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
7 files · +724 -437
M .hexcheck.yaml +1 -1
| @@ -5,7 +5,7 @@ components: | |||
| 5 | 5 | # sessionlock is the display owner's proof ledger, not core placement policy. | |
| 6 | 6 | adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**, internal/sessionlock/**] } | |
| 7 | 7 | # app is the composition root: it wires adapters to core, like cmd. | |
| 8 | - | entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] } | |
| 8 | + | entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**, internal/cli/**] } | |
| 9 | 9 | generated: { role: ignore, paths: [internal/mocks/**] } | |
| 10 | 10 | rules: | |
| 11 | 11 | no-adapter-imports-in-core: error | |
| @@ -5,7 +5,7 @@ components: | |||||
| 5 | # sessionlock is the display owner's proof ledger, not core placement policy. | 5 | # sessionlock is the display owner's proof ledger, not core placement policy. | ||
| 6 | adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**, internal/sessionlock/**] } | 6 | adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**, internal/sessionlock/**] } | ||
| 7 | # app is the composition root: it wires adapters to core, like cmd. | 7 | # app is the composition root: it wires adapters to core, like cmd. | ||
| 8 | - | entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] } | 8 | + | entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**, internal/cli/**] } |
| 9 | generated: { role: ignore, paths: [internal/mocks/**] } | 9 | generated: { role: ignore, paths: [internal/mocks/**] } | ||
| 10 | rules: | 10 | rules: | ||
| 11 | no-adapter-imports-in-core: error | 11 | no-adapter-imports-in-core: error | ||
M cmd/neferwl/main.go +4 -338
| @@ -1,350 +1,16 @@ | |||
| 1 | + | // Command neferwl is the NeferWL Wayland compositor. The command line lives | |
| 2 | + | // in internal/cli; the compositor wiring in internal/app. | |
| 1 | 3 | package main | |
| 2 | 4 | ||
| 3 | 5 | import ( | |
| 4 | - | "context" | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | - | "flag" | |
| 8 | - | "fmt" | |
| 9 | - | "io" | |
| 10 | - | "net" | |
| 11 | - | "net/http" | |
| 12 | - | "net/http/pprof" | |
| 13 | 6 | "os" | |
| 14 | - | "os/signal" | |
| 15 | - | "runtime/debug" | |
| 16 | - | "strconv" | |
| 17 | - | "strings" | |
| 18 | - | "syscall" | |
| 19 | - | "time" | |
| 20 | 7 | ||
| 21 | - | "github.com/bnema/neferwl/internal/adapters/config" | |
| 22 | - | "github.com/bnema/neferwl/internal/adapters/statefile" | |
| 23 | - | "github.com/bnema/neferwl/internal/app" | |
| 24 | - | "github.com/bnema/neferwl/internal/logging" | |
| 25 | - | "github.com/bnema/neferwl/internal/ports" | |
| 8 | + | "github.com/bnema/neferwl/internal/cli" | |
| 26 | 9 | ) | |
| 27 | 10 | ||
| 28 | - | type usageError struct{ error } | |
| 29 | - | ||
| 30 | - | // openScript opens the --input script. It returns a nil interface, not a | |
| 31 | - | // typed-nil *os.File, when no script was requested: app.Run tests the | |
| 32 | - | // interface against nil to decide whether to read a script. closeScript | |
| 33 | - | // closes what openScript opened; it does nothing for stdin and for no script. | |
| 34 | - | func openScript(path string) (script io.ReadCloser, closeScript func(), err error) { | |
| 35 | - | switch path { | |
| 36 | - | case "": | |
| 37 | - | return nil, func() {}, nil | |
| 38 | - | case "-": | |
| 39 | - | return os.Stdin, func() {}, nil | |
| 40 | - | } | |
| 41 | - | f, err := os.Open(path) | |
| 42 | - | if err != nil { | |
| 43 | - | return nil, func() {}, err | |
| 44 | - | } | |
| 45 | - | return f, func() { _ = f.Close() }, nil | |
| 46 | - | } | |
| 47 | - | ||
| 48 | 11 | // version is set with -ldflags "-X main.version=..." by packaged builds. | |
| 49 | 12 | var version string | |
| 50 | 13 | ||
| 51 | 14 | func main() { | |
| 52 | - | tuneGC() | |
| 53 | - | os.Exit(runCode()) | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // tuneGC bounds the heap of a process that runs for days. The Go live heap | |
| 57 | - | // is small (tens of MB; buffers live in GPU and shared memory), so the | |
| 58 | - | // default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13 | |
| 59 | - | // collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB | |
| 60 | - | // more heap. A soft limit makes the collector work harder before the | |
| 61 | - | // process gets large. GOGC and GOMEMLIMIT set in the environment win. | |
| 62 | - | func tuneGC() { | |
| 63 | - | if os.Getenv("GOMEMLIMIT") == "" { | |
| 64 | - | debug.SetMemoryLimit(256 << 20) | |
| 65 | - | } | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // servePprof exposes the runtime profiles under /debug/pprof/ until ctx is | |
| 69 | - | // done. It binds before returning so a bad address fails at startup. | |
| 70 | - | func servePprof(ctx context.Context, addr string) error { | |
| 71 | - | ln, err := net.Listen("tcp", addr) | |
| 72 | - | if err != nil { | |
| 73 | - | return fmt.Errorf("--pprof: %w", err) | |
| 74 | - | } | |
| 75 | - | mux := http.NewServeMux() | |
| 76 | - | mux.HandleFunc("/debug/pprof/", pprof.Index) | |
| 77 | - | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) | |
| 78 | - | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) | |
| 79 | - | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) | |
| 80 | - | mux.HandleFunc("/debug/pprof/trace", pprof.Trace) | |
| 81 | - | srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} | |
| 82 | - | go func() { _ = srv.Serve(ln) }() | |
| 83 | - | go func() { | |
| 84 | - | <-ctx.Done() | |
| 85 | - | _ = srv.Close() | |
| 86 | - | }() | |
| 87 | - | return nil | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | func runCode() int { | |
| 91 | - | err := run() | |
| 92 | - | if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) { | |
| 93 | - | return 0 | |
| 94 | - | } | |
| 95 | - | var usage usageError | |
| 96 | - | if errors.As(err, &usage) { | |
| 97 | - | return 2 | |
| 98 | - | } | |
| 99 | - | return 1 | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | func mergeDebug(configured []string, cli string) string { | |
| 103 | - | if cli == "" { | |
| 104 | - | return strings.Join(configured, ",") | |
| 105 | - | } | |
| 106 | - | if len(configured) == 0 { | |
| 107 | - | return cli | |
| 108 | - | } | |
| 109 | - | return strings.Join(configured, ",") + "," + cli | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | func run() error { | |
| 113 | - | if len(os.Args) > 1 && os.Args[1] == "validate-config" { | |
| 114 | - | if len(os.Args) > 3 { | |
| 115 | - | err := usageError{fmt.Errorf("usage: validate-config [path]")} | |
| 116 | - | fmt.Fprintln(os.Stderr, err) | |
| 117 | - | return err | |
| 118 | - | } | |
| 119 | - | path := config.DefaultPath() | |
| 120 | - | load := config.LoadDefault | |
| 121 | - | if len(os.Args) == 3 { | |
| 122 | - | path = os.Args[2] | |
| 123 | - | load = func() (ports.Config, []config.Warning, error) { return config.Load(path) } | |
| 124 | - | } | |
| 125 | - | _, warnings, err := load() | |
| 126 | - | if err != nil { | |
| 127 | - | fmt.Fprintln(os.Stderr, err) | |
| 128 | - | return err | |
| 129 | - | } | |
| 130 | - | for _, w := range warnings { | |
| 131 | - | fmt.Fprintf(os.Stderr, "%s:%s\n", path, w) | |
| 132 | - | } | |
| 133 | - | if len(warnings) > 0 { | |
| 134 | - | return fmt.Errorf("%d warning(s)", len(warnings)) | |
| 135 | - | } | |
| 136 | - | fmt.Println("ok: " + path) | |
| 137 | - | return nil | |
| 138 | - | } | |
| 139 | - | if len(os.Args) > 1 && os.Args[1] == "state" { | |
| 140 | - | return runState(os.Args[2:]) | |
| 141 | - | } | |
| 142 | - | if len(os.Args) == 2 && os.Args[1] == "version" { | |
| 143 | - | info, ok := debug.ReadBuildInfo() | |
| 144 | - | if !ok { | |
| 145 | - | fmt.Println("unknown") | |
| 146 | - | return nil | |
| 147 | - | } | |
| 148 | - | v := info.Main.Version | |
| 149 | - | if version != "" { | |
| 150 | - | v = version // set by packaged builds (PKGBUILD -X main.version) | |
| 151 | - | } else if v == "" { | |
| 152 | - | v = "(devel)" | |
| 153 | - | } | |
| 154 | - | fmt.Println(v) | |
| 155 | - | return nil | |
| 156 | - | } | |
| 157 | - | flags := flag.NewFlagSet("neferwl", flag.ContinueOnError) | |
| 158 | - | backend := flags.String("backend", "drm", "drm or headless") | |
| 159 | - | screenshot := flags.String("screenshot", "", "write PNG frames to directory") | |
| 160 | - | size := flags.String("size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs") | |
| 161 | - | inputPath := flags.String("input", "", "headless input script path (- for stdin)") | |
| 162 | - | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 163 | - | noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps") | |
| 164 | - | headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable") | |
| 165 | - | screenshotRaw := flags.Bool("screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output") | |
| 166 | - | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 167 | - | debugFlag := flags.String("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)") | |
| 168 | - | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | |
| 169 | - | session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready") | |
| 170 | - | pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060") | |
| 171 | - | if err := flags.Parse(os.Args[1:]); err != nil { | |
| 172 | - | if errors.Is(err, flag.ErrHelp) { | |
| 173 | - | return err | |
| 174 | - | } | |
| 175 | - | return usageError{err} | |
| 176 | - | } | |
| 177 | - | if *backend != "drm" && *backend != "headless" { | |
| 178 | - | err := usageError{fmt.Errorf("invalid backend %q", *backend)} | |
| 179 | - | fmt.Fprintln(os.Stderr, err) | |
| 180 | - | return err | |
| 181 | - | } | |
| 182 | - | if *headlessHDR && *backend != "headless" { | |
| 183 | - | return usageError{fmt.Errorf("--headless-hdr requires --backend=headless")} | |
| 184 | - | } | |
| 185 | - | if *screenshot != "" && *backend != "headless" { | |
| 186 | - | err := usageError{fmt.Errorf("--screenshot requires --backend=headless")} | |
| 187 | - | fmt.Fprintln(os.Stderr, err) | |
| 188 | - | return err | |
| 189 | - | } | |
| 190 | - | if *screenshotRaw && (*screenshot == "" || !*headlessHDR) { | |
| 191 | - | err := usageError{fmt.Errorf("--screenshot-raw requires --screenshot and --headless-hdr")} | |
| 192 | - | fmt.Fprintln(os.Stderr, err) | |
| 193 | - | return err | |
| 194 | - | } | |
| 195 | - | var sizes [][2]int | |
| 196 | - | for _, s := range strings.Split(*size, ",") { | |
| 197 | - | w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s)) | |
| 198 | - | if sizeErr != nil || w == 0 { | |
| 199 | - | err := usageError{fmt.Errorf("--size: %w", sizeErr)} | |
| 200 | - | if sizeErr == nil { | |
| 201 | - | err = usageError{fmt.Errorf("--size: need WxH, got %q", s)} | |
| 202 | - | } | |
| 203 | - | fmt.Fprintln(os.Stderr, err) | |
| 204 | - | return err | |
| 205 | - | } | |
| 206 | - | sizes = append(sizes, [2]int{w, h}) | |
| 207 | - | } | |
| 208 | - | if *inputPath != "" && *backend != "headless" { | |
| 209 | - | err := usageError{fmt.Errorf("--input requires --backend=headless")} | |
| 210 | - | fmt.Fprintln(os.Stderr, err) | |
| 211 | - | return err | |
| 212 | - | } | |
| 213 | - | if *timeout < 0 { | |
| 214 | - | err := usageError{fmt.Errorf("negative timeout")} | |
| 215 | - | fmt.Fprintln(os.Stderr, err) | |
| 216 | - | return err | |
| 217 | - | } | |
| 218 | - | if flags.NArg() != 0 { | |
| 219 | - | err := usageError{fmt.Errorf("unexpected arguments: %v", flags.Args())} | |
| 220 | - | fmt.Fprintln(os.Stderr, err) | |
| 221 | - | return err | |
| 222 | - | } | |
| 223 | - | if *screenshot != "" { | |
| 224 | - | if err := os.MkdirAll(*screenshot, 0o755); err != nil { | |
| 225 | - | fmt.Fprintln(os.Stderr, err) | |
| 226 | - | return err | |
| 227 | - | } | |
| 228 | - | } | |
| 229 | - | path := *configFlag | |
| 230 | - | var cfgErr error | |
| 231 | - | var cfg = config.Defaults() | |
| 232 | - | var warnings []config.Warning | |
| 233 | - | if path == "" { | |
| 234 | - | path = config.DefaultPath() | |
| 235 | - | cfg, warnings, cfgErr = config.LoadDefault() | |
| 236 | - | } else { | |
| 237 | - | cfg, warnings, cfgErr = config.Load(path) | |
| 238 | - | } | |
| 239 | - | if cfgErr != nil { | |
| 240 | - | fmt.Fprintln(os.Stderr, cfgErr) | |
| 241 | - | return cfgErr | |
| 242 | - | } | |
| 243 | - | script, closeScript, err := openScript(*inputPath) | |
| 244 | - | if err != nil { | |
| 245 | - | return err | |
| 246 | - | } | |
| 247 | - | // app.Run closes the script on shutdown; this covers the returns before it. | |
| 248 | - | defer closeScript() | |
| 249 | - | selected := mergeDebug(cfg.Log.Debug, *debugFlag) | |
| 250 | - | if _, err := logging.ParseDebug(selected); err != nil { | |
| 251 | - | fmt.Fprintln(os.Stderr, err) | |
| 252 | - | return usageError{err} | |
| 253 | - | } | |
| 254 | - | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |
| 255 | - | defer stop() | |
| 256 | - | ctx, closeLog, err := logging.Open(ctx, *backend, cfg.Log.Level, selected) | |
| 257 | - | if err != nil { | |
| 258 | - | fmt.Fprintln(os.Stderr, err) | |
| 259 | - | return err | |
| 260 | - | } | |
| 261 | - | defer closeLog() | |
| 262 | - | log := logging.For(ctx, "app") | |
| 263 | - | defer func() { | |
| 264 | - | if p := recover(); p != nil { | |
| 265 | - | log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit") | |
| 266 | - | _ = closeLog() | |
| 267 | - | panic(p) | |
| 268 | - | } | |
| 269 | - | }() | |
| 270 | - | if *pprofAddr != "" { | |
| 271 | - | if err := servePprof(ctx, *pprofAddr); err != nil { | |
| 272 | - | fmt.Fprintln(os.Stderr, err) | |
| 273 | - | return err | |
| 274 | - | } | |
| 275 | - | log.Info().Str("addr", *pprofAddr).Msg("pprof") | |
| 276 | - | } | |
| 277 | - | start := time.Now() | |
| 278 | - | log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run") | |
| 279 | - | configLog := logging.For(ctx, "config") | |
| 280 | - | configLog.Info().Str("path", path).Msg("loaded config") | |
| 281 | - | for _, w := range warnings { | |
| 282 | - | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | |
| 283 | - | } | |
| 284 | - | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, ScreenshotRaw: *screenshotRaw, Sizes: sizes, Script: script}) | |
| 285 | - | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 286 | - | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 287 | - | err = nil | |
| 288 | - | } | |
| 289 | - | reason := "clean" | |
| 290 | - | switch { | |
| 291 | - | case err != nil: | |
| 292 | - | reason = "error" | |
| 293 | - | case ctx.Err() != nil: | |
| 294 | - | reason = "signal" | |
| 295 | - | case *timeout > 0 && time.Since(start) >= *timeout: | |
| 296 | - | reason = "timeout" | |
| 297 | - | } | |
| 298 | - | log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit") | |
| 299 | - | return err | |
| 300 | - | } | |
| 301 | - | ||
| 302 | - | // runState prints the session state for scripts: | |
| 303 | - | // | |
| 304 | - | // neferwl state the whole state | |
| 305 | - | // neferwl state output-of <pid> the output showing that process's window | |
| 306 | - | // | |
| 307 | - | // The file is $NEFERWL_STATE, else the one of $WAYLAND_DISPLAY. | |
| 308 | - | func runState(args []string) error { | |
| 309 | - | usage := usageError{fmt.Errorf("usage: state [output-of <pid>]")} | |
| 310 | - | pid := 0 | |
| 311 | - | switch { | |
| 312 | - | case len(args) == 0: | |
| 313 | - | case len(args) == 2 && args[0] == "output-of": | |
| 314 | - | n, err := strconv.Atoi(args[1]) | |
| 315 | - | if err != nil || n <= 0 { | |
| 316 | - | fmt.Fprintln(os.Stderr, usage) | |
| 317 | - | return usage | |
| 318 | - | } | |
| 319 | - | pid = n | |
| 320 | - | default: | |
| 321 | - | fmt.Fprintln(os.Stderr, usage) | |
| 322 | - | return usage | |
| 323 | - | } | |
| 324 | - | path := os.Getenv(statefile.Env) | |
| 325 | - | if path == "" { | |
| 326 | - | var err error | |
| 327 | - | if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil { | |
| 328 | - | fmt.Fprintln(os.Stderr, err) | |
| 329 | - | return err | |
| 330 | - | } | |
| 331 | - | } | |
| 332 | - | st, err := statefile.Read(path) | |
| 333 | - | if err != nil { | |
| 334 | - | fmt.Fprintln(os.Stderr, err) | |
| 335 | - | return err | |
| 336 | - | } | |
| 337 | - | var out any = st | |
| 338 | - | if pid != 0 { | |
| 339 | - | if out, err = statefile.OutputOf(st, pid); err != nil { | |
| 340 | - | fmt.Fprintln(os.Stderr, err) | |
| 341 | - | return err | |
| 342 | - | } | |
| 343 | - | } | |
| 344 | - | data, err := json.Marshal(out) | |
| 345 | - | if err != nil { | |
| 346 | - | return err | |
| 347 | - | } | |
| 348 | - | fmt.Println(string(data)) | |
| 349 | - | return nil | |
| 15 | + | os.Exit(cli.Main(os.Args[1:], version)) | |
| 350 | 16 | } | |
| @@ -1,350 +1,16 @@ | |||||
| 1 | + | // Command neferwl is the NeferWL Wayland compositor. The command line lives | |||
| 2 | + | // in internal/cli; the compositor wiring in internal/app. | |||
| 1 | package main | 3 | package main | ||
| 2 | 4 | ||||
| 3 | import ( | 5 | import ( | ||
| 4 | - | "context" | |||
| 5 | - | "encoding/json" | |||
| 6 | - | "errors" | |||
| 7 | - | "flag" | |||
| 8 | - | "fmt" | |||
| 9 | - | "io" | |||
| 10 | - | "net" | |||
| 11 | - | "net/http" | |||
| 12 | - | "net/http/pprof" | |||
| 13 | "os" | 6 | "os" | ||
| 14 | - | "os/signal" | |||
| 15 | - | "runtime/debug" | |||
| 16 | - | "strconv" | |||
| 17 | - | "strings" | |||
| 18 | - | "syscall" | |||
| 19 | - | "time" | |||
| 20 | 7 | ||||
| 21 | - | "github.com/bnema/neferwl/internal/adapters/config" | 8 | + | "github.com/bnema/neferwl/internal/cli" |
| 22 | - | "github.com/bnema/neferwl/internal/adapters/statefile" | |||
| 23 | - | "github.com/bnema/neferwl/internal/app" | |||
| 24 | - | "github.com/bnema/neferwl/internal/logging" | |||
| 25 | - | "github.com/bnema/neferwl/internal/ports" | |||
| 26 | ) | 9 | ) | ||
| 27 | 10 | ||||
| 28 | - | type usageError struct{ error } | |||
| 29 | - | ||||
| 30 | - | // openScript opens the --input script. It returns a nil interface, not a | |||
| 31 | - | // typed-nil *os.File, when no script was requested: app.Run tests the | |||
| 32 | - | // interface against nil to decide whether to read a script. closeScript | |||
| 33 | - | // closes what openScript opened; it does nothing for stdin and for no script. | |||
| 34 | - | func openScript(path string) (script io.ReadCloser, closeScript func(), err error) { | |||
| 35 | - | switch path { | |||
| 36 | - | case "": | |||
| 37 | - | return nil, func() {}, nil | |||
| 38 | - | case "-": | |||
| 39 | - | return os.Stdin, func() {}, nil | |||
| 40 | - | } | |||
| 41 | - | f, err := os.Open(path) | |||
| 42 | - | if err != nil { | |||
| 43 | - | return nil, func() {}, err | |||
| 44 | - | } | |||
| 45 | - | return f, func() { _ = f.Close() }, nil | |||
| 46 | - | } | |||
| 47 | - | ||||
| 48 | // version is set with -ldflags "-X main.version=..." by packaged builds. | 11 | // version is set with -ldflags "-X main.version=..." by packaged builds. | ||
| 49 | var version string | 12 | var version string | ||
| 50 | 13 | ||||
| 51 | func main() { | 14 | func main() { | ||
| 52 | - | tuneGC() | 15 | + | os.Exit(cli.Main(os.Args[1:], version)) |
| 53 | - | os.Exit(runCode()) | |||
| 54 | - | } | |||
| 55 | - | ||||
| 56 | - | // tuneGC bounds the heap of a process that runs for days. The Go live heap | |||
| 57 | - | // is small (tens of MB; buffers live in GPU and shared memory), so the | |||
| 58 | - | // default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13 | |||
| 59 | - | // collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB | |||
| 60 | - | // more heap. A soft limit makes the collector work harder before the | |||
| 61 | - | // process gets large. GOGC and GOMEMLIMIT set in the environment win. | |||
| 62 | - | func tuneGC() { | |||
| 63 | - | if os.Getenv("GOMEMLIMIT") == "" { | |||
| 64 | - | debug.SetMemoryLimit(256 << 20) | |||
| 65 | - | } | |||
| 66 | - | } | |||
| 67 | - | ||||
| 68 | - | // servePprof exposes the runtime profiles under /debug/pprof/ until ctx is | |||
| 69 | - | // done. It binds before returning so a bad address fails at startup. | |||
| 70 | - | func servePprof(ctx context.Context, addr string) error { | |||
| 71 | - | ln, err := net.Listen("tcp", addr) | |||
| 72 | - | if err != nil { | |||
| 73 | - | return fmt.Errorf("--pprof: %w", err) | |||
| 74 | - | } | |||
| 75 | - | mux := http.NewServeMux() | |||
| 76 | - | mux.HandleFunc("/debug/pprof/", pprof.Index) | |||
| 77 | - | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) | |||
| 78 | - | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) | |||
| 79 | - | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) | |||
| 80 | - | mux.HandleFunc("/debug/pprof/trace", pprof.Trace) | |||
| 81 | - | srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} | |||
| 82 | - | go func() { _ = srv.Serve(ln) }() | |||
| 83 | - | go func() { | |||
| 84 | - | <-ctx.Done() | |||
| 85 | - | _ = srv.Close() | |||
| 86 | - | }() | |||
| 87 | - | return nil | |||
| 88 | - | } | |||
| 89 | - | ||||
| 90 | - | func runCode() int { | |||
| 91 | - | err := run() | |||
| 92 | - | if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) { | |||
| 93 | - | return 0 | |||
| 94 | - | } | |||
| 95 | - | var usage usageError | |||
| 96 | - | if errors.As(err, &usage) { | |||
| 97 | - | return 2 | |||
| 98 | - | } | |||
| 99 | - | return 1 | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func mergeDebug(configured []string, cli string) string { | |||
| 103 | - | if cli == "" { | |||
| 104 | - | return strings.Join(configured, ",") | |||
| 105 | - | } | |||
| 106 | - | if len(configured) == 0 { | |||
| 107 | - | return cli | |||
| 108 | - | } | |||
| 109 | - | return strings.Join(configured, ",") + "," + cli | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | func run() error { | |||
| 113 | - | if len(os.Args) > 1 && os.Args[1] == "validate-config" { | |||
| 114 | - | if len(os.Args) > 3 { | |||
| 115 | - | err := usageError{fmt.Errorf("usage: validate-config [path]")} | |||
| 116 | - | fmt.Fprintln(os.Stderr, err) | |||
| 117 | - | return err | |||
| 118 | - | } | |||
| 119 | - | path := config.DefaultPath() | |||
| 120 | - | load := config.LoadDefault | |||
| 121 | - | if len(os.Args) == 3 { | |||
| 122 | - | path = os.Args[2] | |||
| 123 | - | load = func() (ports.Config, []config.Warning, error) { return config.Load(path) } | |||
| 124 | - | } | |||
| 125 | - | _, warnings, err := load() | |||
| 126 | - | if err != nil { | |||
| 127 | - | fmt.Fprintln(os.Stderr, err) | |||
| 128 | - | return err | |||
| 129 | - | } | |||
| 130 | - | for _, w := range warnings { | |||
| 131 | - | fmt.Fprintf(os.Stderr, "%s:%s\n", path, w) | |||
| 132 | - | } | |||
| 133 | - | if len(warnings) > 0 { | |||
| 134 | - | return fmt.Errorf("%d warning(s)", len(warnings)) | |||
| 135 | - | } | |||
| 136 | - | fmt.Println("ok: " + path) | |||
| 137 | - | return nil | |||
| 138 | - | } | |||
| 139 | - | if len(os.Args) > 1 && os.Args[1] == "state" { | |||
| 140 | - | return runState(os.Args[2:]) | |||
| 141 | - | } | |||
| 142 | - | if len(os.Args) == 2 && os.Args[1] == "version" { | |||
| 143 | - | info, ok := debug.ReadBuildInfo() | |||
| 144 | - | if !ok { | |||
| 145 | - | fmt.Println("unknown") | |||
| 146 | - | return nil | |||
| 147 | - | } | |||
| 148 | - | v := info.Main.Version | |||
| 149 | - | if version != "" { | |||
| 150 | - | v = version // set by packaged builds (PKGBUILD -X main.version) | |||
| 151 | - | } else if v == "" { | |||
| 152 | - | v = "(devel)" | |||
| 153 | - | } | |||
| 154 | - | fmt.Println(v) | |||
| 155 | - | return nil | |||
| 156 | - | } | |||
| 157 | - | flags := flag.NewFlagSet("neferwl", flag.ContinueOnError) | |||
| 158 | - | backend := flags.String("backend", "drm", "drm or headless") | |||
| 159 | - | screenshot := flags.String("screenshot", "", "write PNG frames to directory") | |||
| 160 | - | size := flags.String("size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs") | |||
| 161 | - | inputPath := flags.String("input", "", "headless input script path (- for stdin)") | |||
| 162 | - | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |||
| 163 | - | noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps") | |||
| 164 | - | headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable") | |||
| 165 | - | screenshotRaw := flags.Bool("screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output") | |||
| 166 | - | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |||
| 167 | - | debugFlag := flags.String("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)") | |||
| 168 | - | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | |||
| 169 | - | session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready") | |||
| 170 | - | pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060") | |||
| 171 | - | if err := flags.Parse(os.Args[1:]); err != nil { | |||
| 172 | - | if errors.Is(err, flag.ErrHelp) { | |||
| 173 | - | return err | |||
| 174 | - | } | |||
| 175 | - | return usageError{err} | |||
| 176 | - | } | |||
| 177 | - | if *backend != "drm" && *backend != "headless" { | |||
| 178 | - | err := usageError{fmt.Errorf("invalid backend %q", *backend)} | |||
| 179 | - | fmt.Fprintln(os.Stderr, err) | |||
| 180 | - | return err | |||
| 181 | - | } | |||
| 182 | - | if *headlessHDR && *backend != "headless" { | |||
| 183 | - | return usageError{fmt.Errorf("--headless-hdr requires --backend=headless")} | |||
| 184 | - | } | |||
| 185 | - | if *screenshot != "" && *backend != "headless" { | |||
| 186 | - | err := usageError{fmt.Errorf("--screenshot requires --backend=headless")} | |||
| 187 | - | fmt.Fprintln(os.Stderr, err) | |||
| 188 | - | return err | |||
| 189 | - | } | |||
| 190 | - | if *screenshotRaw && (*screenshot == "" || !*headlessHDR) { | |||
| 191 | - | err := usageError{fmt.Errorf("--screenshot-raw requires --screenshot and --headless-hdr")} | |||
| 192 | - | fmt.Fprintln(os.Stderr, err) | |||
| 193 | - | return err | |||
| 194 | - | } | |||
| 195 | - | var sizes [][2]int | |||
| 196 | - | for _, s := range strings.Split(*size, ",") { | |||
| 197 | - | w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s)) | |||
| 198 | - | if sizeErr != nil || w == 0 { | |||
| 199 | - | err := usageError{fmt.Errorf("--size: %w", sizeErr)} | |||
| 200 | - | if sizeErr == nil { | |||
| 201 | - | err = usageError{fmt.Errorf("--size: need WxH, got %q", s)} | |||
| 202 | - | } | |||
| 203 | - | fmt.Fprintln(os.Stderr, err) | |||
| 204 | - | return err | |||
| 205 | - | } | |||
| 206 | - | sizes = append(sizes, [2]int{w, h}) | |||
| 207 | - | } | |||
| 208 | - | if *inputPath != "" && *backend != "headless" { | |||
| 209 | - | err := usageError{fmt.Errorf("--input requires --backend=headless")} | |||
| 210 | - | fmt.Fprintln(os.Stderr, err) | |||
| 211 | - | return err | |||
| 212 | - | } | |||
| 213 | - | if *timeout < 0 { | |||
| 214 | - | err := usageError{fmt.Errorf("negative timeout")} | |||
| 215 | - | fmt.Fprintln(os.Stderr, err) | |||
| 216 | - | return err | |||
| 217 | - | } | |||
| 218 | - | if flags.NArg() != 0 { | |||
| 219 | - | err := usageError{fmt.Errorf("unexpected arguments: %v", flags.Args())} | |||
| 220 | - | fmt.Fprintln(os.Stderr, err) | |||
| 221 | - | return err | |||
| 222 | - | } | |||
| 223 | - | if *screenshot != "" { | |||
| 224 | - | if err := os.MkdirAll(*screenshot, 0o755); err != nil { | |||
| 225 | - | fmt.Fprintln(os.Stderr, err) | |||
| 226 | - | return err | |||
| 227 | - | } | |||
| 228 | - | } | |||
| 229 | - | path := *configFlag | |||
| 230 | - | var cfgErr error | |||
| 231 | - | var cfg = config.Defaults() | |||
| 232 | - | var warnings []config.Warning | |||
| 233 | - | if path == "" { | |||
| 234 | - | path = config.DefaultPath() | |||
| 235 | - | cfg, warnings, cfgErr = config.LoadDefault() | |||
| 236 | - | } else { | |||
| 237 | - | cfg, warnings, cfgErr = config.Load(path) | |||
| 238 | - | } | |||
| 239 | - | if cfgErr != nil { | |||
| 240 | - | fmt.Fprintln(os.Stderr, cfgErr) | |||
| 241 | - | return cfgErr | |||
| 242 | - | } | |||
| 243 | - | script, closeScript, err := openScript(*inputPath) | |||
| 244 | - | if err != nil { | |||
| 245 | - | return err | |||
| 246 | - | } | |||
| 247 | - | // app.Run closes the script on shutdown; this covers the returns before it. | |||
| 248 | - | defer closeScript() | |||
| 249 | - | selected := mergeDebug(cfg.Log.Debug, *debugFlag) | |||
| 250 | - | if _, err := logging.ParseDebug(selected); err != nil { | |||
| 251 | - | fmt.Fprintln(os.Stderr, err) | |||
| 252 | - | return usageError{err} | |||
| 253 | - | } | |||
| 254 | - | ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) | |||
| 255 | - | defer stop() | |||
| 256 | - | ctx, closeLog, err := logging.Open(ctx, *backend, cfg.Log.Level, selected) | |||
| 257 | - | if err != nil { | |||
| 258 | - | fmt.Fprintln(os.Stderr, err) | |||
| 259 | - | return err | |||
| 260 | - | } | |||
| 261 | - | defer closeLog() | |||
| 262 | - | log := logging.For(ctx, "app") | |||
| 263 | - | defer func() { | |||
| 264 | - | if p := recover(); p != nil { | |||
| 265 | - | log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit") | |||
| 266 | - | _ = closeLog() | |||
| 267 | - | panic(p) | |||
| 268 | - | } | |||
| 269 | - | }() | |||
| 270 | - | if *pprofAddr != "" { | |||
| 271 | - | if err := servePprof(ctx, *pprofAddr); err != nil { | |||
| 272 | - | fmt.Fprintln(os.Stderr, err) | |||
| 273 | - | return err | |||
| 274 | - | } | |||
| 275 | - | log.Info().Str("addr", *pprofAddr).Msg("pprof") | |||
| 276 | - | } | |||
| 277 | - | start := time.Now() | |||
| 278 | - | log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run") | |||
| 279 | - | configLog := logging.For(ctx, "config") | |||
| 280 | - | configLog.Info().Str("path", path).Msg("loaded config") | |||
| 281 | - | for _, w := range warnings { | |||
| 282 | - | configLog.Warn().Int("line", w.Line).Msg(w.Msg) | |||
| 283 | - | } | |||
| 284 | - | err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, ScreenshotRaw: *screenshotRaw, Sizes: sizes, Script: script}) | |||
| 285 | - | // SIGINT and SIGTERM cancel the context and are clean exits. | |||
| 286 | - | if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) { | |||
| 287 | - | err = nil | |||
| 288 | - | } | |||
| 289 | - | reason := "clean" | |||
| 290 | - | switch { | |||
| 291 | - | case err != nil: | |||
| 292 | - | reason = "error" | |||
| 293 | - | case ctx.Err() != nil: | |||
| 294 | - | reason = "signal" | |||
| 295 | - | case *timeout > 0 && time.Since(start) >= *timeout: | |||
| 296 | - | reason = "timeout" | |||
| 297 | - | } | |||
| 298 | - | log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit") | |||
| 299 | - | return err | |||
| 300 | - | } | |||
| 301 | - | ||||
| 302 | - | // runState prints the session state for scripts: | |||
| 303 | - | // | |||
| 304 | - | // neferwl state the whole state | |||
| 305 | - | // neferwl state output-of <pid> the output showing that process's window | |||
| 306 | - | // | |||
| 307 | - | // The file is $NEFERWL_STATE, else the one of $WAYLAND_DISPLAY. | |||
| 308 | - | func runState(args []string) error { | |||
| 309 | - | usage := usageError{fmt.Errorf("usage: state [output-of <pid>]")} | |||
| 310 | - | pid := 0 | |||
| 311 | - | switch { | |||
| 312 | - | case len(args) == 0: | |||
| 313 | - | case len(args) == 2 && args[0] == "output-of": | |||
| 314 | - | n, err := strconv.Atoi(args[1]) | |||
| 315 | - | if err != nil || n <= 0 { | |||
| 316 | - | fmt.Fprintln(os.Stderr, usage) | |||
| 317 | - | return usage | |||
| 318 | - | } | |||
| 319 | - | pid = n | |||
| 320 | - | default: | |||
| 321 | - | fmt.Fprintln(os.Stderr, usage) | |||
| 322 | - | return usage | |||
| 323 | - | } | |||
| 324 | - | path := os.Getenv(statefile.Env) | |||
| 325 | - | if path == "" { | |||
| 326 | - | var err error | |||
| 327 | - | if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil { | |||
| 328 | - | fmt.Fprintln(os.Stderr, err) | |||
| 329 | - | return err | |||
| 330 | - | } | |||
| 331 | - | } | |||
| 332 | - | st, err := statefile.Read(path) | |||
| 333 | - | if err != nil { | |||
| 334 | - | fmt.Fprintln(os.Stderr, err) | |||
| 335 | - | return err | |||
| 336 | - | } | |||
| 337 | - | var out any = st | |||
| 338 | - | if pid != 0 { | |||
| 339 | - | if out, err = statefile.OutputOf(st, pid); err != nil { | |||
| 340 | - | fmt.Fprintln(os.Stderr, err) | |||
| 341 | - | return err | |||
| 342 | - | } | |||
| 343 | - | } | |||
| 344 | - | data, err := json.Marshal(out) | |||
| 345 | - | if err != nil { | |||
| 346 | - | return err | |||
| 347 | - | } | |||
| 348 | - | fmt.Println(string(data)) | |||
| 349 | - | return nil | |||
| 350 | } | 16 | } | ||
D cmd/neferwl/main_test.go +0 -98
| @@ -1,98 +0,0 @@ | |||
| 1 | - | package main | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "io" | |
| 5 | - | "os" | |
| 6 | - | "strings" | |
| 7 | - | "testing" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | func TestMergeDebug(t *testing.T) { | |
| 11 | - | if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" { | |
| 12 | - | t.Fatal(got) | |
| 13 | - | } | |
| 14 | - | if got := mergeDebug(nil, ""); got != "" { | |
| 15 | - | t.Fatal(got) | |
| 16 | - | } | |
| 17 | - | } | |
| 18 | - | func TestExitCodes(t *testing.T) { | |
| 19 | - | for _, tc := range []struct { | |
| 20 | - | args []string | |
| 21 | - | code int | |
| 22 | - | }{ | |
| 23 | - | {[]string{"neferwl", "--help"}, 0}, | |
| 24 | - | {[]string{"neferwl", "--backend=invalid"}, 2}, | |
| 25 | - | {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2}, | |
| 26 | - | {[]string{"neferwl", "--backend=headless", "--screenshot=/tmp/shots", "--screenshot-raw"}, 2}, | |
| 27 | - | {[]string{"neferwl", "--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2}, | |
| 28 | - | {[]string{"neferwl", "--timeout=-1s"}, 2}, | |
| 29 | - | {[]string{"neferwl", "extra"}, 2}, | |
| 30 | - | {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1}, | |
| 31 | - | {[]string{"neferwl", "validate-config", "/nonexistent/neferwl.conf"}, 1}, | |
| 32 | - | } { | |
| 33 | - | old := os.Args | |
| 34 | - | os.Args = tc.args | |
| 35 | - | got := runCode() | |
| 36 | - | os.Args = old | |
| 37 | - | if got != tc.code { | |
| 38 | - | t.Errorf("%v: %d want %d", tc.args, got, tc.code) | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | if !strings.Contains(mergeDebug([]string{"core"}, "render"), "render") { | |
| 42 | - | t.Fatal("union") | |
| 43 | - | } | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | func TestOpenScript(t *testing.T) { | |
| 47 | - | file := t.TempDir() + "/script" | |
| 48 | - | if err := os.WriteFile(file, []byte("quit\n"), 0o600); err != nil { | |
| 49 | - | t.Fatal(err) | |
| 50 | - | } | |
| 51 | - | for _, tc := range []struct { | |
| 52 | - | name, path string | |
| 53 | - | check func(t *testing.T, script io.ReadCloser, err error) | |
| 54 | - | }{ | |
| 55 | - | {"none", "", func(t *testing.T, script io.ReadCloser, err error) { | |
| 56 | - | if err != nil || script != nil { | |
| 57 | - | t.Fatalf("got %#v, %v; want a nil interface", script, err) | |
| 58 | - | } | |
| 59 | - | }}, | |
| 60 | - | {"stdin", "-", func(t *testing.T, script io.ReadCloser, err error) { | |
| 61 | - | if err != nil || script != io.ReadCloser(os.Stdin) { | |
| 62 | - | t.Fatalf("got %#v, %v; want os.Stdin", script, err) | |
| 63 | - | } | |
| 64 | - | }}, | |
| 65 | - | {"missing", "/nonexistent/script", func(t *testing.T, script io.ReadCloser, err error) { | |
| 66 | - | if err == nil || script != nil { | |
| 67 | - | t.Fatalf("got %#v, %v; want a nil interface and an error", script, err) | |
| 68 | - | } | |
| 69 | - | }}, | |
| 70 | - | {"file", file, func(t *testing.T, script io.ReadCloser, err error) { | |
| 71 | - | if err != nil || script == nil { | |
| 72 | - | t.Fatalf("got %#v, %v", script, err) | |
| 73 | - | } | |
| 74 | - | if got, err := io.ReadAll(script); err != nil || string(got) != "quit\n" { | |
| 75 | - | t.Fatalf("read %q, %v", got, err) | |
| 76 | - | } | |
| 77 | - | }}, | |
| 78 | - | } { | |
| 79 | - | t.Run(tc.name, func(t *testing.T) { | |
| 80 | - | script, closeScript, err := openScript(tc.path) | |
| 81 | - | if closeScript == nil { | |
| 82 | - | t.Fatal("nil close func") | |
| 83 | - | } | |
| 84 | - | tc.check(t, script, err) | |
| 85 | - | closeScript() | |
| 86 | - | if tc.path == "-" { | |
| 87 | - | if _, err := os.Stdin.Stat(); err != nil { | |
| 88 | - | t.Fatalf("stdin closed: %v", err) | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | if tc.name == "file" { | |
| 92 | - | if _, err := script.Read(make([]byte, 1)); err == nil || err == io.EOF { | |
| 93 | - | t.Fatalf("file left open: %v", err) | |
| 94 | - | } | |
| 95 | - | } | |
| 96 | - | }) | |
| 97 | - | } | |
| 98 | - | } | |
| @@ -1,98 +0,0 @@ | |||||
| 1 | - | package main | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "io" | |||
| 5 | - | "os" | |||
| 6 | - | "strings" | |||
| 7 | - | "testing" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | func TestMergeDebug(t *testing.T) { | |||
| 11 | - | if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" { | |||
| 12 | - | t.Fatal(got) | |||
| 13 | - | } | |||
| 14 | - | if got := mergeDebug(nil, ""); got != "" { | |||
| 15 | - | t.Fatal(got) | |||
| 16 | - | } | |||
| 17 | - | } | |||
| 18 | - | func TestExitCodes(t *testing.T) { | |||
| 19 | - | for _, tc := range []struct { | |||
| 20 | - | args []string | |||
| 21 | - | code int | |||
| 22 | - | }{ | |||
| 23 | - | {[]string{"neferwl", "--help"}, 0}, | |||
| 24 | - | {[]string{"neferwl", "--backend=invalid"}, 2}, | |||
| 25 | - | {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2}, | |||
| 26 | - | {[]string{"neferwl", "--backend=headless", "--screenshot=/tmp/shots", "--screenshot-raw"}, 2}, | |||
| 27 | - | {[]string{"neferwl", "--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2}, | |||
| 28 | - | {[]string{"neferwl", "--timeout=-1s"}, 2}, | |||
| 29 | - | {[]string{"neferwl", "extra"}, 2}, | |||
| 30 | - | {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1}, | |||
| 31 | - | {[]string{"neferwl", "validate-config", "/nonexistent/neferwl.conf"}, 1}, | |||
| 32 | - | } { | |||
| 33 | - | old := os.Args | |||
| 34 | - | os.Args = tc.args | |||
| 35 | - | got := runCode() | |||
| 36 | - | os.Args = old | |||
| 37 | - | if got != tc.code { | |||
| 38 | - | t.Errorf("%v: %d want %d", tc.args, got, tc.code) | |||
| 39 | - | } | |||
| 40 | - | } | |||
| 41 | - | if !strings.Contains(mergeDebug([]string{"core"}, "render"), "render") { | |||
| 42 | - | t.Fatal("union") | |||
| 43 | - | } | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | func TestOpenScript(t *testing.T) { | |||
| 47 | - | file := t.TempDir() + "/script" | |||
| 48 | - | if err := os.WriteFile(file, []byte("quit\n"), 0o600); err != nil { | |||
| 49 | - | t.Fatal(err) | |||
| 50 | - | } | |||
| 51 | - | for _, tc := range []struct { | |||
| 52 | - | name, path string | |||
| 53 | - | check func(t *testing.T, script io.ReadCloser, err error) | |||
| 54 | - | }{ | |||
| 55 | - | {"none", "", func(t *testing.T, script io.ReadCloser, err error) { | |||
| 56 | - | if err != nil || script != nil { | |||
| 57 | - | t.Fatalf("got %#v, %v; want a nil interface", script, err) | |||
| 58 | - | } | |||
| 59 | - | }}, | |||
| 60 | - | {"stdin", "-", func(t *testing.T, script io.ReadCloser, err error) { | |||
| 61 | - | if err != nil || script != io.ReadCloser(os.Stdin) { | |||
| 62 | - | t.Fatalf("got %#v, %v; want os.Stdin", script, err) | |||
| 63 | - | } | |||
| 64 | - | }}, | |||
| 65 | - | {"missing", "/nonexistent/script", func(t *testing.T, script io.ReadCloser, err error) { | |||
| 66 | - | if err == nil || script != nil { | |||
| 67 | - | t.Fatalf("got %#v, %v; want a nil interface and an error", script, err) | |||
| 68 | - | } | |||
| 69 | - | }}, | |||
| 70 | - | {"file", file, func(t *testing.T, script io.ReadCloser, err error) { | |||
| 71 | - | if err != nil || script == nil { | |||
| 72 | - | t.Fatalf("got %#v, %v", script, err) | |||
| 73 | - | } | |||
| 74 | - | if got, err := io.ReadAll(script); err != nil || string(got) != "quit\n" { | |||
| 75 | - | t.Fatalf("read %q, %v", got, err) | |||
| 76 | - | } | |||
| 77 | - | }}, | |||
| 78 | - | } { | |||
| 79 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 80 | - | script, closeScript, err := openScript(tc.path) | |||
| 81 | - | if closeScript == nil { | |||
| 82 | - | t.Fatal("nil close func") | |||
| 83 | - | } | |||
| 84 | - | tc.check(t, script, err) | |||
| 85 | - | closeScript() | |||
| 86 | - | if tc.path == "-" { | |||
| 87 | - | if _, err := os.Stdin.Stat(); err != nil { | |||
| 88 | - | t.Fatalf("stdin closed: %v", err) | |||
| 89 | - | } | |||
| 90 | - | } | |||
| 91 | - | if tc.name == "file" { | |||
| 92 | - | if _, err := script.Read(make([]byte, 1)); err == nil || err == io.EOF { | |||
| 93 | - | t.Fatalf("file left open: %v", err) | |||
| 94 | - | } | |||
| 95 | - | } | |||
| 96 | - | }) | |||
| 97 | - | } | |||
| 98 | - | } | |||
A internal/cli/cli.go +139 -0
| @@ -0,0 +1,139 @@ | |||
| 1 | + | // Package cli parses the neferwl command line and runs the selected command. | |
| 2 | + | package cli | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "flag" | |
| 8 | + | "fmt" | |
| 9 | + | "io" | |
| 10 | + | "os" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Env holds what a command uses besides its arguments. | |
| 14 | + | type Env struct { | |
| 15 | + | Stdout, Stderr io.Writer | |
| 16 | + | // Version is the packaged build version; empty for source builds. | |
| 17 | + | Version string | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Runner runs a command with its positional arguments, after flag parsing. | |
| 21 | + | type Runner func(ctx context.Context, env Env, args []string) error | |
| 22 | + | ||
| 23 | + | // Command is one neferwl command. Setup registers the command's flags on fs | |
| 24 | + | // and returns its Runner, which reads the parsed flag values. | |
| 25 | + | type Command struct { | |
| 26 | + | Name string // empty for the root command | |
| 27 | + | Args string // positional arguments synopsis, e.g. "[path]" | |
| 28 | + | Summary string | |
| 29 | + | Setup func(fs *flag.FlagSet) Runner | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // root runs the compositor; commands are the subcommands. | |
| 33 | + | var ( | |
| 34 | + | root = Command{Args: "[flags]", Summary: "Run the compositor.", Setup: setupRun} | |
| 35 | + | commands = []Command{validateConfigCmd, stateCmd, versionCmd} | |
| 36 | + | ) | |
| 37 | + | ||
| 38 | + | // Main runs the command line args (without the program name) and returns | |
| 39 | + | // the process exit code: 0 on success, 2 on usage errors, 1 otherwise. | |
| 40 | + | func Main(args []string, version string) int { | |
| 41 | + | env := Env{Stdout: os.Stdout, Stderr: os.Stderr, Version: version} | |
| 42 | + | return exitCode(env.Stderr, Execute(context.Background(), env, args)) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Execute selects the command named by args[0], or the root command, parses | |
| 46 | + | // its flags and runs it. -h and --help print the command usage to Stdout. | |
| 47 | + | func Execute(ctx context.Context, env Env, args []string) error { | |
| 48 | + | cmd := lookup(args) | |
| 49 | + | if cmd.Name != "" { | |
| 50 | + | args = args[1:] | |
| 51 | + | } | |
| 52 | + | fs := flag.NewFlagSet(cmd.path(), flag.ContinueOnError) | |
| 53 | + | fs.SetOutput(io.Discard) // errors and usage are printed once, here and in exitCode | |
| 54 | + | run := cmd.Setup(fs) | |
| 55 | + | if err := fs.Parse(args); err != nil { | |
| 56 | + | if errors.Is(err, flag.ErrHelp) { | |
| 57 | + | printUsage(env.Stdout, cmd, fs) | |
| 58 | + | return nil | |
| 59 | + | } | |
| 60 | + | return cmd.usageError(err) | |
| 61 | + | } | |
| 62 | + | err := run(ctx, env, fs.Args()) | |
| 63 | + | var usage usageError | |
| 64 | + | if errors.As(err, &usage) { | |
| 65 | + | return cmd.usageError(usage.error) | |
| 66 | + | } | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func lookup(args []string) Command { | |
| 71 | + | if len(args) > 0 { | |
| 72 | + | for _, c := range commands { | |
| 73 | + | if args[0] == c.Name { | |
| 74 | + | return c | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | return root | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (c Command) path() string { | |
| 82 | + | if c.Name == "" { | |
| 83 | + | return "neferwl" | |
| 84 | + | } | |
| 85 | + | return "neferwl " + c.Name | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func (c Command) synopsis() string { | |
| 89 | + | if c.Args == "" { | |
| 90 | + | return c.path() | |
| 91 | + | } | |
| 92 | + | return c.path() + " " + c.Args | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // usageError marks a bad command line; it exits with code 2. | |
| 96 | + | type usageError struct{ error } | |
| 97 | + | ||
| 98 | + | // usagef returns a usage error; Execute appends the command synopsis. | |
| 99 | + | func usagef(format string, a ...any) error { | |
| 100 | + | return usageError{fmt.Errorf(format, a...)} | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func (c Command) usageError(err error) error { | |
| 104 | + | return usageError{fmt.Errorf("%w\nusage: %s (see %s --help)", err, c.synopsis(), c.path())} | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func printUsage(w io.Writer, cmd Command, fs *flag.FlagSet) { | |
| 108 | + | fmt.Fprintf(w, "usage: %s\n\n%s\n", cmd.synopsis(), cmd.Summary) | |
| 109 | + | if cmd.Name == "" { | |
| 110 | + | fmt.Fprintln(w, "\nCommands:") | |
| 111 | + | for _, c := range commands { | |
| 112 | + | fmt.Fprintf(w, " %-34s %s\n", c.synopsis(), c.Summary) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | hasFlags := false | |
| 116 | + | fs.VisitAll(func(*flag.Flag) { hasFlags = true }) | |
| 117 | + | if hasFlags { | |
| 118 | + | fmt.Fprintln(w, "\nFlags:") | |
| 119 | + | fs.SetOutput(w) | |
| 120 | + | fs.PrintDefaults() | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // exitCode prints err to w, unless the run log already has it, and maps | |
| 125 | + | // it to the process exit code. A canceled context (SIGINT, SIGTERM) is a | |
| 126 | + | // clean exit. | |
| 127 | + | func exitCode(w io.Writer, err error) int { | |
| 128 | + | if err == nil || errors.Is(err, context.Canceled) { | |
| 129 | + | return 0 | |
| 130 | + | } | |
| 131 | + | if !errors.As(err, new(loggedError)) { | |
| 132 | + | fmt.Fprintln(w, err) | |
| 133 | + | } | |
| 134 | + | var usage usageError | |
| 135 | + | if errors.As(err, &usage) { | |
| 136 | + | return 2 | |
| 137 | + | } | |
| 138 | + | return 1 | |
| 139 | + | } | |
| @@ -0,0 +1,139 @@ | |||||
| 1 | + | // Package cli parses the neferwl command line and runs the selected command. | |||
| 2 | + | package cli | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "flag" | |||
| 8 | + | "fmt" | |||
| 9 | + | "io" | |||
| 10 | + | "os" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // Env holds what a command uses besides its arguments. | |||
| 14 | + | type Env struct { | |||
| 15 | + | Stdout, Stderr io.Writer | |||
| 16 | + | // Version is the packaged build version; empty for source builds. | |||
| 17 | + | Version string | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Runner runs a command with its positional arguments, after flag parsing. | |||
| 21 | + | type Runner func(ctx context.Context, env Env, args []string) error | |||
| 22 | + | ||||
| 23 | + | // Command is one neferwl command. Setup registers the command's flags on fs | |||
| 24 | + | // and returns its Runner, which reads the parsed flag values. | |||
| 25 | + | type Command struct { | |||
| 26 | + | Name string // empty for the root command | |||
| 27 | + | Args string // positional arguments synopsis, e.g. "[path]" | |||
| 28 | + | Summary string | |||
| 29 | + | Setup func(fs *flag.FlagSet) Runner | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // root runs the compositor; commands are the subcommands. | |||
| 33 | + | var ( | |||
| 34 | + | root = Command{Args: "[flags]", Summary: "Run the compositor.", Setup: setupRun} | |||
| 35 | + | commands = []Command{validateConfigCmd, stateCmd, versionCmd} | |||
| 36 | + | ) | |||
| 37 | + | ||||
| 38 | + | // Main runs the command line args (without the program name) and returns | |||
| 39 | + | // the process exit code: 0 on success, 2 on usage errors, 1 otherwise. | |||
| 40 | + | func Main(args []string, version string) int { | |||
| 41 | + | env := Env{Stdout: os.Stdout, Stderr: os.Stderr, Version: version} | |||
| 42 | + | return exitCode(env.Stderr, Execute(context.Background(), env, args)) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Execute selects the command named by args[0], or the root command, parses | |||
| 46 | + | // its flags and runs it. -h and --help print the command usage to Stdout. | |||
| 47 | + | func Execute(ctx context.Context, env Env, args []string) error { | |||
| 48 | + | cmd := lookup(args) | |||
| 49 | + | if cmd.Name != "" { | |||
| 50 | + | args = args[1:] | |||
| 51 | + | } | |||
| 52 | + | fs := flag.NewFlagSet(cmd.path(), flag.ContinueOnError) | |||
| 53 | + | fs.SetOutput(io.Discard) // errors and usage are printed once, here and in exitCode | |||
| 54 | + | run := cmd.Setup(fs) | |||
| 55 | + | if err := fs.Parse(args); err != nil { | |||
| 56 | + | if errors.Is(err, flag.ErrHelp) { | |||
| 57 | + | printUsage(env.Stdout, cmd, fs) | |||
| 58 | + | return nil | |||
| 59 | + | } | |||
| 60 | + | return cmd.usageError(err) | |||
| 61 | + | } | |||
| 62 | + | err := run(ctx, env, fs.Args()) | |||
| 63 | + | var usage usageError | |||
| 64 | + | if errors.As(err, &usage) { | |||
| 65 | + | return cmd.usageError(usage.error) | |||
| 66 | + | } | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func lookup(args []string) Command { | |||
| 71 | + | if len(args) > 0 { | |||
| 72 | + | for _, c := range commands { | |||
| 73 | + | if args[0] == c.Name { | |||
| 74 | + | return c | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | return root | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func (c Command) path() string { | |||
| 82 | + | if c.Name == "" { | |||
| 83 | + | return "neferwl" | |||
| 84 | + | } | |||
| 85 | + | return "neferwl " + c.Name | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func (c Command) synopsis() string { | |||
| 89 | + | if c.Args == "" { | |||
| 90 | + | return c.path() | |||
| 91 | + | } | |||
| 92 | + | return c.path() + " " + c.Args | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // usageError marks a bad command line; it exits with code 2. | |||
| 96 | + | type usageError struct{ error } | |||
| 97 | + | ||||
| 98 | + | // usagef returns a usage error; Execute appends the command synopsis. | |||
| 99 | + | func usagef(format string, a ...any) error { | |||
| 100 | + | return usageError{fmt.Errorf(format, a...)} | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func (c Command) usageError(err error) error { | |||
| 104 | + | return usageError{fmt.Errorf("%w\nusage: %s (see %s --help)", err, c.synopsis(), c.path())} | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func printUsage(w io.Writer, cmd Command, fs *flag.FlagSet) { | |||
| 108 | + | fmt.Fprintf(w, "usage: %s\n\n%s\n", cmd.synopsis(), cmd.Summary) | |||
| 109 | + | if cmd.Name == "" { | |||
| 110 | + | fmt.Fprintln(w, "\nCommands:") | |||
| 111 | + | for _, c := range commands { | |||
| 112 | + | fmt.Fprintf(w, " %-34s %s\n", c.synopsis(), c.Summary) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | hasFlags := false | |||
| 116 | + | fs.VisitAll(func(*flag.Flag) { hasFlags = true }) | |||
| 117 | + | if hasFlags { | |||
| 118 | + | fmt.Fprintln(w, "\nFlags:") | |||
| 119 | + | fs.SetOutput(w) | |||
| 120 | + | fs.PrintDefaults() | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // exitCode prints err to w, unless the run log already has it, and maps | |||
| 125 | + | // it to the process exit code. A canceled context (SIGINT, SIGTERM) is a | |||
| 126 | + | // clean exit. | |||
| 127 | + | func exitCode(w io.Writer, err error) int { | |||
| 128 | + | if err == nil || errors.Is(err, context.Canceled) { | |||
| 129 | + | return 0 | |||
| 130 | + | } | |||
| 131 | + | if !errors.As(err, new(loggedError)) { | |||
| 132 | + | fmt.Fprintln(w, err) | |||
| 133 | + | } | |||
| 134 | + | var usage usageError | |||
| 135 | + | if errors.As(err, &usage) { | |||
| 136 | + | return 2 | |||
| 137 | + | } | |||
| 138 | + | return 1 | |||
| 139 | + | } | |||
A internal/cli/cli_test.go +182 -0
| @@ -0,0 +1,182 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // execute runs args like Main and returns the exit code and output. | |
| 15 | + | func execute(t *testing.T, args ...string) (code int, stdout, stderr string) { | |
| 16 | + | t.Helper() | |
| 17 | + | var out, errOut bytes.Buffer | |
| 18 | + | env := Env{Stdout: &out, Stderr: &errOut, Version: "1.2.3"} | |
| 19 | + | code = exitCode(&errOut, Execute(context.Background(), env, args)) | |
| 20 | + | return code, out.String(), errOut.String() | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestMergeDebug(t *testing.T) { | |
| 24 | + | if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" { | |
| 25 | + | t.Fatal(got) | |
| 26 | + | } | |
| 27 | + | if got := mergeDebug(nil, ""); got != "" { | |
| 28 | + | t.Fatal(got) | |
| 29 | + | } | |
| 30 | + | if got := mergeDebug([]string{"core"}, ""); got != "core" { | |
| 31 | + | t.Fatal(got) | |
| 32 | + | } | |
| 33 | + | if n := testing.AllocsPerRun(10, func() { _ = mergeDebug([]string{"core", "app"}, "render") }); n > 1 { | |
| 34 | + | t.Fatalf("%v allocations, want 1", n) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestParseSizes(t *testing.T) { | |
| 39 | + | sizes, err := parseSizes("1920x1080, 1280x720") | |
| 40 | + | if err != nil || len(sizes) != 2 || sizes[1] != [2]int{1280, 720} { | |
| 41 | + | t.Fatal(sizes, err) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // TestCommands checks the exit code and where each command writes: data | |
| 46 | + | // and help on stdout, an error exactly once on stderr. | |
| 47 | + | func TestCommands(t *testing.T) { | |
| 48 | + | dir := t.TempDir() | |
| 49 | + | cfg := dir + "/config" | |
| 50 | + | badCfg := dir + "/bad" | |
| 51 | + | stateFile := dir + "/state.json" | |
| 52 | + | pid := strconv.Itoa(os.Getpid()) | |
| 53 | + | for path, data := range map[string]string{ | |
| 54 | + | cfg: "", | |
| 55 | + | badCfg: "unknown.key = 1\n", | |
| 56 | + | stateFile: `{"output":"DP-1","outputs":[{"name":"DP-1","active":1,"count":1}],"windows":[{"id":1,"pid":` + pid + `,"output":"DP-1"}]}`, | |
| 57 | + | } { | |
| 58 | + | if err := os.WriteFile(path, []byte(data), 0o600); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | t.Setenv("NEFERWL_STATE", stateFile) | |
| 63 | + | for _, tc := range []struct { | |
| 64 | + | args []string | |
| 65 | + | code int | |
| 66 | + | stdout, stderr string // substrings; "" means empty | |
| 67 | + | }{ | |
| 68 | + | {[]string{"--help"}, 0, "neferwl state [output-of <pid>]", ""}, | |
| 69 | + | {[]string{"state", "-h"}, 0, "usage: neferwl state [output-of <pid>]", ""}, | |
| 70 | + | {[]string{"version"}, 0, "1.2.3\n", ""}, | |
| 71 | + | {[]string{"validate-config", cfg}, 0, "ok: " + cfg + "\n", ""}, | |
| 72 | + | {[]string{"validate-config", badCfg}, 1, "", "line 1"}, | |
| 73 | + | {[]string{"state", "output-of", pid}, 0, `{"name":"DP-1","active":1,"count":1,"workspace":"","workspace_id":""}` + "\n", ""}, | |
| 74 | + | {[]string{"state"}, 0, `"output":"DP-1"`, ""}, | |
| 75 | + | {[]string{"--unknown"}, 2, "", "flag provided but not defined"}, | |
| 76 | + | {[]string{"--backend=invalid"}, 2, "", "invalid backend"}, | |
| 77 | + | {[]string{"--backend=drm", "--screenshot=" + dir}, 2, "", "--screenshot requires"}, | |
| 78 | + | {[]string{"--backend=drm", "--headless-hdr"}, 2, "", "--headless-hdr requires"}, | |
| 79 | + | {[]string{"--backend=drm", "--input=-"}, 2, "", "--input requires"}, | |
| 80 | + | {[]string{"--backend=headless", "--screenshot=" + dir, "--screenshot-raw"}, 2, "", "--screenshot-raw requires"}, | |
| 81 | + | {[]string{"--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2, "", "--screenshot-raw requires"}, | |
| 82 | + | {[]string{"--size=1920"}, 2, "", "--size"}, | |
| 83 | + | {[]string{"--timeout=-1s"}, 2, "", "negative timeout"}, | |
| 84 | + | {[]string{"--debug=nope"}, 2, "", "--debug"}, | |
| 85 | + | {[]string{"extra"}, 2, "", "unexpected arguments"}, | |
| 86 | + | {[]string{"--config=/nonexistent/neferwl.conf"}, 1, "", "no such file"}, | |
| 87 | + | {[]string{"validate-config", "/nonexistent/neferwl.conf"}, 1, "", "no such file"}, | |
| 88 | + | {[]string{"validate-config", "a", "b"}, 2, "", "usage: neferwl validate-config [path]"}, | |
| 89 | + | {[]string{"state", "output-of", "x"}, 2, "", "usage: neferwl state [output-of <pid>]"}, | |
| 90 | + | {[]string{"state", "extra"}, 2, "", "unexpected arguments"}, | |
| 91 | + | {[]string{"version", "extra"}, 2, "", "usage: neferwl version"}, | |
| 92 | + | } { | |
| 93 | + | code, stdout, stderr := execute(t, tc.args...) | |
| 94 | + | if code != tc.code { | |
| 95 | + | t.Errorf("%v: exit %d, want %d (%s)", tc.args, code, tc.code, stderr) | |
| 96 | + | } | |
| 97 | + | if (tc.stdout == "") != (stdout == "") || !strings.Contains(stdout, tc.stdout) { | |
| 98 | + | t.Errorf("%v: stdout %q, want %q", tc.args, stdout, tc.stdout) | |
| 99 | + | } | |
| 100 | + | switch { | |
| 101 | + | case tc.stderr == "" && stderr != "": | |
| 102 | + | t.Errorf("%v: unexpected stderr %q", tc.args, stderr) | |
| 103 | + | case tc.stderr != "" && strings.Count(stderr, tc.stderr) != 1: | |
| 104 | + | t.Errorf("%v: stderr %q has %q %d times, want once", tc.args, stderr, tc.stderr, strings.Count(stderr, tc.stderr)) | |
| 105 | + | } | |
| 106 | + | if code == 2 && strings.Count(stderr, "usage:") != 1 { | |
| 107 | + | t.Errorf("%v: stderr %q needs one usage line", tc.args, stderr) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestExitCode(t *testing.T) { | |
| 113 | + | for _, tc := range []struct { | |
| 114 | + | name string | |
| 115 | + | err error | |
| 116 | + | code int | |
| 117 | + | print bool | |
| 118 | + | }{ | |
| 119 | + | {"canceled", context.Canceled, 0, false}, | |
| 120 | + | {"logged", loggedError{errors.New("boom")}, 1, false}, | |
| 121 | + | {"plain", errors.New("boom"), 1, true}, | |
| 122 | + | } { | |
| 123 | + | var b bytes.Buffer | |
| 124 | + | if code := exitCode(&b, tc.err); code != tc.code || (b.Len() > 0) != tc.print { | |
| 125 | + | t.Errorf("%s: exit %d printed %q", tc.name, code, b.String()) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestOpenScript(t *testing.T) { | |
| 131 | + | file := t.TempDir() + "/script" | |
| 132 | + | if err := os.WriteFile(file, []byte("quit\n"), 0o600); err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | for _, tc := range []struct { | |
| 136 | + | name, path string | |
| 137 | + | check func(t *testing.T, script io.ReadCloser, err error) | |
| 138 | + | }{ | |
| 139 | + | {"none", "", func(t *testing.T, script io.ReadCloser, err error) { | |
| 140 | + | if err != nil || script != nil { | |
| 141 | + | t.Fatalf("got %#v, %v; want a nil interface", script, err) | |
| 142 | + | } | |
| 143 | + | }}, | |
| 144 | + | {"stdin", "-", func(t *testing.T, script io.ReadCloser, err error) { | |
| 145 | + | if err != nil || script != io.ReadCloser(os.Stdin) { | |
| 146 | + | t.Fatalf("got %#v, %v; want os.Stdin", script, err) | |
| 147 | + | } | |
| 148 | + | }}, | |
| 149 | + | {"missing", "/nonexistent/script", func(t *testing.T, script io.ReadCloser, err error) { | |
| 150 | + | if err == nil || script != nil { | |
| 151 | + | t.Fatalf("got %#v, %v; want a nil interface and an error", script, err) | |
| 152 | + | } | |
| 153 | + | }}, | |
| 154 | + | {"file", file, func(t *testing.T, script io.ReadCloser, err error) { | |
| 155 | + | if err != nil || script == nil { | |
| 156 | + | t.Fatalf("got %#v, %v", script, err) | |
| 157 | + | } | |
| 158 | + | if got, err := io.ReadAll(script); err != nil || string(got) != "quit\n" { | |
| 159 | + | t.Fatalf("read %q, %v", got, err) | |
| 160 | + | } | |
| 161 | + | }}, | |
| 162 | + | } { | |
| 163 | + | t.Run(tc.name, func(t *testing.T) { | |
| 164 | + | script, closeScript, err := openScript(tc.path) | |
| 165 | + | if closeScript == nil { | |
| 166 | + | t.Fatal("nil close func") | |
| 167 | + | } | |
| 168 | + | tc.check(t, script, err) | |
| 169 | + | closeScript() | |
| 170 | + | if tc.path == "-" { | |
| 171 | + | if _, err := os.Stdin.Stat(); err != nil { | |
| 172 | + | t.Fatalf("stdin closed: %v", err) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | if tc.name == "file" { | |
| 176 | + | if _, err := script.Read(make([]byte, 1)); err == nil || err == io.EOF { | |
| 177 | + | t.Fatalf("file left open: %v", err) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | }) | |
| 181 | + | } | |
| 182 | + | } | |
| @@ -0,0 +1,182 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // execute runs args like Main and returns the exit code and output. | |||
| 15 | + | func execute(t *testing.T, args ...string) (code int, stdout, stderr string) { | |||
| 16 | + | t.Helper() | |||
| 17 | + | var out, errOut bytes.Buffer | |||
| 18 | + | env := Env{Stdout: &out, Stderr: &errOut, Version: "1.2.3"} | |||
| 19 | + | code = exitCode(&errOut, Execute(context.Background(), env, args)) | |||
| 20 | + | return code, out.String(), errOut.String() | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestMergeDebug(t *testing.T) { | |||
| 24 | + | if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" { | |||
| 25 | + | t.Fatal(got) | |||
| 26 | + | } | |||
| 27 | + | if got := mergeDebug(nil, ""); got != "" { | |||
| 28 | + | t.Fatal(got) | |||
| 29 | + | } | |||
| 30 | + | if got := mergeDebug([]string{"core"}, ""); got != "core" { | |||
| 31 | + | t.Fatal(got) | |||
| 32 | + | } | |||
| 33 | + | if n := testing.AllocsPerRun(10, func() { _ = mergeDebug([]string{"core", "app"}, "render") }); n > 1 { | |||
| 34 | + | t.Fatalf("%v allocations, want 1", n) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestParseSizes(t *testing.T) { | |||
| 39 | + | sizes, err := parseSizes("1920x1080, 1280x720") | |||
| 40 | + | if err != nil || len(sizes) != 2 || sizes[1] != [2]int{1280, 720} { | |||
| 41 | + | t.Fatal(sizes, err) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // TestCommands checks the exit code and where each command writes: data | |||
| 46 | + | // and help on stdout, an error exactly once on stderr. | |||
| 47 | + | func TestCommands(t *testing.T) { | |||
| 48 | + | dir := t.TempDir() | |||
| 49 | + | cfg := dir + "/config" | |||
| 50 | + | badCfg := dir + "/bad" | |||
| 51 | + | stateFile := dir + "/state.json" | |||
| 52 | + | pid := strconv.Itoa(os.Getpid()) | |||
| 53 | + | for path, data := range map[string]string{ | |||
| 54 | + | cfg: "", | |||
| 55 | + | badCfg: "unknown.key = 1\n", | |||
| 56 | + | stateFile: `{"output":"DP-1","outputs":[{"name":"DP-1","active":1,"count":1}],"windows":[{"id":1,"pid":` + pid + `,"output":"DP-1"}]}`, | |||
| 57 | + | } { | |||
| 58 | + | if err := os.WriteFile(path, []byte(data), 0o600); err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | t.Setenv("NEFERWL_STATE", stateFile) | |||
| 63 | + | for _, tc := range []struct { | |||
| 64 | + | args []string | |||
| 65 | + | code int | |||
| 66 | + | stdout, stderr string // substrings; "" means empty | |||
| 67 | + | }{ | |||
| 68 | + | {[]string{"--help"}, 0, "neferwl state [output-of <pid>]", ""}, | |||
| 69 | + | {[]string{"state", "-h"}, 0, "usage: neferwl state [output-of <pid>]", ""}, | |||
| 70 | + | {[]string{"version"}, 0, "1.2.3\n", ""}, | |||
| 71 | + | {[]string{"validate-config", cfg}, 0, "ok: " + cfg + "\n", ""}, | |||
| 72 | + | {[]string{"validate-config", badCfg}, 1, "", "line 1"}, | |||
| 73 | + | {[]string{"state", "output-of", pid}, 0, `{"name":"DP-1","active":1,"count":1,"workspace":"","workspace_id":""}` + "\n", ""}, | |||
| 74 | + | {[]string{"state"}, 0, `"output":"DP-1"`, ""}, | |||
| 75 | + | {[]string{"--unknown"}, 2, "", "flag provided but not defined"}, | |||
| 76 | + | {[]string{"--backend=invalid"}, 2, "", "invalid backend"}, | |||
| 77 | + | {[]string{"--backend=drm", "--screenshot=" + dir}, 2, "", "--screenshot requires"}, | |||
| 78 | + | {[]string{"--backend=drm", "--headless-hdr"}, 2, "", "--headless-hdr requires"}, | |||
| 79 | + | {[]string{"--backend=drm", "--input=-"}, 2, "", "--input requires"}, | |||
| 80 | + | {[]string{"--backend=headless", "--screenshot=" + dir, "--screenshot-raw"}, 2, "", "--screenshot-raw requires"}, | |||
| 81 | + | {[]string{"--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2, "", "--screenshot-raw requires"}, | |||
| 82 | + | {[]string{"--size=1920"}, 2, "", "--size"}, | |||
| 83 | + | {[]string{"--timeout=-1s"}, 2, "", "negative timeout"}, | |||
| 84 | + | {[]string{"--debug=nope"}, 2, "", "--debug"}, | |||
| 85 | + | {[]string{"extra"}, 2, "", "unexpected arguments"}, | |||
| 86 | + | {[]string{"--config=/nonexistent/neferwl.conf"}, 1, "", "no such file"}, | |||
| 87 | + | {[]string{"validate-config", "/nonexistent/neferwl.conf"}, 1, "", "no such file"}, | |||
| 88 | + | {[]string{"validate-config", "a", "b"}, 2, "", "usage: neferwl validate-config [path]"}, | |||
| 89 | + | {[]string{"state", "output-of", "x"}, 2, "", "usage: neferwl state [output-of <pid>]"}, | |||
| 90 | + | {[]string{"state", "extra"}, 2, "", "unexpected arguments"}, | |||
| 91 | + | {[]string{"version", "extra"}, 2, "", "usage: neferwl version"}, | |||
| 92 | + | } { | |||
| 93 | + | code, stdout, stderr := execute(t, tc.args...) | |||
| 94 | + | if code != tc.code { | |||
| 95 | + | t.Errorf("%v: exit %d, want %d (%s)", tc.args, code, tc.code, stderr) | |||
| 96 | + | } | |||
| 97 | + | if (tc.stdout == "") != (stdout == "") || !strings.Contains(stdout, tc.stdout) { | |||
| 98 | + | t.Errorf("%v: stdout %q, want %q", tc.args, stdout, tc.stdout) | |||
| 99 | + | } | |||
| 100 | + | switch { | |||
| 101 | + | case tc.stderr == "" && stderr != "": | |||
| 102 | + | t.Errorf("%v: unexpected stderr %q", tc.args, stderr) | |||
| 103 | + | case tc.stderr != "" && strings.Count(stderr, tc.stderr) != 1: | |||
| 104 | + | t.Errorf("%v: stderr %q has %q %d times, want once", tc.args, stderr, tc.stderr, strings.Count(stderr, tc.stderr)) | |||
| 105 | + | } | |||
| 106 | + | if code == 2 && strings.Count(stderr, "usage:") != 1 { | |||
| 107 | + | t.Errorf("%v: stderr %q needs one usage line", tc.args, stderr) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestExitCode(t *testing.T) { | |||
| 113 | + | for _, tc := range []struct { | |||
| 114 | + | name string | |||
| 115 | + | err error | |||
| 116 | + | code int | |||
| 117 | + | print bool | |||
| 118 | + | }{ | |||
| 119 | + | {"canceled", context.Canceled, 0, false}, | |||
| 120 | + | {"logged", loggedError{errors.New("boom")}, 1, false}, | |||
| 121 | + | {"plain", errors.New("boom"), 1, true}, | |||
| 122 | + | } { | |||
| 123 | + | var b bytes.Buffer | |||
| 124 | + | if code := exitCode(&b, tc.err); code != tc.code || (b.Len() > 0) != tc.print { | |||
| 125 | + | t.Errorf("%s: exit %d printed %q", tc.name, code, b.String()) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestOpenScript(t *testing.T) { | |||
| 131 | + | file := t.TempDir() + "/script" | |||
| 132 | + | if err := os.WriteFile(file, []byte("quit\n"), 0o600); err != nil { | |||
| 133 | + | t.Fatal(err) | |||
| 134 | + | } | |||
| 135 | + | for _, tc := range []struct { | |||
| 136 | + | name, path string | |||
| 137 | + | check func(t *testing.T, script io.ReadCloser, err error) | |||
| 138 | + | }{ | |||
| 139 | + | {"none", "", func(t *testing.T, script io.ReadCloser, err error) { | |||
| 140 | + | if err != nil || script != nil { | |||
| 141 | + | t.Fatalf("got %#v, %v; want a nil interface", script, err) | |||
| 142 | + | } | |||
| 143 | + | }}, | |||
| 144 | + | {"stdin", "-", func(t *testing.T, script io.ReadCloser, err error) { | |||
| 145 | + | if err != nil || script != io.ReadCloser(os.Stdin) { | |||
| 146 | + | t.Fatalf("got %#v, %v; want os.Stdin", script, err) | |||
| 147 | + | } | |||
| 148 | + | }}, | |||
| 149 | + | {"missing", "/nonexistent/script", func(t *testing.T, script io.ReadCloser, err error) { | |||
| 150 | + | if err == nil || script != nil { | |||
| 151 | + | t.Fatalf("got %#v, %v; want a nil interface and an error", script, err) | |||
| 152 | + | } | |||
| 153 | + | }}, | |||
| 154 | + | {"file", file, func(t *testing.T, script io.ReadCloser, err error) { | |||
| 155 | + | if err != nil || script == nil { | |||
| 156 | + | t.Fatalf("got %#v, %v", script, err) | |||
| 157 | + | } | |||
| 158 | + | if got, err := io.ReadAll(script); err != nil || string(got) != "quit\n" { | |||
| 159 | + | t.Fatalf("read %q, %v", got, err) | |||
| 160 | + | } | |||
| 161 | + | }}, | |||
| 162 | + | } { | |||
| 163 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 164 | + | script, closeScript, err := openScript(tc.path) | |||
| 165 | + | if closeScript == nil { | |||
| 166 | + | t.Fatal("nil close func") | |||
| 167 | + | } | |||
| 168 | + | tc.check(t, script, err) | |||
| 169 | + | closeScript() | |||
| 170 | + | if tc.path == "-" { | |||
| 171 | + | if _, err := os.Stdin.Stat(); err != nil { | |||
| 172 | + | t.Fatalf("stdin closed: %v", err) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | if tc.name == "file" { | |||
| 176 | + | if _, err := script.Read(make([]byte, 1)); err == nil || err == io.EOF { | |||
| 177 | + | t.Fatalf("file left open: %v", err) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | }) | |||
| 181 | + | } | |||
| 182 | + | } | |||
A internal/cli/commands.go +131 -0
| @@ -0,0 +1,131 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "flag" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 9 | + | "runtime/debug" | |
| 10 | + | "strconv" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/config" | |
| 13 | + | "github.com/bnema/neferwl/internal/adapters/statefile" | |
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | var validateConfigCmd = Command{ | |
| 18 | + | Name: "validate-config", | |
| 19 | + | Args: "[path]", | |
| 20 | + | Summary: "Check a config file without starting the compositor.", | |
| 21 | + | Setup: func(*flag.FlagSet) Runner { | |
| 22 | + | return func(_ context.Context, env Env, args []string) error { | |
| 23 | + | if len(args) > 1 { | |
| 24 | + | return usagef("expected at most one path, got %d", len(args)) | |
| 25 | + | } | |
| 26 | + | path := "" | |
| 27 | + | if len(args) == 1 { | |
| 28 | + | path = args[0] | |
| 29 | + | } | |
| 30 | + | path, _, warnings, err := loadConfig(path) | |
| 31 | + | if err != nil { | |
| 32 | + | return err | |
| 33 | + | } | |
| 34 | + | for _, w := range warnings { | |
| 35 | + | fmt.Fprintf(env.Stderr, "%s:%s\n", path, w) | |
| 36 | + | } | |
| 37 | + | if len(warnings) > 0 { | |
| 38 | + | return fmt.Errorf("%d warning(s)", len(warnings)) | |
| 39 | + | } | |
| 40 | + | fmt.Fprintf(env.Stdout, "ok: %s\n", path) | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | }, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // loadConfig reads the config at path, or the XDG default when path is | |
| 47 | + | // empty; a missing default file gives the defaults. It returns the path read. | |
| 48 | + | func loadConfig(path string) (string, ports.Config, []config.Warning, error) { | |
| 49 | + | if path == "" { | |
| 50 | + | cfg, warnings, err := config.LoadDefault() | |
| 51 | + | return config.DefaultPath(), cfg, warnings, err | |
| 52 | + | } | |
| 53 | + | cfg, warnings, err := config.Load(path) | |
| 54 | + | return path, cfg, warnings, err | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // stateCmd reads the file named by $NEFERWL_STATE, else the one of | |
| 58 | + | // $WAYLAND_DISPLAY. | |
| 59 | + | var stateCmd = Command{ | |
| 60 | + | Name: "state", | |
| 61 | + | Args: "[output-of <pid>]", | |
| 62 | + | Summary: "Print the session state, or the output showing a process's window, as JSON.", | |
| 63 | + | Setup: func(*flag.FlagSet) Runner { | |
| 64 | + | return func(_ context.Context, env Env, args []string) error { | |
| 65 | + | pid := 0 | |
| 66 | + | switch { | |
| 67 | + | case len(args) == 0: | |
| 68 | + | case len(args) == 2 && args[0] == "output-of": | |
| 69 | + | n, err := strconv.Atoi(args[1]) | |
| 70 | + | if err != nil || n <= 0 { | |
| 71 | + | return usagef("invalid pid %q", args[1]) | |
| 72 | + | } | |
| 73 | + | pid = n | |
| 74 | + | default: | |
| 75 | + | return usagef("unexpected arguments: %v", args) | |
| 76 | + | } | |
| 77 | + | path := os.Getenv(statefile.Env) | |
| 78 | + | if path == "" { | |
| 79 | + | var err error | |
| 80 | + | if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil { | |
| 81 | + | return err | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | st, err := statefile.Read(path) | |
| 85 | + | if err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | var out any = st | |
| 89 | + | if pid != 0 { | |
| 90 | + | if out, err = statefile.OutputOf(st, pid); err != nil { | |
| 91 | + | return err | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | data, err := json.Marshal(out) | |
| 95 | + | if err != nil { | |
| 96 | + | return err | |
| 97 | + | } | |
| 98 | + | _, err = env.Stdout.Write(append(data, '\n')) | |
| 99 | + | return err | |
| 100 | + | } | |
| 101 | + | }, | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | var versionCmd = Command{ | |
| 105 | + | Name: "version", | |
| 106 | + | Summary: "Print the neferwl version.", | |
| 107 | + | Setup: func(*flag.FlagSet) Runner { | |
| 108 | + | return func(_ context.Context, env Env, args []string) error { | |
| 109 | + | if len(args) != 0 { | |
| 110 | + | return usagef("unexpected arguments: %v", args) | |
| 111 | + | } | |
| 112 | + | fmt.Fprintln(env.Stdout, buildVersion(env.Version)) | |
| 113 | + | return nil | |
| 114 | + | } | |
| 115 | + | }, | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // buildVersion prefers the packaged version, then the module version. | |
| 119 | + | func buildVersion(packaged string) string { | |
| 120 | + | if packaged != "" { | |
| 121 | + | return packaged | |
| 122 | + | } | |
| 123 | + | info, ok := debug.ReadBuildInfo() | |
| 124 | + | if !ok { | |
| 125 | + | return "unknown" | |
| 126 | + | } | |
| 127 | + | if info.Main.Version == "" { | |
| 128 | + | return "(devel)" | |
| 129 | + | } | |
| 130 | + | return info.Main.Version | |
| 131 | + | } | |
| @@ -0,0 +1,131 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "flag" | |||
| 7 | + | "fmt" | |||
| 8 | + | "os" | |||
| 9 | + | "runtime/debug" | |||
| 10 | + | "strconv" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 13 | + | "github.com/bnema/neferwl/internal/adapters/statefile" | |||
| 14 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | var validateConfigCmd = Command{ | |||
| 18 | + | Name: "validate-config", | |||
| 19 | + | Args: "[path]", | |||
| 20 | + | Summary: "Check a config file without starting the compositor.", | |||
| 21 | + | Setup: func(*flag.FlagSet) Runner { | |||
| 22 | + | return func(_ context.Context, env Env, args []string) error { | |||
| 23 | + | if len(args) > 1 { | |||
| 24 | + | return usagef("expected at most one path, got %d", len(args)) | |||
| 25 | + | } | |||
| 26 | + | path := "" | |||
| 27 | + | if len(args) == 1 { | |||
| 28 | + | path = args[0] | |||
| 29 | + | } | |||
| 30 | + | path, _, warnings, err := loadConfig(path) | |||
| 31 | + | if err != nil { | |||
| 32 | + | return err | |||
| 33 | + | } | |||
| 34 | + | for _, w := range warnings { | |||
| 35 | + | fmt.Fprintf(env.Stderr, "%s:%s\n", path, w) | |||
| 36 | + | } | |||
| 37 | + | if len(warnings) > 0 { | |||
| 38 | + | return fmt.Errorf("%d warning(s)", len(warnings)) | |||
| 39 | + | } | |||
| 40 | + | fmt.Fprintf(env.Stdout, "ok: %s\n", path) | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | }, | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // loadConfig reads the config at path, or the XDG default when path is | |||
| 47 | + | // empty; a missing default file gives the defaults. It returns the path read. | |||
| 48 | + | func loadConfig(path string) (string, ports.Config, []config.Warning, error) { | |||
| 49 | + | if path == "" { | |||
| 50 | + | cfg, warnings, err := config.LoadDefault() | |||
| 51 | + | return config.DefaultPath(), cfg, warnings, err | |||
| 52 | + | } | |||
| 53 | + | cfg, warnings, err := config.Load(path) | |||
| 54 | + | return path, cfg, warnings, err | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // stateCmd reads the file named by $NEFERWL_STATE, else the one of | |||
| 58 | + | // $WAYLAND_DISPLAY. | |||
| 59 | + | var stateCmd = Command{ | |||
| 60 | + | Name: "state", | |||
| 61 | + | Args: "[output-of <pid>]", | |||
| 62 | + | Summary: "Print the session state, or the output showing a process's window, as JSON.", | |||
| 63 | + | Setup: func(*flag.FlagSet) Runner { | |||
| 64 | + | return func(_ context.Context, env Env, args []string) error { | |||
| 65 | + | pid := 0 | |||
| 66 | + | switch { | |||
| 67 | + | case len(args) == 0: | |||
| 68 | + | case len(args) == 2 && args[0] == "output-of": | |||
| 69 | + | n, err := strconv.Atoi(args[1]) | |||
| 70 | + | if err != nil || n <= 0 { | |||
| 71 | + | return usagef("invalid pid %q", args[1]) | |||
| 72 | + | } | |||
| 73 | + | pid = n | |||
| 74 | + | default: | |||
| 75 | + | return usagef("unexpected arguments: %v", args) | |||
| 76 | + | } | |||
| 77 | + | path := os.Getenv(statefile.Env) | |||
| 78 | + | if path == "" { | |||
| 79 | + | var err error | |||
| 80 | + | if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil { | |||
| 81 | + | return err | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | st, err := statefile.Read(path) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | var out any = st | |||
| 89 | + | if pid != 0 { | |||
| 90 | + | if out, err = statefile.OutputOf(st, pid); err != nil { | |||
| 91 | + | return err | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | data, err := json.Marshal(out) | |||
| 95 | + | if err != nil { | |||
| 96 | + | return err | |||
| 97 | + | } | |||
| 98 | + | _, err = env.Stdout.Write(append(data, '\n')) | |||
| 99 | + | return err | |||
| 100 | + | } | |||
| 101 | + | }, | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | var versionCmd = Command{ | |||
| 105 | + | Name: "version", | |||
| 106 | + | Summary: "Print the neferwl version.", | |||
| 107 | + | Setup: func(*flag.FlagSet) Runner { | |||
| 108 | + | return func(_ context.Context, env Env, args []string) error { | |||
| 109 | + | if len(args) != 0 { | |||
| 110 | + | return usagef("unexpected arguments: %v", args) | |||
| 111 | + | } | |||
| 112 | + | fmt.Fprintln(env.Stdout, buildVersion(env.Version)) | |||
| 113 | + | return nil | |||
| 114 | + | } | |||
| 115 | + | }, | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // buildVersion prefers the packaged version, then the module version. | |||
| 119 | + | func buildVersion(packaged string) string { | |||
| 120 | + | if packaged != "" { | |||
| 121 | + | return packaged | |||
| 122 | + | } | |||
| 123 | + | info, ok := debug.ReadBuildInfo() | |||
| 124 | + | if !ok { | |||
| 125 | + | return "unknown" | |||
| 126 | + | } | |||
| 127 | + | if info.Main.Version == "" { | |||
| 128 | + | return "(devel)" | |||
| 129 | + | } | |||
| 130 | + | return info.Main.Version | |||
| 131 | + | } | |||
A internal/cli/run.go +267 -0
| @@ -0,0 +1,267 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 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/app" | |
| 21 | + | "github.com/bnema/neferwl/internal/logging" | |
| 22 | + | "golang.org/x/term" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // runFlags are the root command flags. | |
| 26 | + | type runFlags struct { | |
| 27 | + | backend, screenshot, size, input, debug, config, pprof string | |
| 28 | + | noTerminal, noXwayland, headlessHDR, screenshotRaw, session bool | |
| 29 | + | timeout time.Duration | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (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. | |
| 49 | + | func (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 | + | ||
| 74 | + | func 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 | + | ||
| 89 | + | func 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. | |
| 103 | + | func (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. | |
| 185 | + | type loggedError struct{ error } | |
| 186 | + | ||
| 187 | + | func (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. | |
| 191 | + | func 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. | |
| 221 | + | func 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. | |
| 241 | + | func 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. | |
| 249 | + | func 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 | + | } | |
| @@ -0,0 +1,267 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 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/app" | |||
| 21 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 22 | + | "golang.org/x/term" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // runFlags are the root command flags. | |||
| 26 | + | type runFlags struct { | |||
| 27 | + | backend, screenshot, size, input, debug, config, pprof string | |||
| 28 | + | noTerminal, noXwayland, headlessHDR, screenshotRaw, session bool | |||
| 29 | + | timeout time.Duration | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (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. | |||
| 49 | + | func (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 | + | ||||
| 74 | + | func 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 | + | ||||
| 89 | + | func 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. | |||
| 103 | + | func (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. | |||
| 185 | + | type loggedError struct{ error } | |||
| 186 | + | ||||
| 187 | + | func (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. | |||
| 191 | + | func 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. | |||
| 221 | + | func 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. | |||
| 241 | + | func 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. | |||
| 249 | + | func 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 | + | } | |||