summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-02 08:39:35 +0200
committerPaul Buetow <paul@buetow.org>2026-03-02 08:39:35 +0200
commitb3327d17f9f5c7080b05d6d5457cf25550d40ad9 (patch)
tree090e66047c14c9183058bd68a8d4afc12b48f399
parent38620359537d77ab10acb888d266d3c6eb16fc9b (diff)
Add --open support for live flamegraph browser launch
-rw-r--r--internal/eventloop.go8
-rw-r--r--internal/flags/flags.go4
-rw-r--r--internal/flags/flags_test.go28
-rw-r--r--internal/flamegraph/liveserver.go79
-rw-r--r--internal/flamegraph/liveserver_open_test.go130
-rw-r--r--internal/ior.go2
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,