2019-05-28 23:38:51 -06:00
|
|
|
// Copyright 2019 The Go Authors. All rights reserved.
|
|
|
|
// Use of this source code is governed by a BSD-style
|
|
|
|
// license that can be found in the LICENSE file.
|
|
|
|
|
|
|
|
package debug
|
|
|
|
|
|
|
|
import (
|
2019-05-29 17:58:27 -06:00
|
|
|
"bytes"
|
2019-05-28 23:38:51 -06:00
|
|
|
"context"
|
2020-02-07 06:30:31 -07:00
|
|
|
"fmt"
|
2019-05-29 12:55:52 -06:00
|
|
|
"go/token"
|
2019-05-28 23:38:51 -06:00
|
|
|
"html/template"
|
2020-02-07 06:30:31 -07:00
|
|
|
"io"
|
2019-12-17 20:12:17 -07:00
|
|
|
stdlog "log"
|
2019-05-28 23:38:51 -06:00
|
|
|
"net"
|
|
|
|
"net/http"
|
2019-06-14 16:31:00 -06:00
|
|
|
"net/http/pprof"
|
2019-05-28 23:38:51 -06:00
|
|
|
_ "net/http/pprof" // pull in the standard pprof handlers
|
2020-02-07 06:30:31 -07:00
|
|
|
"os"
|
2019-05-29 12:55:52 -06:00
|
|
|
"path"
|
2020-02-07 06:30:31 -07:00
|
|
|
"path/filepath"
|
2020-02-03 15:07:45 -07:00
|
|
|
"reflect"
|
2019-05-29 21:57:43 -06:00
|
|
|
"runtime"
|
2020-02-10 12:34:33 -07:00
|
|
|
rpprof "runtime/pprof"
|
2019-05-29 21:57:43 -06:00
|
|
|
"strconv"
|
2019-12-17 20:12:17 -07:00
|
|
|
"strings"
|
2019-05-29 12:55:52 -06:00
|
|
|
"sync"
|
2020-01-14 16:58:59 -07:00
|
|
|
"time"
|
2019-05-29 12:55:52 -06:00
|
|
|
|
2020-02-05 11:30:35 -07:00
|
|
|
"golang.org/x/tools/internal/lsp/protocol"
|
2019-05-29 12:55:52 -06:00
|
|
|
"golang.org/x/tools/internal/span"
|
2020-02-05 11:30:35 -07:00
|
|
|
"golang.org/x/tools/internal/telemetry"
|
2019-08-14 10:51:42 -06:00
|
|
|
"golang.org/x/tools/internal/telemetry/export"
|
2020-02-07 06:30:31 -07:00
|
|
|
"golang.org/x/tools/internal/telemetry/export/ocagent"
|
2019-08-14 10:51:42 -06:00
|
|
|
"golang.org/x/tools/internal/telemetry/export/prometheus"
|
2019-08-13 13:07:39 -06:00
|
|
|
"golang.org/x/tools/internal/telemetry/log"
|
|
|
|
"golang.org/x/tools/internal/telemetry/tag"
|
2019-05-29 12:55:52 -06:00
|
|
|
)
|
|
|
|
|
2020-02-28 08:30:03 -07:00
|
|
|
type exporter struct {
|
|
|
|
stderr io.Writer
|
|
|
|
}
|
|
|
|
|
|
|
|
type instanceKeyType int
|
|
|
|
|
|
|
|
const instanceKey = instanceKeyType(0)
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// An Instance holds all debug information associated with a gopls instance.
|
2020-02-06 06:27:27 -07:00
|
|
|
type Instance struct {
|
2020-02-19 08:17:48 -07:00
|
|
|
Logfile string
|
|
|
|
StartTime time.Time
|
|
|
|
ServerAddress string
|
|
|
|
DebugAddress string
|
|
|
|
ListenedDebugAddress string
|
|
|
|
Workdir string
|
|
|
|
OCAgentConfig string
|
2020-02-07 06:30:31 -07:00
|
|
|
|
|
|
|
LogWriter io.Writer
|
|
|
|
|
2020-02-28 12:08:49 -07:00
|
|
|
ocagent *ocagent.Exporter
|
2020-02-07 06:30:31 -07:00
|
|
|
prometheus *prometheus.Exporter
|
|
|
|
rpcs *rpcs
|
|
|
|
traces *traces
|
2020-02-19 08:17:48 -07:00
|
|
|
State *State
|
2020-01-14 16:58:59 -07:00
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// State holds debugging information related to the server state.
|
|
|
|
type State struct {
|
|
|
|
mu sync.Mutex
|
|
|
|
caches objset
|
|
|
|
sessions objset
|
|
|
|
views objset
|
|
|
|
clients objset
|
|
|
|
servers objset
|
|
|
|
}
|
|
|
|
|
|
|
|
type ider interface {
|
|
|
|
ID() string
|
|
|
|
}
|
|
|
|
|
|
|
|
type objset struct {
|
|
|
|
objs []ider
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *objset) add(elem ider) {
|
|
|
|
s.objs = append(s.objs, elem)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *objset) drop(elem ider) {
|
|
|
|
var newobjs []ider
|
|
|
|
for _, obj := range s.objs {
|
|
|
|
if obj.ID() != elem.ID() {
|
|
|
|
newobjs = append(newobjs, obj)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
s.objs = newobjs
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *objset) find(id string) ider {
|
|
|
|
for _, e := range s.objs {
|
|
|
|
if e.ID() == id {
|
|
|
|
return e
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Caches returns the set of Cache objects currently being served.
|
|
|
|
func (st *State) Caches() []Cache {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
caches := make([]Cache, len(st.caches.objs))
|
|
|
|
for i, c := range st.caches.objs {
|
|
|
|
caches[i] = c.(Cache)
|
|
|
|
}
|
|
|
|
return caches
|
|
|
|
}
|
|
|
|
|
|
|
|
// Sessions returns the set of Session objects currently being served.
|
|
|
|
func (st *State) Sessions() []Session {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
sessions := make([]Session, len(st.sessions.objs))
|
|
|
|
for i, s := range st.sessions.objs {
|
|
|
|
sessions[i] = s.(Session)
|
|
|
|
}
|
|
|
|
return sessions
|
|
|
|
}
|
|
|
|
|
|
|
|
// Views returns the set of View objects currently being served.
|
|
|
|
func (st *State) Views() []View {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
views := make([]View, len(st.views.objs))
|
|
|
|
for i, v := range st.views.objs {
|
|
|
|
views[i] = v.(View)
|
|
|
|
}
|
|
|
|
return views
|
|
|
|
}
|
|
|
|
|
|
|
|
// Clients returns the set of Clients currently being served.
|
|
|
|
func (st *State) Clients() []Client {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
clients := make([]Client, len(st.clients.objs))
|
|
|
|
for i, c := range st.clients.objs {
|
|
|
|
clients[i] = c.(Client)
|
|
|
|
}
|
|
|
|
return clients
|
|
|
|
}
|
|
|
|
|
|
|
|
// Servers returns the set of Servers the instance is currently connected to.
|
|
|
|
func (st *State) Servers() []Server {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
servers := make([]Server, len(st.servers.objs))
|
|
|
|
for i, s := range st.servers.objs {
|
|
|
|
servers[i] = s.(Server)
|
|
|
|
}
|
|
|
|
return servers
|
|
|
|
}
|
|
|
|
|
|
|
|
// A Client is an incoming connection from a remote client.
|
|
|
|
type Client interface {
|
|
|
|
ID() string
|
|
|
|
Session() Session
|
2020-02-19 08:18:21 -07:00
|
|
|
DebugAddress() string
|
2020-02-19 08:17:48 -07:00
|
|
|
Logfile() string
|
|
|
|
ServerID() string
|
|
|
|
}
|
|
|
|
|
|
|
|
// A Server is an outgoing connection to a remote LSP server.
|
|
|
|
type Server interface {
|
|
|
|
ID() string
|
2020-02-19 08:18:21 -07:00
|
|
|
DebugAddress() string
|
2020-02-19 08:17:48 -07:00
|
|
|
Logfile() string
|
|
|
|
ClientID() string
|
|
|
|
}
|
|
|
|
|
|
|
|
// A Cache is an in-memory cache.
|
2019-05-29 12:55:52 -06:00
|
|
|
type Cache interface {
|
|
|
|
ID() string
|
|
|
|
FileSet() *token.FileSet
|
2020-02-03 15:07:45 -07:00
|
|
|
MemStats() map[reflect.Type]int
|
2019-05-29 12:55:52 -06:00
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// A Session is an LSP serving session.
|
2019-05-29 12:55:52 -06:00
|
|
|
type Session interface {
|
|
|
|
ID() string
|
|
|
|
Cache() Cache
|
|
|
|
Files() []*File
|
|
|
|
File(hash string) *File
|
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// A View is a root directory within a Session.
|
2019-05-29 12:55:52 -06:00
|
|
|
type View interface {
|
|
|
|
ID() string
|
|
|
|
Name() string
|
|
|
|
Folder() span.URI
|
|
|
|
Session() Session
|
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// A File is is a file within a session.
|
2019-05-29 12:55:52 -06:00
|
|
|
type File struct {
|
|
|
|
Session Session
|
|
|
|
URI span.URI
|
|
|
|
Data string
|
|
|
|
Error error
|
|
|
|
Hash string
|
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// AddCache adds a cache to the set being served.
|
|
|
|
func (st *State) AddCache(cache Cache) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.caches.add(cache)
|
|
|
|
}
|
2019-05-28 23:38:51 -06:00
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// DropCache drops a cache from the set being served.
|
|
|
|
func (st *State) DropCache(cache Cache) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.caches.drop(cache)
|
2019-05-29 12:55:52 -06:00
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// AddSession adds a session to the set being served.
|
|
|
|
func (st *State) AddSession(session Session) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.sessions.add(session)
|
2019-05-29 12:55:52 -06:00
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// DropSession drops a session from the set being served.
|
|
|
|
func (st *State) DropSession(session Session) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.sessions.drop(session)
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddView adds a view to the set being served.
|
|
|
|
func (st *State) AddView(view View) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.views.add(view)
|
|
|
|
}
|
|
|
|
|
|
|
|
// DropView drops a view from the set being served.
|
|
|
|
func (st *State) DropView(view View) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.views.drop(view)
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddClient adds a client to the set being served.
|
|
|
|
func (st *State) AddClient(client Client) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.clients.add(client)
|
|
|
|
}
|
|
|
|
|
|
|
|
// DropClient adds a client to the set being served.
|
|
|
|
func (st *State) DropClient(client Client) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.clients.drop(client)
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddServer adds a server to the set being queried. In practice, there should
|
|
|
|
// be at most one remote server.
|
|
|
|
func (st *State) AddServer(server Server) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.servers.add(server)
|
|
|
|
}
|
|
|
|
|
|
|
|
// DropServer drops a server to the set being queried.
|
|
|
|
func (st *State) DropServer(server Server) {
|
|
|
|
st.mu.Lock()
|
|
|
|
defer st.mu.Unlock()
|
|
|
|
st.servers.drop(server)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Instance) getCache(r *http.Request) interface{} {
|
|
|
|
i.State.mu.Lock()
|
|
|
|
defer i.State.mu.Unlock()
|
2019-05-29 12:55:52 -06:00
|
|
|
id := path.Base(r.URL.Path)
|
2020-03-02 07:28:51 -07:00
|
|
|
c, ok := i.State.caches.find(id).(Cache)
|
|
|
|
if !ok {
|
|
|
|
return nil
|
|
|
|
}
|
2019-05-29 12:55:52 -06:00
|
|
|
result := struct {
|
|
|
|
Cache
|
|
|
|
Sessions []Session
|
2020-02-19 08:17:48 -07:00
|
|
|
}{
|
2020-03-02 07:28:51 -07:00
|
|
|
Cache: c,
|
2020-02-19 08:17:48 -07:00
|
|
|
}
|
2019-05-29 12:55:52 -06:00
|
|
|
|
|
|
|
// now find all the views that belong to this session
|
2020-02-19 08:17:48 -07:00
|
|
|
for _, vd := range i.State.sessions.objs {
|
|
|
|
v := vd.(Session)
|
2019-05-29 12:55:52 -06:00
|
|
|
if v.Cache().ID() == id {
|
|
|
|
result.Sessions = append(result.Sessions, v)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
func (i *Instance) getSession(r *http.Request) interface{} {
|
|
|
|
i.State.mu.Lock()
|
|
|
|
defer i.State.mu.Unlock()
|
2019-05-29 12:55:52 -06:00
|
|
|
id := path.Base(r.URL.Path)
|
2020-03-02 07:28:51 -07:00
|
|
|
s, ok := i.State.sessions.find(id).(Session)
|
|
|
|
if !ok {
|
|
|
|
return nil
|
|
|
|
}
|
2019-05-29 12:55:52 -06:00
|
|
|
result := struct {
|
|
|
|
Session
|
|
|
|
Views []View
|
|
|
|
}{
|
2020-03-02 07:28:51 -07:00
|
|
|
Session: s,
|
2019-05-29 12:55:52 -06:00
|
|
|
}
|
|
|
|
// now find all the views that belong to this session
|
2020-02-19 08:17:48 -07:00
|
|
|
for _, vd := range i.State.views.objs {
|
|
|
|
v := vd.(View)
|
2019-05-29 12:55:52 -06:00
|
|
|
if v.Session().ID() == id {
|
|
|
|
result.Views = append(result.Views, v)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
func (i Instance) getClient(r *http.Request) interface{} {
|
|
|
|
i.State.mu.Lock()
|
|
|
|
defer i.State.mu.Unlock()
|
|
|
|
id := path.Base(r.URL.Path)
|
2020-03-02 07:28:51 -07:00
|
|
|
c, ok := i.State.clients.find(id).(Client)
|
|
|
|
if !ok {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return c
|
2019-05-29 12:55:52 -06:00
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
func (i Instance) getServer(r *http.Request) interface{} {
|
|
|
|
i.State.mu.Lock()
|
|
|
|
defer i.State.mu.Unlock()
|
2019-05-29 12:55:52 -06:00
|
|
|
id := path.Base(r.URL.Path)
|
2020-03-02 07:28:51 -07:00
|
|
|
s, ok := i.State.servers.find(id).(Server)
|
|
|
|
if !ok {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return s
|
2019-05-29 12:55:52 -06:00
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
func (i Instance) getView(r *http.Request) interface{} {
|
|
|
|
i.State.mu.Lock()
|
|
|
|
defer i.State.mu.Unlock()
|
|
|
|
id := path.Base(r.URL.Path)
|
2020-03-02 07:28:51 -07:00
|
|
|
v, ok := i.State.views.find(id).(View)
|
|
|
|
if !ok {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return v
|
2020-02-19 08:17:48 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Instance) getFile(r *http.Request) interface{} {
|
|
|
|
i.State.mu.Lock()
|
|
|
|
defer i.State.mu.Unlock()
|
2019-05-29 12:55:52 -06:00
|
|
|
hash := path.Base(r.URL.Path)
|
|
|
|
sid := path.Base(path.Dir(r.URL.Path))
|
2020-03-02 07:28:51 -07:00
|
|
|
s, ok := i.State.sessions.find(sid).(Session)
|
|
|
|
if !ok {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return s.File(hash)
|
2019-05-29 12:55:52 -06:00
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
func (i *Instance) getInfo(r *http.Request) interface{} {
|
|
|
|
buf := &bytes.Buffer{}
|
|
|
|
i.PrintServerInfo(buf)
|
|
|
|
return template.HTML(buf.String())
|
2019-05-28 23:38:51 -06:00
|
|
|
}
|
|
|
|
|
2019-05-29 21:57:43 -06:00
|
|
|
func getMemory(r *http.Request) interface{} {
|
|
|
|
var m runtime.MemStats
|
|
|
|
runtime.ReadMemStats(&m)
|
|
|
|
return m
|
|
|
|
}
|
|
|
|
|
2020-02-28 08:30:03 -07:00
|
|
|
func init() {
|
|
|
|
export.SetExporter(&exporter{
|
|
|
|
stderr: os.Stderr,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
|
|
|
func GetInstance(ctx context.Context) *Instance {
|
|
|
|
if ctx == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
v := ctx.Value(instanceKey)
|
|
|
|
if v == nil {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return v.(*Instance)
|
|
|
|
}
|
|
|
|
|
|
|
|
// WithInstance creates debug instance ready for use using the supplied
|
|
|
|
// configuration and stores it in the returned context.
|
|
|
|
func WithInstance(ctx context.Context, workdir, agent string) context.Context {
|
2020-02-19 08:17:48 -07:00
|
|
|
i := &Instance{
|
|
|
|
StartTime: time.Now(),
|
|
|
|
Workdir: workdir,
|
|
|
|
OCAgentConfig: agent,
|
2020-01-30 16:49:04 -07:00
|
|
|
}
|
2020-02-07 06:30:31 -07:00
|
|
|
i.LogWriter = os.Stderr
|
|
|
|
ocConfig := ocagent.Discover()
|
|
|
|
//TODO: we should not need to adjust the discovered configuration
|
|
|
|
ocConfig.Address = i.OCAgentConfig
|
|
|
|
i.ocagent = ocagent.Connect(ocConfig)
|
|
|
|
i.prometheus = prometheus.New()
|
|
|
|
i.rpcs = &rpcs{}
|
|
|
|
i.traces = &traces{}
|
2020-02-19 08:17:48 -07:00
|
|
|
i.State = &State{}
|
2020-02-28 08:30:03 -07:00
|
|
|
return context.WithValue(ctx, instanceKey, i)
|
2020-02-07 06:30:31 -07:00
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// SetLogFile sets the logfile for use with this instance.
|
2020-02-19 12:25:12 -07:00
|
|
|
func (i *Instance) SetLogFile(logfile string) (func(), error) {
|
2020-02-11 10:41:14 -07:00
|
|
|
// TODO: probably a better solution for deferring closure to the caller would
|
|
|
|
// be for the debug instance to itself be closed, but this fixes the
|
|
|
|
// immediate bug of logs not being captured.
|
|
|
|
closeLog := func() {}
|
2020-02-07 06:30:31 -07:00
|
|
|
if logfile != "" {
|
|
|
|
if logfile == "auto" {
|
|
|
|
logfile = filepath.Join(os.TempDir(), fmt.Sprintf("gopls-%d.log", os.Getpid()))
|
|
|
|
}
|
|
|
|
f, err := os.Create(logfile)
|
|
|
|
if err != nil {
|
2020-02-19 12:25:12 -07:00
|
|
|
return nil, fmt.Errorf("unable to create log file: %v", err)
|
2020-02-11 10:41:14 -07:00
|
|
|
}
|
|
|
|
closeLog = func() {
|
|
|
|
defer f.Close()
|
2020-02-07 06:30:31 -07:00
|
|
|
}
|
|
|
|
stdlog.SetOutput(io.MultiWriter(os.Stderr, f))
|
|
|
|
i.LogWriter = f
|
|
|
|
}
|
|
|
|
i.Logfile = logfile
|
2020-02-19 12:25:12 -07:00
|
|
|
return closeLog, nil
|
2020-02-07 06:30:31 -07:00
|
|
|
}
|
|
|
|
|
2019-05-28 23:38:51 -06:00
|
|
|
// Serve starts and runs a debug server in the background.
|
|
|
|
// It also logs the port the server starts on, to allow for :0 auto assigned
|
|
|
|
// ports.
|
2020-02-07 06:30:31 -07:00
|
|
|
func (i *Instance) Serve(ctx context.Context) error {
|
|
|
|
if i.DebugAddress == "" {
|
2019-05-28 23:38:51 -06:00
|
|
|
return nil
|
|
|
|
}
|
2020-02-07 06:30:31 -07:00
|
|
|
listener, err := net.Listen("tcp", i.DebugAddress)
|
2019-05-28 23:38:51 -06:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2020-02-19 08:17:48 -07:00
|
|
|
i.ListenedDebugAddress = listener.Addr().String()
|
2019-12-17 20:12:17 -07:00
|
|
|
|
|
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
2020-02-07 06:30:31 -07:00
|
|
|
if strings.HasSuffix(i.DebugAddress, ":0") {
|
2019-12-17 20:12:17 -07:00
|
|
|
stdlog.Printf("debug server listening on port %d", port)
|
|
|
|
}
|
|
|
|
log.Print(ctx, "Debug serving", tag.Of("Port", port))
|
2019-05-28 23:38:51 -06:00
|
|
|
go func() {
|
2019-06-14 16:31:00 -06:00
|
|
|
mux := http.NewServeMux()
|
2020-02-19 08:17:48 -07:00
|
|
|
mux.HandleFunc("/", render(mainTmpl, func(*http.Request) interface{} { return i }))
|
2020-01-30 16:49:04 -07:00
|
|
|
mux.HandleFunc("/debug/", render(debugTmpl, nil))
|
2019-06-14 16:31:00 -06:00
|
|
|
mux.HandleFunc("/debug/pprof/", pprof.Index)
|
|
|
|
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
|
|
|
|
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
|
|
|
|
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
|
|
|
|
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
|
2020-02-07 06:30:31 -07:00
|
|
|
if i.prometheus != nil {
|
|
|
|
mux.HandleFunc("/metrics/", i.prometheus.Serve)
|
|
|
|
}
|
|
|
|
if i.rpcs != nil {
|
|
|
|
mux.HandleFunc("/rpc/", render(rpcTmpl, i.rpcs.getData))
|
|
|
|
}
|
|
|
|
if i.traces != nil {
|
|
|
|
mux.HandleFunc("/trace/", render(traceTmpl, i.traces.getData))
|
|
|
|
}
|
2020-02-19 08:17:48 -07:00
|
|
|
mux.HandleFunc("/cache/", render(cacheTmpl, i.getCache))
|
|
|
|
mux.HandleFunc("/session/", render(sessionTmpl, i.getSession))
|
|
|
|
mux.HandleFunc("/view/", render(viewTmpl, i.getView))
|
|
|
|
mux.HandleFunc("/client/", render(clientTmpl, i.getClient))
|
|
|
|
mux.HandleFunc("/server/", render(serverTmpl, i.getServer))
|
|
|
|
mux.HandleFunc("/file/", render(fileTmpl, i.getFile))
|
|
|
|
mux.HandleFunc("/info", render(infoTmpl, i.getInfo))
|
2020-01-30 16:49:04 -07:00
|
|
|
mux.HandleFunc("/memory", render(memoryTmpl, getMemory))
|
2019-06-14 16:31:00 -06:00
|
|
|
if err := http.Serve(listener, mux); err != nil {
|
2019-07-14 21:08:10 -06:00
|
|
|
log.Error(ctx, "Debug server failed", err)
|
2019-05-28 23:38:51 -06:00
|
|
|
return
|
|
|
|
}
|
2019-07-14 21:08:10 -06:00
|
|
|
log.Print(ctx, "Debug server finished")
|
2019-05-28 23:38:51 -06:00
|
|
|
}()
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
// MonitorMemory starts recording memory statistics each second.
|
2020-02-10 12:34:33 -07:00
|
|
|
func (i *Instance) MonitorMemory(ctx context.Context) {
|
|
|
|
tick := time.NewTicker(time.Second)
|
2020-02-18 13:17:40 -07:00
|
|
|
nextThresholdGiB := uint64(1)
|
2020-02-10 12:34:33 -07:00
|
|
|
go func() {
|
|
|
|
for {
|
|
|
|
<-tick.C
|
|
|
|
var mem runtime.MemStats
|
|
|
|
runtime.ReadMemStats(&mem)
|
|
|
|
if mem.HeapAlloc < nextThresholdGiB*1<<30 {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
i.writeMemoryDebug(nextThresholdGiB)
|
|
|
|
log.Print(ctx, fmt.Sprintf("Wrote memory usage debug info to %v", os.TempDir()))
|
|
|
|
nextThresholdGiB++
|
|
|
|
}
|
|
|
|
}()
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *Instance) writeMemoryDebug(threshold uint64) error {
|
|
|
|
fname := func(t string) string {
|
|
|
|
return fmt.Sprintf("gopls.%d-%dGiB-%s", os.Getpid(), threshold, t)
|
|
|
|
}
|
|
|
|
|
|
|
|
f, err := os.Create(filepath.Join(os.TempDir(), fname("heap.pb.gz")))
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer f.Close()
|
|
|
|
if err := rpprof.Lookup("heap").WriteTo(f, 0); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
f, err = os.Create(filepath.Join(os.TempDir(), fname("goroutines.txt")))
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
defer f.Close()
|
|
|
|
if err := rpprof.Lookup("goroutine").WriteTo(f, 1); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2020-02-28 08:30:03 -07:00
|
|
|
func (e *exporter) StartSpan(ctx context.Context, spn *telemetry.Span) {
|
|
|
|
i := GetInstance(ctx)
|
|
|
|
if i == nil {
|
|
|
|
return
|
|
|
|
}
|
2020-02-05 11:30:35 -07:00
|
|
|
if i.ocagent != nil {
|
|
|
|
i.ocagent.StartSpan(ctx, spn)
|
|
|
|
}
|
|
|
|
if i.traces != nil {
|
|
|
|
i.traces.StartSpan(ctx, spn)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-02-28 08:30:03 -07:00
|
|
|
func (e *exporter) FinishSpan(ctx context.Context, spn *telemetry.Span) {
|
|
|
|
i := GetInstance(ctx)
|
|
|
|
if i == nil {
|
|
|
|
return
|
|
|
|
}
|
2020-02-05 11:30:35 -07:00
|
|
|
if i.ocagent != nil {
|
|
|
|
i.ocagent.FinishSpan(ctx, spn)
|
|
|
|
}
|
|
|
|
if i.traces != nil {
|
|
|
|
i.traces.FinishSpan(ctx, spn)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-03-01 10:16:26 -07:00
|
|
|
func (e *exporter) ProcessEvent(ctx context.Context, event telemetry.Event) context.Context {
|
2020-02-28 08:30:03 -07:00
|
|
|
i := GetInstance(ctx)
|
2020-03-01 10:16:26 -07:00
|
|
|
if event.Type == telemetry.EventLog && (event.Error != nil || i == nil) {
|
2020-02-28 08:30:03 -07:00
|
|
|
fmt.Fprintf(e.stderr, "%v\n", event)
|
|
|
|
}
|
2020-03-01 10:16:26 -07:00
|
|
|
protocol.LogEvent(ctx, event)
|
2020-02-28 08:30:03 -07:00
|
|
|
if i == nil {
|
2020-03-01 10:16:26 -07:00
|
|
|
return ctx
|
2020-02-05 11:30:35 -07:00
|
|
|
}
|
|
|
|
if i.ocagent != nil {
|
2020-03-01 10:16:26 -07:00
|
|
|
ctx = i.ocagent.ProcessEvent(ctx, event)
|
2020-02-05 11:30:35 -07:00
|
|
|
}
|
2020-03-01 10:16:26 -07:00
|
|
|
return ctx
|
2020-02-05 11:30:35 -07:00
|
|
|
}
|
|
|
|
|
2020-02-28 08:30:03 -07:00
|
|
|
func (e *exporter) Metric(ctx context.Context, data telemetry.MetricData) {
|
|
|
|
i := GetInstance(ctx)
|
|
|
|
if i == nil {
|
|
|
|
return
|
|
|
|
}
|
2020-02-05 11:30:35 -07:00
|
|
|
if i.ocagent != nil {
|
|
|
|
i.ocagent.Metric(ctx, data)
|
|
|
|
}
|
|
|
|
if i.traces != nil {
|
|
|
|
i.prometheus.Metric(ctx, data)
|
|
|
|
}
|
|
|
|
if i.rpcs != nil {
|
|
|
|
i.rpcs.Metric(ctx, data)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-01-14 16:58:59 -07:00
|
|
|
type dataFunc func(*http.Request) interface{}
|
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
func render(tmpl *template.Template, fun dataFunc) func(http.ResponseWriter, *http.Request) {
|
2019-05-28 23:38:51 -06:00
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
2019-08-17 21:26:28 -06:00
|
|
|
var data interface{}
|
|
|
|
if fun != nil {
|
|
|
|
data = fun(r)
|
|
|
|
}
|
|
|
|
if err := tmpl.Execute(w, data); err != nil {
|
|
|
|
log.Error(context.Background(), "", err)
|
2020-02-19 08:17:48 -07:00
|
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
2019-08-17 21:26:28 -06:00
|
|
|
}
|
2019-05-28 23:38:51 -06:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-05-29 21:57:43 -06:00
|
|
|
func commas(s string) string {
|
|
|
|
for i := len(s); i > 3; {
|
|
|
|
i -= 3
|
|
|
|
s = s[:i] + "," + s[i:]
|
|
|
|
}
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
|
|
|
|
func fuint64(v uint64) string {
|
|
|
|
return commas(strconv.FormatUint(v, 10))
|
|
|
|
}
|
|
|
|
|
|
|
|
func fuint32(v uint32) string {
|
|
|
|
return commas(strconv.FormatUint(uint64(v), 10))
|
|
|
|
}
|
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
var baseTemplate = template.Must(template.New("").Parse(`
|
2019-05-28 23:38:51 -06:00
|
|
|
<html>
|
|
|
|
<head>
|
2019-05-29 12:55:52 -06:00
|
|
|
<title>{{template "title" .}}</title>
|
2019-05-28 23:38:51 -06:00
|
|
|
<style>
|
|
|
|
.profile-name{
|
|
|
|
display:inline-block;
|
|
|
|
width:6rem;
|
|
|
|
}
|
2019-05-29 21:57:43 -06:00
|
|
|
td.value {
|
|
|
|
text-align: right;
|
|
|
|
}
|
2019-06-26 20:46:12 -06:00
|
|
|
ul.events {
|
|
|
|
list-style-type: none;
|
|
|
|
}
|
|
|
|
|
2019-05-28 23:38:51 -06:00
|
|
|
</style>
|
2019-05-29 21:57:43 -06:00
|
|
|
{{block "head" .}}{{end}}
|
2019-05-28 23:38:51 -06:00
|
|
|
</head>
|
|
|
|
<body>
|
2019-05-29 17:58:27 -06:00
|
|
|
<a href="/">Main</a>
|
|
|
|
<a href="/info">Info</a>
|
2019-05-29 21:57:43 -06:00
|
|
|
<a href="/memory">Memory</a>
|
2019-07-09 08:55:41 -06:00
|
|
|
<a href="/metrics">Metrics</a>
|
2019-07-09 08:55:22 -06:00
|
|
|
<a href="/rpc">RPC</a>
|
2019-06-26 20:46:12 -06:00
|
|
|
<a href="/trace">Trace</a>
|
2019-05-29 17:58:27 -06:00
|
|
|
<hr>
|
|
|
|
<h1>{{template "title" .}}</h1>
|
|
|
|
{{block "body" .}}
|
2019-05-28 23:38:51 -06:00
|
|
|
Unknown page
|
|
|
|
{{end}}
|
|
|
|
</body>
|
|
|
|
</html>
|
2019-05-29 12:55:52 -06:00
|
|
|
|
|
|
|
{{define "cachelink"}}<a href="/cache/{{.}}">Cache {{.}}</a>{{end}}
|
2020-02-19 08:17:48 -07:00
|
|
|
{{define "clientlink"}}<a href="/client/{{.}}">Client {{.}}</a>{{end}}
|
|
|
|
{{define "serverlink"}}<a href="/server/{{.}}">Server {{.}}</a>{{end}}
|
2019-05-29 12:55:52 -06:00
|
|
|
{{define "sessionlink"}}<a href="/session/{{.}}">Session {{.}}</a>{{end}}
|
|
|
|
{{define "viewlink"}}<a href="/view/{{.}}">View {{.}}</a>{{end}}
|
|
|
|
{{define "filelink"}}<a href="/file/{{.Session.ID}}/{{.Hash}}">{{.URI}}</a>{{end}}
|
2019-05-29 21:57:43 -06:00
|
|
|
`)).Funcs(template.FuncMap{
|
|
|
|
"fuint64": fuint64,
|
|
|
|
"fuint32": fuint32,
|
2020-02-18 10:47:19 -07:00
|
|
|
"localAddress": func(s string) string {
|
|
|
|
// Try to translate loopback addresses to localhost, both for cosmetics and
|
|
|
|
// because unspecified ipv6 addresses can break links on Windows.
|
|
|
|
//
|
|
|
|
// TODO(rfindley): In the future, it would be better not to assume the
|
|
|
|
// server is running on localhost, and instead construct this address using
|
|
|
|
// the remote host.
|
|
|
|
host, port, err := net.SplitHostPort(s)
|
|
|
|
if err != nil {
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
ip := net.ParseIP(host)
|
|
|
|
if ip == nil {
|
|
|
|
return s
|
|
|
|
}
|
|
|
|
if ip.IsLoopback() || ip.IsUnspecified() {
|
|
|
|
return "localhost:" + port
|
|
|
|
}
|
|
|
|
return s
|
|
|
|
},
|
2019-05-29 21:57:43 -06:00
|
|
|
})
|
2019-05-28 23:38:51 -06:00
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
var mainTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
2019-05-28 23:38:51 -06:00
|
|
|
{{define "title"}}GoPls server information{{end}}
|
|
|
|
{{define "body"}}
|
2019-05-29 12:55:52 -06:00
|
|
|
<h2>Caches</h2>
|
2020-02-19 08:17:48 -07:00
|
|
|
<ul>{{range .State.Caches}}<li>{{template "cachelink" .ID}}</li>{{end}}</ul>
|
2019-05-29 12:55:52 -06:00
|
|
|
<h2>Sessions</h2>
|
2020-02-19 08:17:48 -07:00
|
|
|
<ul>{{range .State.Sessions}}<li>{{template "sessionlink" .ID}} from {{template "cachelink" .Cache.ID}}</li>{{end}}</ul>
|
2019-05-29 12:55:52 -06:00
|
|
|
<h2>Views</h2>
|
2020-02-19 08:17:48 -07:00
|
|
|
<ul>{{range .State.Views}}<li>{{.Name}} is {{template "viewlink" .ID}} from {{template "sessionlink" .Session.ID}} in {{.Folder}}</li>{{end}}</ul>
|
|
|
|
<h2>Clients</h2>
|
|
|
|
<ul>{{range .State.Clients}}<li>{{template "clientlink" .ID}}</li>{{end}}</ul>
|
|
|
|
<h2>Servers</h2>
|
|
|
|
<ul>{{range .State.Servers}}<li>{{template "serverlink" .ID}}</li>{{end}}</ul>
|
2019-05-29 17:58:27 -06:00
|
|
|
{{end}}
|
|
|
|
`))
|
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
var infoTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
2019-05-29 17:58:27 -06:00
|
|
|
{{define "title"}}GoPls version information{{end}}
|
|
|
|
{{define "body"}}
|
|
|
|
{{.}}
|
2019-05-28 23:38:51 -06:00
|
|
|
{{end}}
|
|
|
|
`))
|
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
var memoryTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
2019-05-29 21:57:43 -06:00
|
|
|
{{define "title"}}GoPls memory usage{{end}}
|
|
|
|
{{define "head"}}<meta http-equiv="refresh" content="5">{{end}}
|
|
|
|
{{define "body"}}
|
|
|
|
<h2>Stats</h2>
|
|
|
|
<table>
|
|
|
|
<tr><td class="label">Allocated bytes</td><td class="value">{{fuint64 .HeapAlloc}}</td></tr>
|
|
|
|
<tr><td class="label">Total allocated bytes</td><td class="value">{{fuint64 .TotalAlloc}}</td></tr>
|
|
|
|
<tr><td class="label">System bytes</td><td class="value">{{fuint64 .Sys}}</td></tr>
|
|
|
|
<tr><td class="label">Heap system bytes</td><td class="value">{{fuint64 .HeapSys}}</td></tr>
|
|
|
|
<tr><td class="label">Malloc calls</td><td class="value">{{fuint64 .Mallocs}}</td></tr>
|
|
|
|
<tr><td class="label">Frees</td><td class="value">{{fuint64 .Frees}}</td></tr>
|
|
|
|
<tr><td class="label">Idle heap bytes</td><td class="value">{{fuint64 .HeapIdle}}</td></tr>
|
|
|
|
<tr><td class="label">In use bytes</td><td class="value">{{fuint64 .HeapInuse}}</td></tr>
|
|
|
|
<tr><td class="label">Released to system bytes</td><td class="value">{{fuint64 .HeapReleased}}</td></tr>
|
|
|
|
<tr><td class="label">Heap object count</td><td class="value">{{fuint64 .HeapObjects}}</td></tr>
|
|
|
|
<tr><td class="label">Stack in use bytes</td><td class="value">{{fuint64 .StackInuse}}</td></tr>
|
|
|
|
<tr><td class="label">Stack from system bytes</td><td class="value">{{fuint64 .StackSys}}</td></tr>
|
|
|
|
<tr><td class="label">Bucket hash bytes</td><td class="value">{{fuint64 .BuckHashSys}}</td></tr>
|
2019-09-11 00:14:36 -06:00
|
|
|
<tr><td class="label">GC metadata bytes</td><td class="value">{{fuint64 .GCSys}}</td></tr>
|
2019-05-29 21:57:43 -06:00
|
|
|
<tr><td class="label">Off heap bytes</td><td class="value">{{fuint64 .OtherSys}}</td></tr>
|
|
|
|
</table>
|
|
|
|
<h2>By size</h2>
|
|
|
|
<table>
|
|
|
|
<tr><th>Size</th><th>Mallocs</th><th>Frees</th></tr>
|
|
|
|
{{range .BySize}}<tr><td class="value">{{fuint32 .Size}}</td><td class="value">{{fuint64 .Mallocs}}</td><td class="value">{{fuint64 .Frees}}</td></tr>{{end}}
|
|
|
|
</table>
|
|
|
|
{{end}}
|
|
|
|
`))
|
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
var debugTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
2019-05-28 23:38:51 -06:00
|
|
|
{{define "title"}}GoPls Debug pages{{end}}
|
|
|
|
{{define "body"}}
|
2019-05-29 12:55:52 -06:00
|
|
|
<a href="/debug/pprof">Profiling</a>
|
|
|
|
{{end}}
|
|
|
|
`))
|
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
var cacheTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
2019-05-29 12:55:52 -06:00
|
|
|
{{define "title"}}Cache {{.ID}}{{end}}
|
|
|
|
{{define "body"}}
|
|
|
|
<h2>Sessions</h2>
|
|
|
|
<ul>{{range .Sessions}}<li>{{template "sessionlink" .ID}}</li>{{end}}</ul>
|
2020-02-03 15:07:45 -07:00
|
|
|
<h2>memoize.Store entries</h2>
|
|
|
|
<ul>{{range $k,$v := .MemStats}}<li>{{$k}} - {{$v}}</li>{{end}}</ul>
|
2019-05-29 12:55:52 -06:00
|
|
|
{{end}}
|
|
|
|
`))
|
|
|
|
|
2020-02-19 08:17:48 -07:00
|
|
|
var clientTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
|
|
|
{{define "title"}}Client {{.ID}}{{end}}
|
|
|
|
{{define "body"}}
|
|
|
|
Using session: <b>{{template "sessionlink" .Session.ID}}</b><br>
|
2020-03-02 07:28:51 -07:00
|
|
|
{{if .DebugAddress}}Debug this client at: <a href="http://{{localAddress .DebugAddress}}">{{localAddress .DebugAddress}}</a><br>{{end}}
|
2020-02-19 08:17:48 -07:00
|
|
|
Logfile: {{.Logfile}}<br>
|
2020-02-18 10:47:19 -07:00
|
|
|
Gopls Path: {{.GoplsPath}}<br>
|
2020-02-19 08:17:48 -07:00
|
|
|
{{end}}
|
|
|
|
`))
|
|
|
|
|
|
|
|
var serverTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
|
|
|
{{define "title"}}Server {{.ID}}{{end}}
|
|
|
|
{{define "body"}}
|
2020-03-02 07:28:51 -07:00
|
|
|
{{if .DebugAddress}}Debug this server at: <a href="http://{{localAddress .DebugAddress}}">{{localAddress .DebugAddress}}</a><br>{{end}}
|
2020-02-19 08:17:48 -07:00
|
|
|
Logfile: {{.Logfile}}<br>
|
2020-02-18 10:47:19 -07:00
|
|
|
Gopls Path: {{.GoplsPath}}<br>
|
2020-02-19 08:17:48 -07:00
|
|
|
{{end}}
|
|
|
|
`))
|
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
var sessionTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
2019-05-29 12:55:52 -06:00
|
|
|
{{define "title"}}Session {{.ID}}{{end}}
|
|
|
|
{{define "body"}}
|
|
|
|
From: <b>{{template "cachelink" .Cache.ID}}</b><br>
|
|
|
|
<h2>Views</h2>
|
|
|
|
<ul>{{range .Views}}<li>{{.Name}} is {{template "viewlink" .ID}} in {{.Folder}}</li>{{end}}</ul>
|
|
|
|
<h2>Files</h2>
|
|
|
|
<ul>{{range .Files}}<li>{{template "filelink" .}}</li>{{end}}</ul>
|
|
|
|
{{end}}
|
|
|
|
`))
|
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
var viewTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
2019-05-29 12:55:52 -06:00
|
|
|
{{define "title"}}View {{.ID}}{{end}}
|
|
|
|
{{define "body"}}
|
|
|
|
Name: <b>{{.Name}}</b><br>
|
|
|
|
Folder: <b>{{.Folder}}</b><br>
|
|
|
|
From: <b>{{template "sessionlink" .Session.ID}}</b><br>
|
|
|
|
<h2>Environment</h2>
|
|
|
|
<ul>{{range .Env}}<li>{{.}}</li>{{end}}</ul>
|
|
|
|
{{end}}
|
|
|
|
`))
|
|
|
|
|
2020-01-30 16:49:04 -07:00
|
|
|
var fileTmpl = template.Must(template.Must(baseTemplate.Clone()).Parse(`
|
2019-05-29 12:55:52 -06:00
|
|
|
{{define "title"}}File {{.Hash}}{{end}}
|
|
|
|
{{define "body"}}
|
|
|
|
From: <b>{{template "sessionlink" .Session.ID}}</b><br>
|
|
|
|
URI: <b>{{.URI}}</b><br>
|
|
|
|
Hash: <b>{{.Hash}}</b><br>
|
|
|
|
Error: <b>{{.Error}}</b><br>
|
|
|
|
<h3>Contents</h3>
|
|
|
|
<pre>{{.Data}}</pre>
|
2019-05-28 23:38:51 -06:00
|
|
|
{{end}}
|
|
|
|
`))
|