diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-02 08:39:35 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-02 08:39:35 +0200 |
| commit | b3327d17f9f5c7080b05d6d5457cf25550d40ad9 (patch) | |
| tree | 090e66047c14c9183058bd68a8d4afc12b48f399 | |
| parent | 38620359537d77ab10acb888d266d3c6eb16fc9b (diff) | |
Add --open support for live flamegraph browser launch
| -rw-r--r-- | internal/eventloop.go | 8 | ||||
| -rw-r--r-- | internal/flags/flags.go | 4 | ||||
| -rw-r--r-- | internal/flags/flags_test.go | 28 | ||||
| -rw-r--r-- | internal/flamegraph/liveserver.go | 79 | ||||
| -rw-r--r-- | internal/flamegraph/liveserver_open_test.go | 130 | ||||
| -rw-r--r-- | internal/ior.go | 2 |
6 files changed, 249 insertions, 2 deletions
diff --git a/internal/eventloop.go b/internal/eventloop.go index 41ab48b..50494dc 100644 --- a/internal/eventloop.go +++ b/internal/eventloop.go @@ -26,6 +26,8 @@ type eventLoopConfig struct { pathFilter string liveFlamegraph bool liveInterval time.Duration + liveOpen bool + liveOpenCommand string collapsedFields []string countField string flamegraphName string @@ -282,7 +284,11 @@ func (e *eventLoop) run(ctx context.Context, rawCh <-chan []byte) { if e.liveTrie != nil { fmt.Println("Starting live flamegraph server") go func() { - if err := flamegraph.ServeLive(ctx, e.liveTrie, e.cfg.liveInterval); err != nil && ctx.Err() == nil { + liveOptions := flamegraph.LiveServerOptions{ + AutoOpenBrowser: e.cfg.liveOpen, + OpenCommand: e.cfg.liveOpenCommand, + } + if err := flamegraph.ServeLiveWithOptions(ctx, e.liveTrie, e.cfg.liveInterval, liveOptions); err != nil && ctx.Err() == nil { fmt.Println("Live flamegraph server error:", err) } }() diff --git a/internal/flags/flags.go b/internal/flags/flags.go index bf348c7..c4ee7d2 100644 --- a/internal/flags/flags.go +++ b/internal/flags/flags.go @@ -65,6 +65,8 @@ type Flags struct { FlamegraphEnable bool LiveFlamegraph bool LiveInterval time.Duration + OpenLiveBrowser bool + OpenCommand string FlamegraphName string TUIExportEnable bool @@ -122,6 +124,8 @@ func parse() error { flag.BoolVar(&singleton.FlamegraphEnable, "flamegraph", false, "Enable flamegraph builder") flag.BoolVar(&singleton.LiveFlamegraph, "live", false, "Enable live flamegraph mode") flag.DurationVar(&singleton.LiveInterval, "live-interval", 200*time.Millisecond, "Live flamegraph refresh interval") + flag.BoolVar(&singleton.OpenLiveBrowser, "open", false, "Auto-open live flamegraph URL in a browser (used with -live)") + flag.StringVar(&singleton.OpenCommand, "open-cmd", "", "Custom command to open live flamegraph URL; use {url} placeholder or URL is appended") flag.StringVar(&singleton.FlamegraphName, "name", "default", "Name of the flamegraph, used to generate the SVG file") flag.BoolVar(&singleton.TUIExportEnable, "tuiExport", true, "Enable writing TUI snapshot export files") diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go index a4feb5d..3fa74c6 100644 --- a/internal/flags/flags_test.go +++ b/internal/flags/flags_test.go @@ -69,6 +69,12 @@ func TestParseLiveFlagsAndInterval(t *testing.T) { if got := int(pidFilter.Load()); got != 1234 { t.Fatalf("global pid filter = %d, want 1234", got) } + if cfg.OpenLiveBrowser { + t.Fatalf("expected open-live disabled by default") + } + if cfg.OpenCommand != "" { + t.Fatalf("expected empty open command by default") + } } func TestParseLiveDefaults(t *testing.T) { @@ -83,6 +89,28 @@ func TestParseLiveDefaults(t *testing.T) { if cfg.LiveInterval != 200*time.Millisecond { t.Fatalf("default live interval = %v, want %v", cfg.LiveInterval, 200*time.Millisecond) } + if cfg.OpenLiveBrowser { + t.Fatalf("expected open-live disabled by default") + } + if cfg.OpenCommand != "" { + t.Fatalf("expected empty open command by default") + } +} + +func TestParseOpenFlags(t *testing.T) { + cfg, err := parseForTest(t, "-live", "-open", "-open-cmd", "chromium --new-window") + if err != nil { + t.Fatalf("parse returned error: %v", err) + } + if !cfg.LiveFlamegraph { + t.Fatalf("expected live mode enabled") + } + if !cfg.OpenLiveBrowser { + t.Fatalf("expected -open to enable live browser open") + } + if cfg.OpenCommand != "chromium --new-window" { + t.Fatalf("open command = %q, want %q", cfg.OpenCommand, "chromium --new-window") + } } func TestParseDefaultCollapsedFieldsOrder(t *testing.T) { diff --git a/internal/flamegraph/liveserver.go b/internal/flamegraph/liveserver.go index 801b841..dda1af1 100644 --- a/internal/flamegraph/liveserver.go +++ b/internal/flamegraph/liveserver.go @@ -3,8 +3,12 @@ package flamegraph import ( "context" "encoding/json" + "errors" "fmt" "net/http" + "os/exec" + "runtime" + "strings" "time" ) @@ -14,18 +18,91 @@ var liveServerTimeouts = serverTimeouts{ idleTimeout: 60 * time.Second, } +type LiveServerOptions struct { + AutoOpenBrowser bool + OpenCommand string +} + +var openBrowserURLFn = openBrowserURL + // ServeLive starts the live flamegraph HTTP server and blocks until ctx is canceled. func ServeLive(ctx context.Context, lt *LiveTrie, interval time.Duration) error { + return ServeLiveWithOptions(ctx, lt, interval, LiveServerOptions{}) +} + +// ServeLiveWithOptions starts the live flamegraph server with runtime options. +func ServeLiveWithOptions(ctx context.Context, lt *LiveTrie, interval time.Duration, options LiveServerOptions) error { mux := http.NewServeMux() mux.HandleFunc("/", handleLivePage()) mux.HandleFunc("/events", handleSSE(lt, interval)) mux.HandleFunc("/reset", handleReset(lt)) mux.HandleFunc("/order", handleOrder(lt)) return runServer(ctx, mux, liveServerTimeouts, func(hostname string, port int) { - fmt.Printf("Live flamegraph available at http://%s:%d/\n", hostname, port) + url := fmt.Sprintf("http://%s:%d/", hostname, port) + fmt.Printf("Live flamegraph available at %s\n", url) + if err := maybeOpenLiveBrowser(url, options); err != nil { + fmt.Printf("Live flamegraph browser auto-open failed: %v\n", err) + } }) } +func maybeOpenLiveBrowser(url string, options LiveServerOptions) error { + if !options.AutoOpenBrowser { + return nil + } + return openBrowserURLFn(url, options.OpenCommand) +} + +func openBrowserURL(url, openCommand string) error { + parts, err := browserOpenCommandParts(runtime.GOOS, openCommand, url) + if err != nil { + return err + } + cmd := exec.Command(parts[0], parts[1:]...) + if err := cmd.Start(); err != nil { + return err + } + go func() { _ = cmd.Wait() }() + return nil +} + +func browserOpenCommandParts(goos, openCommand, url string) ([]string, error) { + var parts []string + if trimmed := strings.TrimSpace(openCommand); trimmed != "" { + parts = strings.Fields(trimmed) + } else { + parts = defaultBrowserCommand(goos) + } + if len(parts) == 0 { + return nil, errors.New("empty browser open command") + } + + containsURL := false + for i := range parts { + if strings.Contains(parts[i], "{url}") { + parts[i] = strings.ReplaceAll(parts[i], "{url}", url) + containsURL = true + } + } + if !containsURL { + parts = append(parts, url) + } + return parts, nil +} + +func defaultBrowserCommand(goos string) []string { + switch goos { + case "darwin": + return []string{"open", "-a", "Firefox"} + case "linux": + return []string{"firefox"} + case "windows": + return []string{"cmd", "/c", "start"} + default: + return []string{"firefox"} + } +} + func handleLivePage() http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/flamegraph/liveserver_open_test.go b/internal/flamegraph/liveserver_open_test.go new file mode 100644 index 0000000..9d280b9 --- /dev/null +++ b/internal/flamegraph/liveserver_open_test.go @@ -0,0 +1,130 @@ +package flamegraph + +import ( + "errors" + "testing" +) + +func TestBrowserOpenCommandPartsUsesLinuxDefault(t *testing.T) { + got, err := browserOpenCommandParts("linux", "", "http://localhost:1234/") + if err != nil { + t.Fatalf("browserOpenCommandParts returned error: %v", err) + } + want := []string{"firefox", "http://localhost:1234/"} + if len(got) != len(want) { + t.Fatalf("len(parts) = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("parts[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestBrowserOpenCommandPartsUsesDarwinDefault(t *testing.T) { + got, err := browserOpenCommandParts("darwin", "", "http://localhost:1234/") + if err != nil { + t.Fatalf("browserOpenCommandParts returned error: %v", err) + } + want := []string{"open", "-a", "Firefox", "http://localhost:1234/"} + if len(got) != len(want) { + t.Fatalf("len(parts) = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("parts[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestBrowserOpenCommandPartsOverrideAppendsURL(t *testing.T) { + got, err := browserOpenCommandParts("linux", "chromium --new-window", "http://localhost:1234/") + if err != nil { + t.Fatalf("browserOpenCommandParts returned error: %v", err) + } + want := []string{"chromium", "--new-window", "http://localhost:1234/"} + if len(got) != len(want) { + t.Fatalf("len(parts) = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("parts[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestBrowserOpenCommandPartsOverrideReplacesPlaceholder(t *testing.T) { + got, err := browserOpenCommandParts("linux", "open-browser --target={url}", "http://localhost:1234/") + if err != nil { + t.Fatalf("browserOpenCommandParts returned error: %v", err) + } + want := []string{"open-browser", "--target=http://localhost:1234/"} + if len(got) != len(want) { + t.Fatalf("len(parts) = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("parts[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestMaybeOpenLiveBrowserDisabledSkipsOpen(t *testing.T) { + called := false + orig := openBrowserURLFn + openBrowserURLFn = func(url, openCommand string) error { + called = true + return nil + } + t.Cleanup(func() { openBrowserURLFn = orig }) + + err := maybeOpenLiveBrowser("http://localhost:1234/", LiveServerOptions{AutoOpenBrowser: false}) + if err != nil { + t.Fatalf("maybeOpenLiveBrowser returned error: %v", err) + } + if called { + t.Fatalf("expected browser opener not to be called when disabled") + } +} + +func TestMaybeOpenLiveBrowserEnabledCallsOpen(t *testing.T) { + called := false + orig := openBrowserURLFn + openBrowserURLFn = func(url, openCommand string) error { + called = true + if url != "http://localhost:1234/" { + t.Fatalf("url = %q, want %q", url, "http://localhost:1234/") + } + if openCommand != "chromium" { + t.Fatalf("openCommand = %q, want %q", openCommand, "chromium") + } + return nil + } + t.Cleanup(func() { openBrowserURLFn = orig }) + + err := maybeOpenLiveBrowser("http://localhost:1234/", LiveServerOptions{ + AutoOpenBrowser: true, + OpenCommand: "chromium", + }) + if err != nil { + t.Fatalf("maybeOpenLiveBrowser returned error: %v", err) + } + if !called { + t.Fatalf("expected browser opener to be called") + } +} + +func TestMaybeOpenLiveBrowserPropagatesOpenError(t *testing.T) { + orig := openBrowserURLFn + openBrowserURLFn = func(url, openCommand string) error { + return errors.New("launch failed") + } + t.Cleanup(func() { openBrowserURLFn = orig }) + + err := maybeOpenLiveBrowser("http://localhost:1234/", LiveServerOptions{ + AutoOpenBrowser: true, + }) + if err == nil || err.Error() != "launch failed" { + t.Fatalf("expected launch failed error, got %v", err) + } +} diff --git a/internal/ior.go b/internal/ior.go index 39d09b5..b109b2e 100644 --- a/internal/ior.go +++ b/internal/ior.go @@ -229,6 +229,8 @@ func newEventLoopConfig(cfg flags.Flags) eventLoopConfig { pathFilter: cfg.PathFilter, liveFlamegraph: cfg.LiveFlamegraph, liveInterval: cfg.LiveInterval, + liveOpen: cfg.OpenLiveBrowser, + liveOpenCommand: cfg.OpenCommand, collapsedFields: fields, countField: cfg.CountField, flamegraphName: cfg.FlamegraphName, |
