diff --git a/go.mod b/go.mod index 0f9297e..4c85806 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ toolchain go1.22.5 require ( gfx.cafe/util/go v1.14.0 + github.com/DataDog/gostackparse v0.7.0 + github.com/a-h/templ v0.2.778 github.com/alecthomas/kong v1.2.1 github.com/go-chi/chi/v5 v5.1.0 github.com/google/pprof v0.0.0-20241029010322-833c56d90c8e @@ -14,8 +16,6 @@ require ( ) require ( - github.com/chzyer/readline v1.5.1 // indirect github.com/ianlancetaylor/demangle v0.0.0-20240912202439-0a2b6291aafd // indirect - golang.org/x/sys v0.25.0 // indirect golang.org/x/text v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index 7601334..e9c1de1 100644 --- a/go.sum +++ b/go.sum @@ -1,59 +1,41 @@ -gfx.cafe/util/go v1.10.0 h1:2J2QK3lBm9lZcjlQqWCDaR+SNNSgiT/yXdRH8PenY6o= -gfx.cafe/util/go v1.10.0/go.mod h1:xzge01gF6zKOM3VztFGWuFlmlFX00pPkonZ3YU7NRSU= -gfx.cafe/util/go v1.13.0 h1:ZNBFsHVKx3Nspb6l1MTwsj/lnV1M+0AhvlHbjtfj6s0= -gfx.cafe/util/go v1.13.0/go.mod h1:69P+MuE7Bwz69cVgCTMG4adySzirioh3X5IFpE6MNcY= -gfx.cafe/util/go v1.13.1 h1:hEkAexZUlBQZSQG21HkS0l/eqeuh0rY9kjcOwjGlwKI= -gfx.cafe/util/go v1.13.1/go.mod h1:69P+MuE7Bwz69cVgCTMG4adySzirioh3X5IFpE6MNcY= gfx.cafe/util/go v1.14.0 h1:W15T4ZQSraASbl1eNZmF8pEsM435uAEIxTBzx691vRg= gfx.cafe/util/go v1.14.0/go.mod h1:s6KPm1PUsYFL00XoMOI+Y2iorVPZ+9yBc1WWUjI0n9Y= -github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU= -github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= +github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= +github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM= +github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= -github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= -github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= +github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/kong v1.2.1 h1:E8jH4Tsgv6wCRX2nGrdPyHDUCSG83WH2qE4XLACD33Q= github.com/alecthomas/kong v1.2.1/go.mod h1:rKTSFhbdp3Ryefn8x5MOEprnRFQ7nlmMC01GKhehhBM= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ= -github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI= -github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= -github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= -github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= -github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/google/pprof v0.0.0-20240528025155-186aa0362fba h1:ql1qNgCyOB7iAEk8JTNM+zJrgIbnyCKX/wdlyPufP5g= -github.com/google/pprof v0.0.0-20240528025155-186aa0362fba/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k= -github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= -github.com/google/pprof v0.0.0-20240903155634-a8630aee4ab9 h1:q5g0N9eal4bmJwXHC5z0QCKs8qhS35hFfq0BAYsIwZI= -github.com/google/pprof v0.0.0-20240903155634-a8630aee4ab9/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= -github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 h1:c5FlPPgxOn7kJz3VoPLkQYQXGBS3EklQ4Zfi57uOuqQ= -github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20241029010322-833c56d90c8e h1:v7R0PZoC2p1KWQmv1+GqCXQe59Ab1TkDF8Y9Lg2W6m4= github.com/google/pprof v0.0.0-20241029010322-833c56d90c8e/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= -github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465 h1:KwWnWVWCNtNq/ewIX7HIKnELmEx2nDP42yskD/pi7QE= -github.com/ianlancetaylor/demangle v0.0.0-20240312041847-bd984b5ce465/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= -github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1DU8ngI+aef9ZhAhNGQhcRTrWxVeG07F+c/Rw= -github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/ianlancetaylor/demangle v0.0.0-20240912202439-0a2b6291aafd h1:EVX1s+XNss9jkRW9K6XGJn2jL2lB1h5H804oKPsxOec= github.com/ianlancetaylor/demangle v0.0.0-20240912202439-0a2b6291aafd/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= -github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= -golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/goroutineinstance/instance.go b/pkg/goroutineinstance/instance.go new file mode 100644 index 0000000..fcdb138 --- /dev/null +++ b/pkg/goroutineinstance/instance.go @@ -0,0 +1,49 @@ +package goroutineinstance + +import ( + "encoding/json" + "flag" + "log" + "net/http" + "sync" + + "github.com/DataDog/gostackparse" + "github.com/a-h/templ" + "github.com/go-chi/chi/v5" + "github.com/rs/xid" + "tuxpa.in/a/pprofweb/pkg/goroutineinstance/site" +) + +type Instance struct { + id xid.ID + dat []*gostackparse.Goroutine + + mu sync.RWMutex + + once sync.Once + handler http.Handler +} + +func (i *Instance) Id() xid.ID { + return i.id +} +func (i *Instance) ServeHTTP(tw http.ResponseWriter, tr *http.Request) { + i.once.Do(func() { + r := chi.NewRouter() + indexHandler := templ.Handler(site.Index()) + r.Get("/", indexHandler.ServeHTTP) + r.Get("/raw", func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(i.dat) + }) + i.handler = r + }) + log.Printf("serve goroutine instance %s %s", i.id, tr.URL.String()) + i.handler.ServeHTTP(tw, tr) +} + +// Mostly copied from https://github.com/google/pprof/blob/master/internal/driver/flags.go +type pprofFlags struct { + args []string + s flag.FlagSet + usage []string +} diff --git a/pkg/goroutineinstance/instancestore.go b/pkg/goroutineinstance/instancestore.go new file mode 100644 index 0000000..aa00a7f --- /dev/null +++ b/pkg/goroutineinstance/instancestore.go @@ -0,0 +1,84 @@ +package goroutineinstance + +import ( + "encoding/json" + "fmt" + "time" + + "gfx.cafe/util/go/generic" + "github.com/DataDog/gostackparse" + "github.com/rs/xid" + "github.com/spf13/afero" +) + +type InstanceStore interface { + NewInstance(xs []*gostackparse.Goroutine) (*Instance, error) + GetInstance(id xid.ID) (*Instance, error) +} + +type fileBasedInstanceStore struct { + expiry time.Duration + fs afero.Fs + + cache generic.Map[xid.ID, *Instance] +} + +func NewFileBasedInstanceStore(fs afero.Fs, expiry time.Duration) *fileBasedInstanceStore { + return &fileBasedInstanceStore{ + fs: fs, + expiry: expiry, + } +} + +func (o *fileBasedInstanceStore) NewInstance(dat []*gostackparse.Goroutine) (*Instance, error) { + // save instance + id := xid.New() + i := &Instance{ + id: id, + dat: dat, + } + + jsonDat, err := json.Marshal(dat) + if err != nil { + return nil, err + } + + file, err := o.fs.Create(id.String()) + if err != nil { + return nil, err + } + _, err = file.Write(jsonDat) + if err != nil { + file.Close() + return nil, err + } + err = file.Close() + if err != nil { + return nil, err + } + o.cache.Store(id, i) + return i, nil +} + +func (o *fileBasedInstanceStore) GetInstance(id xid.ID) (*Instance, error) { + if i, ok := o.cache.Load(id); ok { + return i, nil + } + if o.expiry > 0 { + if id.Time().After(time.Now().Add(o.expiry)) { + o.fs.Remove(id.String()) + return nil, fmt.Errorf("goroutine expired") + } + } + bts, err := afero.ReadFile(o.fs, id.String()) + if err != nil { + return nil, err + } + i := &Instance{id: id} + err = json.Unmarshal(bts, &i.dat) + if err != nil { + return nil, err + } + o.cache.Store(id, i) + return i, nil +} diff --git a/pkg/goroutineinstance/site/generate.go b/pkg/goroutineinstance/site/generate.go new file mode 100644 index 0000000..f00dd96 --- /dev/null +++ b/pkg/goroutineinstance/site/generate.go @@ -0,0 +1,3 @@ +package site + +//go:generate go run github.com/a-h/templ/cmd/templ@latest generate diff --git a/pkg/goroutineinstance/site/index.templ b/pkg/goroutineinstance/site/index.templ new file mode 100644 index 0000000..6acc2f8 --- /dev/null +++ b/pkg/goroutineinstance/site/index.templ @@ -0,0 +1,11 @@ +package site + + + +templ Index() { + @layout() { +
+ gmorning +
+ } +} diff --git a/pkg/goroutineinstance/site/index_templ.go b/pkg/goroutineinstance/site/index_templ.go new file mode 100644 index 0000000..e237b18 --- /dev/null +++ b/pkg/goroutineinstance/site/index_templ.go @@ -0,0 +1,58 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package site + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Index() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
hi
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = layout().Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/pkg/goroutineinstance/site/layout.templ b/pkg/goroutineinstance/site/layout.templ new file mode 100644 index 0000000..e2ee7d5 --- /dev/null +++ b/pkg/goroutineinstance/site/layout.templ @@ -0,0 +1,16 @@ +package site + + +templ layout(contents templ.Component) { + + + + goroutine explorer + + + + @contents + + + +} diff --git a/pkg/goroutineinstance/site/layout_templ.go b/pkg/goroutineinstance/site/layout_templ.go new file mode 100644 index 0000000..7638c9e --- /dev/null +++ b/pkg/goroutineinstance/site/layout_templ.go @@ -0,0 +1,48 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package site + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func layout(contents templ.Component) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("goroutine explorer") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = contents.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/pkg/pprofweb/pprofweb.go b/pkg/pprofweb/pprofweb.go index f34c35e..7af136b 100644 --- a/pkg/pprofweb/pprofweb.go +++ b/pkg/pprofweb/pprofweb.go @@ -1,24 +1,21 @@ package pprofweb import ( + "bytes" "errors" - "flag" "fmt" "io" "log" "net/http" - "os" - "path" "strings" - "sync" "time" - "gfx.cafe/util/go/generic" + "github.com/DataDog/gostackparse" "github.com/go-chi/chi/v5" - "github.com/google/pprof/driver" "github.com/rs/xid" "github.com/spf13/afero" - "tuxpa.in/a/pprofweb/pkg/flagset" + "tuxpa.in/a/pprofweb/pkg/goroutineinstance" + "tuxpa.in/a/pprofweb/pkg/profileinstance" ) type Config struct { @@ -27,9 +24,8 @@ type Config struct { } type Server struct { - fs afero.Fs - - instances generic.Map[xid.ID, *Instance] + profiles profileinstance.InstanceStore + goroutines goroutineinstance.InstanceStore Config } @@ -37,8 +33,9 @@ type Server struct { func NewServer(fs afero.Fs, c Config) *Server { srv := afero.NewCacheOnReadFs(fs, afero.NewMemMapFs(), 24*time.Hour) s := &Server{ - fs: srv, - Config: c, + profiles: profileinstance.NewFileBasedInstanceStore(srv, c.Expire), + goroutines: goroutineinstance.NewFileBasedInstanceStore(srv, c.Expire), + Config: c, } if s.MaxUploadSize == 0 { @@ -53,21 +50,29 @@ func (s *Server) HandleHTTP() func(chi.Router) { return func(r chi.Router) { r.HandleFunc("/", rootHandler) r.HandleFunc("/upload", s.HandleUpload) - r.HandleFunc("/{xid}", s.ServeInstance) - r.HandleFunc("/{xid}/*", s.ServeInstance) - + r.Route("/profile", func(r chi.Router) { + r.Route("/{xid}", func(r chi.Router) { + r.HandleFunc("/", s.ServeProfileInstance) + r.HandleFunc("/*", s.ServeProfileInstance) + }) + }) + r.Route("/goroutine", func(r chi.Router) { + r.Route("/{xid}", func(r chi.Router) { + r.HandleFunc("/", s.ServeGoroutineInstance) + r.HandleFunc("/*", s.ServeGoroutineInstance) + }) + }) } } - -func (s *Server) ServeInstance(w http.ResponseWriter, r *http.Request) { - err := s.serveInstance(w, r) +func (s *Server) ServeGoroutineInstance(w http.ResponseWriter, r *http.Request) { + err := s.serveGoroutineInstance(w, r) if err != nil { http.Error(w, "instance not found: "+err.Error(), http.StatusNotFound) return } } -func (s *Server) serveInstance(w http.ResponseWriter, r *http.Request) error { +func (s *Server) serveGoroutineInstance(w http.ResponseWriter, r *http.Request) error { log.Printf("serveInstance %s %s", r.Method, r.URL.String()) sid := chi.URLParam(r, "xid") if sid == "" { @@ -77,7 +82,7 @@ func (s *Server) serveInstance(w http.ResponseWriter, r *http.Request) error { if err != nil { return errors.New("invalid id") } - inst, err := s.GetInstance(id) + inst, err := s.goroutines.GetInstance(id) if err != nil { return errors.New("instance not found: " + err.Error()) } @@ -85,56 +90,30 @@ func (s *Server) serveInstance(w http.ResponseWriter, r *http.Request) error { return nil } -func (s *Server) NewInstance(xs []byte) (*Instance, error) { - // save instance - id := xid.New() - i := &Instance{ - id: id, - dat: xs, - } - - file, err := s.fs.Create(id.String()) +func (s *Server) ServeProfileInstance(w http.ResponseWriter, r *http.Request) { + err := s.serveProfileInstance(w, r) if err != nil { - return nil, err + http.Error(w, "instance not found: "+err.Error(), http.StatusNotFound) + return } - _, err = file.Write(xs) - if err != nil { - file.Close() - return nil, err - } - err = file.Close() - if err != nil { - return nil, err - } - err = i.startServer() - if err != nil { - return nil, err - } - s.instances.Store(id, i) - return i, nil } -func (s *Server) GetInstance(id xid.ID) (*Instance, error) { - if i, ok := s.instances.Load(id); ok { - return i, nil +func (s *Server) serveProfileInstance(w http.ResponseWriter, r *http.Request) error { + log.Printf("serveInstance %s %s", r.Method, r.URL.String()) + sid := chi.URLParam(r, "xid") + if sid == "" { + return errors.New("no id sent") } - if s.Expire > 0 { - if id.Time().After(time.Now().Add(s.Expire)) { - s.fs.Remove(id.String()) - return nil, fmt.Errorf("profile expired") - } - } - bts, err := afero.ReadFile(s.fs, id.String()) + id, err := xid.FromString(sid) if err != nil { - return nil, err + return errors.New("invalid id") } - i := &Instance{id: id, dat: bts} - err = i.startServer() + inst, err := s.profiles.GetInstance(id) if err != nil { - return nil, err + return errors.New("instance not found: " + err.Error()) } - s.instances.Store(id, i) - return i, nil + inst.ServeHTTP(w, r) + return nil } func (s *Server) HandleUpload(w http.ResponseWriter, r *http.Request) { @@ -165,66 +144,40 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) error { return err } - instance, err := s.NewInstance(bts) - if err != nil { - return err + // sanity check that allows us to be a little lazy. + if len(bts) < 128 { + return errors.New("profile too small") + } + + var redirString string + // check if it is a goroutine dump + if bytes.HasPrefix(bts, []byte("goroutine")) { + // goroutine dump. we should immediately try to parse it + routines, errs := gostackparse.Parse(bytes.NewReader(bts)) + if errs != nil { + return fmt.Errorf("error parsing goroutine dump: %w", err) + } + if len(routines) == 0 { + return errors.New("no goroutines found") + } + instance, err := s.goroutines.NewInstance(routines) + if err != nil { + return err + } + redirString = "goroutine/" + instance.Id().String() + } else { + instance, err := s.profiles.NewInstance(bts) + if err != nil { + return err + } + redirString = "profile/" + instance.Id().String() } for _, h := range r.Header.Values("Accept") { if strings.Contains(h, "text/html") { - http.Redirect(w, r, instance.id.String()+"/", http.StatusSeeOther) + http.Redirect(w, r, redirString, http.StatusSeeOther) return nil } } - w.Write([]byte(instance.id.String())) + w.Write([]byte(redirString)) return nil } - -type Instance struct { - id xid.ID - dat []byte - - handler http.Handler - - mu sync.RWMutex -} - -func (i *Instance) startServer() error { - file, err := os.CreateTemp("", i.id.String()) - if err != nil { - return err - } - file.Write(i.dat) - flags := &flagset.Set{ - Argz: []string{"-http=localhost:0", "-no_browser", file.Name()}, - } - options := &driver.Options{ - Flagset: flags, - HTTPServer: i.startHTTP, - } - if err := driver.PProf(options); err != nil { - return err - } - return nil -} -func (i *Instance) startHTTP(args *driver.HTTPServerArgs) error { - r := chi.NewRouter() - for pattern, handler := range args.Handlers { - jpat := "/" + path.Join(i.id.String(), pattern) - r.Handle(jpat, handler) - r.Handle(jpat+"/", handler) - } - i.handler = r - return nil -} - -func (i *Instance) ServeHTTP(w http.ResponseWriter, r *http.Request) { - log.Printf("serveInstanceHTTP %s %s", i.id, r.URL.String()) - i.handler.ServeHTTP(w, r) -} - -// Mostly copied from https://github.com/google/pprof/blob/master/internal/driver/flags.go -type pprofFlags struct { - args []string - s flag.FlagSet - usage []string -} diff --git a/pkg/profileinstance/instance.go b/pkg/profileinstance/instance.go new file mode 100644 index 0000000..9695033 --- /dev/null +++ b/pkg/profileinstance/instance.go @@ -0,0 +1,69 @@ +package profileinstance + +import ( + "flag" + "log" + "net/http" + "os" + "path" + "sync" + + "github.com/go-chi/chi/v5" + "github.com/google/pprof/driver" + "github.com/rs/xid" + "tuxpa.in/a/pprofweb/pkg/flagset" +) + +type Instance struct { + id xid.ID + dat []byte + + handler http.Handler + + mu sync.RWMutex +} + +func (i *Instance) Id() xid.ID { + return i.id +} + +func (i *Instance) startServer() error { + file, err := os.CreateTemp("", i.id.String()) + if err != nil { + return err + } + file.Write(i.dat) + flags := &flagset.Set{ + Argz: []string{"-http=localhost.invalid:0", "-no_browser", file.Name()}, + } + options := &driver.Options{ + Flagset: flags, + HTTPServer: i.startHTTP, + } + if err := driver.PProf(options); err != nil { + return err + } + return nil +} +func (i *Instance) startHTTP(args *driver.HTTPServerArgs) error { + r := chi.NewRouter() + for pattern, handler := range args.Handlers { + jpat := path.Join(pattern) + r.Handle(jpat, handler) + r.Handle(jpat+"/", handler) + } + i.handler = r + return nil +} + +func (i *Instance) ServeHTTP(w http.ResponseWriter, r *http.Request) { + log.Printf("serveInstanceHTTP %s %s", i.id, r.URL.String()) + i.handler.ServeHTTP(w, r) +} + +// Mostly copied from https://github.com/google/pprof/blob/master/internal/driver/flags.go +type pprofFlags struct { + args []string + s flag.FlagSet + usage []string +} diff --git a/pkg/profileinstance/instancestore.go b/pkg/profileinstance/instancestore.go new file mode 100644 index 0000000..5e9a133 --- /dev/null +++ b/pkg/profileinstance/instancestore.go @@ -0,0 +1,81 @@ +package profileinstance + +import ( + "fmt" + "time" + + "gfx.cafe/util/go/generic" + "github.com/rs/xid" + "github.com/spf13/afero" +) + +type InstanceStore interface { + NewInstance(xs []byte) (*Instance, error) + GetInstance(id xid.ID) (*Instance, error) +} + +type fileBasedInstanceStore struct { + expiry time.Duration + fs afero.Fs + + cache generic.Map[xid.ID, *Instance] +} + +func NewFileBasedInstanceStore(fs afero.Fs, expiry time.Duration) *fileBasedInstanceStore { + return &fileBasedInstanceStore{ + fs: fs, + expiry: expiry, + } +} + +func (o *fileBasedInstanceStore) NewInstance(xs []byte) (*Instance, error) { + // save instance + id := xid.New() + i := &Instance{ + id: id, + dat: xs, + } + + file, err := o.fs.Create(id.String()) + if err != nil { + return nil, err + } + _, err = file.Write(xs) + if err != nil { + file.Close() + return nil, err + } + err = file.Close() + if err != nil { + return nil, err + } + err = i.startServer() + if err != nil { + return nil, err + } + o.cache.Store(id, i) + return i, nil +} + +func (o *fileBasedInstanceStore) GetInstance(id xid.ID) (*Instance, error) { + if i, ok := o.cache.Load(id); ok { + return i, nil + } + if o.expiry > 0 { + if id.Time().After(time.Now().Add(o.expiry)) { + o.fs.Remove(id.String()) + return nil, fmt.Errorf("profile expired") + } + } + bts, err := afero.ReadFile(o.fs, id.String()) + if err != nil { + return nil, err + } + i := &Instance{id: id, dat: bts} + err = i.startServer() + if err != nil { + return nil, err + } + o.cache.Store(id, i) + return i, nil +}