Skip to content

Commit 233aed7

Browse files
authored
Add --debug-address flag for debug and profiling of the LSP (#4437)
1 parent 1254fac commit 233aed7

3 files changed

Lines changed: 352 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Fix build failures for modules with a vendored `descriptor.proto`.
66
- Fix LSP incorrectly reporting "edition '2024' not yet fully supported" errors.
77
- Fix CEL compilation error messages in `buf lint` to use the structured error API instead of parsing cel-go's text output.
8+
- Add `--debug-address` flag to `buf lsp serve` to provide debug and profile support.
89

910
## [v1.68.1] - 2026-04-14
1011

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
// Copyright 2020-2026 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package lspserve
16+
17+
import (
18+
"fmt"
19+
"html/template"
20+
"net"
21+
"net/http"
22+
"net/http/pprof"
23+
"os"
24+
"runtime"
25+
"runtime/debug"
26+
"strconv"
27+
"time"
28+
29+
"github.com/bufbuild/buf/private/pkg/transport/http/httpserver"
30+
)
31+
32+
// debugServer is an HTTP server that serves debug information about the
33+
// running LSP server.
34+
type debugServer struct {
35+
version string
36+
startTime time.Time
37+
38+
listener net.Listener
39+
server *http.Server
40+
}
41+
42+
// newDebugServer creates and starts a debug HTTP server on the given address.
43+
// The address format is "host:port", e.g. "localhost:6060" or ":0" for an
44+
// OS-assigned port.
45+
func newDebugServer(addr string, version string) (*debugServer, error) {
46+
listener, err := net.Listen("tcp", addr)
47+
if err != nil {
48+
return nil, fmt.Errorf("could not start debug server: %w", err)
49+
}
50+
51+
// Sample 1-in-1000 blocking events and 1-in-10 mutex contention events.
52+
// Rate 1 would trace every event and add measurable overhead to the LSP.
53+
runtime.SetBlockProfileRate(1000)
54+
runtime.SetMutexProfileFraction(10)
55+
56+
ds := &debugServer{
57+
version: version,
58+
startTime: time.Now(),
59+
listener: listener,
60+
}
61+
62+
mux := http.NewServeMux()
63+
mux.HandleFunc("/", ds.render(mainTmpl, ds.getMain))
64+
mux.HandleFunc("/info", ds.render(infoTmpl, ds.getInfo))
65+
mux.HandleFunc("/memory", ds.render(memoryTmpl, ds.getMemory))
66+
mux.HandleFunc("/debug/pprof/", pprof.Index)
67+
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
68+
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
69+
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
70+
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
71+
72+
ds.server = &http.Server{
73+
Handler: mux,
74+
ReadHeaderTimeout: httpserver.DefaultReadHeaderTimeout,
75+
IdleTimeout: httpserver.DefaultIdleTimeout,
76+
}
77+
go func() { _ = ds.server.Serve(listener) }()
78+
79+
return ds, nil
80+
}
81+
82+
// Addr returns the address the debug server is listening on.
83+
func (ds *debugServer) Addr() net.Addr {
84+
return ds.listener.Addr()
85+
}
86+
87+
// Close shuts down the debug server.
88+
func (ds *debugServer) Close() error {
89+
return ds.server.Close()
90+
}
91+
92+
type serverInfo struct {
93+
Version string
94+
StartTime time.Time
95+
Uptime string
96+
PID int
97+
GoVersion string
98+
GOOS string
99+
GOARCH string
100+
NumCPU int
101+
GOMAXPROCS int
102+
NumGoroutine int
103+
BuildInfo string
104+
}
105+
106+
func (ds *debugServer) getServerInfo() serverInfo {
107+
uptime := time.Since(ds.startTime).Truncate(time.Second)
108+
var buildInfoStr string
109+
if bi, ok := debug.ReadBuildInfo(); ok {
110+
buildInfoStr = bi.String()
111+
}
112+
return serverInfo{
113+
Version: ds.version,
114+
GoVersion: runtime.Version(),
115+
GOOS: runtime.GOOS,
116+
GOARCH: runtime.GOARCH,
117+
PID: os.Getpid(),
118+
StartTime: ds.startTime,
119+
Uptime: uptime.String(),
120+
NumCPU: runtime.NumCPU(),
121+
GOMAXPROCS: runtime.GOMAXPROCS(0),
122+
NumGoroutine: runtime.NumGoroutine(),
123+
BuildInfo: buildInfoStr,
124+
}
125+
}
126+
127+
func (ds *debugServer) getMain(_ *http.Request) any {
128+
return ds.getServerInfo()
129+
}
130+
131+
func (ds *debugServer) getInfo(_ *http.Request) any {
132+
return ds.getServerInfo()
133+
}
134+
135+
func (ds *debugServer) getMemory(_ *http.Request) any {
136+
var m runtime.MemStats
137+
runtime.ReadMemStats(&m)
138+
return m
139+
}
140+
141+
type dataFunc func(*http.Request) any
142+
143+
func (ds *debugServer) render(tmpl *template.Template, fun dataFunc) http.HandlerFunc {
144+
return func(w http.ResponseWriter, r *http.Request) {
145+
if err := tmpl.Execute(w, fun(r)); err != nil {
146+
http.Error(w, err.Error(), http.StatusInternalServerError)
147+
}
148+
}
149+
}
150+
151+
// commas formats a non-negative integer string with comma separators.
152+
func commas(s string) string {
153+
for i := len(s); i > 3; {
154+
i -= 3
155+
s = s[:i] + "," + s[i:]
156+
}
157+
return s
158+
}
159+
160+
func fuint64(v uint64) string {
161+
return commas(strconv.FormatUint(v, 10))
162+
}
163+
164+
func fuint32(v uint32) string {
165+
return commas(strconv.FormatUint(uint64(v), 10))
166+
}
167+
168+
var baseTmpl = template.Must(template.New("").Funcs(template.FuncMap{
169+
"fuint64": fuint64,
170+
"fuint32": fuint32,
171+
}).Parse(`
172+
<html>
173+
<head>
174+
<title>{{template "title" .}}</title>
175+
<style>
176+
body {
177+
font-family: sans-serif;
178+
font-size: 1rem;
179+
line-height: 1.6;
180+
margin: 0;
181+
padding: 0;
182+
}
183+
nav {
184+
background: #2d2d2d;
185+
padding: 0.5rem 1rem;
186+
}
187+
nav a {
188+
color: #fff;
189+
text-decoration: none;
190+
margin-right: 1.5rem;
191+
font-size: 0.9rem;
192+
}
193+
nav a:hover {
194+
text-decoration: underline;
195+
}
196+
.content {
197+
padding: 1rem 2rem;
198+
}
199+
table {
200+
border-collapse: collapse;
201+
margin: 0.5rem 0;
202+
}
203+
td, th {
204+
padding: 0.25rem 0.75rem;
205+
text-align: left;
206+
border-bottom: 1px solid #eee;
207+
}
208+
td.value {
209+
text-align: right;
210+
font-family: monospace;
211+
}
212+
th {
213+
border-bottom: 2px solid #ddd;
214+
font-weight: 600;
215+
}
216+
pre {
217+
background: #f5f5f5;
218+
padding: 1rem;
219+
overflow-x: auto;
220+
font-size: 0.85rem;
221+
}
222+
h1 { margin-top: 0; }
223+
.label { color: #555; }
224+
</style>
225+
{{block "head" .}}{{end}}
226+
</head>
227+
<body>
228+
<nav>
229+
<a href="/">Main</a>
230+
<a href="/info">Info</a>
231+
<a href="/memory">Memory</a>
232+
<a href="/debug/pprof">Profiling</a>
233+
</nav>
234+
<div class="content">
235+
<h1>{{template "title" .}}</h1>
236+
{{block "body" .}}
237+
Unknown page
238+
{{end}}
239+
</div>
240+
</body>
241+
</html>
242+
`))
243+
244+
var mainTmpl = template.Must(template.Must(baseTmpl.Clone()).Parse(`
245+
{{define "title"}}Buf LSP Debug{{end}}
246+
{{define "body"}}
247+
<h2>Server</h2>
248+
<table>
249+
<tr><td class="label">Version</td><td>{{.Version}}</td></tr>
250+
<tr><td class="label">Go version</td><td>{{.GoVersion}}</td></tr>
251+
<tr><td class="label">Platform</td><td>{{.GOOS}}/{{.GOARCH}}</td></tr>
252+
<tr><td class="label">PID</td><td>{{.PID}}</td></tr>
253+
<tr><td class="label">Started</td><td>{{.StartTime.Format "2006-01-02 15:04:05"}}</td></tr>
254+
<tr><td class="label">Uptime</td><td>{{.Uptime}}</td></tr>
255+
</table>
256+
257+
<h2>Debug Pages</h2>
258+
<ul>
259+
<li><a href="/info">Server info and build details</a></li>
260+
<li><a href="/memory">Memory usage</a></li>
261+
<li><a href="/debug/pprof">Profiling (pprof)</a></li>
262+
</ul>
263+
264+
<h2>Profiles</h2>
265+
<ul>
266+
<li><a href="/debug/pprof/goroutine?debug=1">Goroutines</a></li>
267+
<li><a href="/debug/pprof/heap?debug=1">Heap</a></li>
268+
<li><a href="/debug/pprof/allocs?debug=1">Allocs</a></li>
269+
<li><a href="/debug/pprof/block?debug=1">Block</a></li>
270+
<li><a href="/debug/pprof/mutex?debug=1">Mutex</a></li>
271+
<li><a href="/debug/pprof/threadcreate?debug=1">Thread create</a></li>
272+
</ul>
273+
{{end}}
274+
`))
275+
276+
var infoTmpl = template.Must(template.Must(baseTmpl.Clone()).Parse(`
277+
{{define "title"}}Buf LSP Info{{end}}
278+
{{define "body"}}
279+
<h2>Server</h2>
280+
<table>
281+
<tr><td class="label">Version</td><td>{{.Version}}</td></tr>
282+
<tr><td class="label">Go version</td><td>{{.GoVersion}}</td></tr>
283+
<tr><td class="label">Platform</td><td>{{.GOOS}}/{{.GOARCH}}</td></tr>
284+
<tr><td class="label">PID</td><td>{{.PID}}</td></tr>
285+
<tr><td class="label">Started</td><td>{{.StartTime.Format "2006-01-02 15:04:05"}}</td></tr>
286+
<tr><td class="label">Uptime</td><td>{{.Uptime}}</td></tr>
287+
<tr><td class="label">NumCPU</td><td>{{.NumCPU}}</td></tr>
288+
<tr><td class="label">GOMAXPROCS</td><td>{{.GOMAXPROCS}}</td></tr>
289+
<tr><td class="label">Goroutines</td><td>{{.NumGoroutine}}</td></tr>
290+
</table>
291+
292+
{{if .BuildInfo}}
293+
<h2>Build Info</h2>
294+
<pre>{{.BuildInfo}}</pre>
295+
{{end}}
296+
{{end}}
297+
`))
298+
299+
var memoryTmpl = template.Must(template.Must(baseTmpl.Clone()).Parse(`
300+
{{define "title"}}Buf LSP Memory{{end}}
301+
{{define "body"}}
302+
<h2>Stats</h2>
303+
<table>
304+
<tr><td class="label">Allocated bytes</td><td class="value">{{fuint64 .HeapAlloc}}</td></tr>
305+
<tr><td class="label">Total allocated bytes</td><td class="value">{{fuint64 .TotalAlloc}}</td></tr>
306+
<tr><td class="label">System bytes</td><td class="value">{{fuint64 .Sys}}</td></tr>
307+
<tr><td class="label">Heap system bytes</td><td class="value">{{fuint64 .HeapSys}}</td></tr>
308+
<tr><td class="label">Malloc calls</td><td class="value">{{fuint64 .Mallocs}}</td></tr>
309+
<tr><td class="label">Frees</td><td class="value">{{fuint64 .Frees}}</td></tr>
310+
<tr><td class="label">Idle heap bytes</td><td class="value">{{fuint64 .HeapIdle}}</td></tr>
311+
<tr><td class="label">In use bytes</td><td class="value">{{fuint64 .HeapInuse}}</td></tr>
312+
<tr><td class="label">Released to system bytes</td><td class="value">{{fuint64 .HeapReleased}}</td></tr>
313+
<tr><td class="label">Heap object count</td><td class="value">{{fuint64 .HeapObjects}}</td></tr>
314+
<tr><td class="label">Stack in use bytes</td><td class="value">{{fuint64 .StackInuse}}</td></tr>
315+
<tr><td class="label">Stack from system bytes</td><td class="value">{{fuint64 .StackSys}}</td></tr>
316+
<tr><td class="label">Bucket hash bytes</td><td class="value">{{fuint64 .BuckHashSys}}</td></tr>
317+
<tr><td class="label">GC metadata bytes</td><td class="value">{{fuint64 .GCSys}}</td></tr>
318+
<tr><td class="label">Off heap bytes</td><td class="value">{{fuint64 .OtherSys}}</td></tr>
319+
</table>
320+
<h2>By Size</h2>
321+
<table>
322+
<tr><th>Size</th><th>Mallocs</th><th>Frees</th></tr>
323+
{{range .BySize}}<tr><td class="value">{{fuint32 .Size}}</td><td class="value">{{fuint64 .Mallocs}}</td><td class="value">{{fuint64 .Frees}}</td></tr>{{end}}
324+
</table>
325+
{{end}}
326+
`))

cmd/buf/internal/command/lsp/lspserve/lspserve.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"errors"
2323
"fmt"
2424
"io"
25+
"log/slog"
2526
"net"
2627

2728
"buf.build/go/app/appcmd"
@@ -36,7 +37,8 @@ import (
3637

3738
const (
3839
// pipe is chosen because that's what the vscode LSP client expects.
39-
pipeFlagName = "pipe"
40+
pipeFlagName = "pipe"
41+
debugAddressFlagName = "debug-address"
4042
)
4143

4244
// NewCommand constructs the CLI command for executing the LSP.
@@ -69,6 +71,8 @@ func NewCommand(
6971
type flags struct {
7072
// A file path to a UNIX socket to use for IPC. If empty, stdio is used instead.
7173
PipePath string
74+
// An address (host:port) to serve the debug server on. If empty, no debug server is started.
75+
DebugAddress string
7276
}
7377

7478
// Bind sets up the CLI flags that the LSP needs.
@@ -79,6 +83,12 @@ func (f *flags) Bind(flagSet *pflag.FlagSet) {
7983
"",
8084
"path to a UNIX socket to listen on; uses stdio if not specified",
8185
)
86+
flagSet.StringVar(
87+
&f.DebugAddress,
88+
debugAddressFlagName,
89+
"",
90+
"address to serve debug endpoints on (e.g. localhost:6060); disabled if not specified",
91+
)
8292
}
8393

8494
func newFlags() *flags {
@@ -91,6 +101,20 @@ func run(
91101
container appext.Container,
92102
flags *flags,
93103
) (retErr error) {
104+
if flags.DebugAddress != "" {
105+
server, err := newDebugServer(flags.DebugAddress, bufcli.Version)
106+
if err != nil {
107+
return err
108+
}
109+
container.Logger().Info(
110+
"debug server listening",
111+
slog.String("address", server.Addr().String()),
112+
)
113+
defer func() {
114+
retErr = errors.Join(retErr, server.Close())
115+
}()
116+
}
117+
94118
transport, err := dial(container, flags)
95119
if err != nil {
96120
return err

0 commit comments

Comments
 (0)