blob: 0ec3787bd20f9845581ed2c60ad42710a47a5254 [file] [log] [blame]
// Copyright 2024 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 crashmonitor_test
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime/debug"
"strings"
"testing"
"time"
"golang.org/x/telemetry"
"golang.org/x/telemetry/internal/counter"
"golang.org/x/telemetry/internal/crashmonitor"
"golang.org/x/telemetry/internal/testenv"
)
func TestMain(m *testing.M) {
entry := os.Getenv("CRASHMONITOR_TEST_ENTRYPOINT")
switch entry {
case "via-stderr":
// This mode bypasses Start and debug.SetCrashOutput;
// the crash is printed to stderr.
debug.SetTraceback("system")
crashmonitor.WriteSentinel(os.Stderr)
child() // this line is "TestMain:+9"
panic("unreachable")
case "start.panic", "start.exit":
// These modes uses Start and debug.SetCrashOutput.
// We stub the actual telemetry by instead writing to a file.
crashmonitor.SetIncrementCounter(func(name string) {
os.WriteFile(os.Getenv("TELEMETRY_FILE"), []byte(name), 0666)
})
crashmonitor.SetChildExitHook(func() {
os.WriteFile(os.Getenv("TELEMETRY_EXIT_FILE"), nil, 0666)
})
telemetry.Start(telemetry.Config{ReportCrashes: true})
if entry == "start.panic" {
go func() {
child() // this line is "TestMain.func2:1"
}()
select {} // deadlocks when reached
} else {
os.Exit(42)
}
default:
os.Exit(m.Run()) // run tests as normal
}
}
func child() {
fmt.Println("hello")
grandchild() // this line is "child:+2"
}
func grandchild() {
panic("oops") // this line is "grandchild:=92" (the call from child is inlined)
}
// TestViaStderr is an internal test that asserts that the telemetry
// stack generated by the panic in grandchild is correct. It uses
// stderr, and does not rely on [start.Start] or [debug.SetCrashOutput].
func TestViaStderr(t *testing.T) {
_, _, stderr := runSelf(t, "via-stderr")
got, err := crashmonitor.TelemetryCounterName(stderr)
if err != nil {
t.Fatal(err)
}
got = sanitize(counter.DecodeStack(got))
want := "crash/crash\n" +
"runtime.gopanic:--\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.grandchild:=66\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.child:+2\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.TestMain:+9\n" +
"main.main:--\n" +
"runtime.main:--\n" +
"runtime.goexit:--"
if !crashmonitor.Supported() { // !go1.23
// Before go1.23, the traceback excluded PCs for inlined frames.
want = strings.ReplaceAll(want, "golang.org/x/telemetry/internal/crashmonitor_test.child:+2\n", "")
}
if got != want {
t.Errorf("got counter name <<%s>>, want <<%s>>", got, want)
}
}
func waitForExitFile(t *testing.T, exitFile string) {
deadline := time.Now().Add(10 * time.Second)
for {
_, err := os.ReadFile(exitFile)
if err == nil {
break // success
}
if !os.IsNotExist(err) {
t.Fatalf("failed to read exit file: %v", err)
}
// The crashmonitor has not written the file yet.
// Allow it more time.
if time.Now().After(deadline) {
t.Fatalf("crashmonitor failed to write file in a timely manner")
}
time.Sleep(10 * time.Millisecond)
}
}
// TestStart is an integration test of the crashmonitor feature of [telemetry.Start].
// Requires go1.23+.
func TestStart(t *testing.T) {
if !crashmonitor.Supported() {
t.Skip("crashmonitor not supported")
}
// Assert that the crash monitor does nothing when the child
// process merely exits.
t.Run("exit", func(t *testing.T) {
telemetryFile, exitFile, _ := runSelf(t, "start.exit")
waitForExitFile(t, exitFile)
data, err := os.ReadFile(telemetryFile)
if err == nil {
t.Fatalf("telemetry counter <<%s>> was unexpectedly incremented", data)
}
})
// Assert that the crash monitor increments a telemetry
// counter of the correct name when the child process panics.
t.Run("panic", func(t *testing.T) {
// Gather a stack trace from executing the panic statement above.
telemetryFile, exitFile, _ := runSelf(t, "start.panic")
waitForExitFile(t, exitFile)
data, err := os.ReadFile(telemetryFile)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
got := sanitize(counter.DecodeStack(string(data)))
want := "crash/crash\n" +
"runtime.gopanic:--\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.grandchild:=66\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.child:+2\n" +
"golang.org/x/telemetry/internal/crashmonitor_test.TestMain.func3:+1\n" +
"runtime.goexit:--"
if got != want {
t.Errorf("got counter name <<%s>>, want <<%s>>", got, want)
}
})
}
// runSelf fork+exec's this test executable using an alternate entry point.
// It returns the child's stderr, the name of the file
// to which any incremented counter name will be written, and
// the name of the file that will be written to when the crashmonitor
// exits.
func runSelf(t *testing.T, entrypoint string) (string, string, []byte) {
testenv.MustHaveExec(t)
exe, err := os.Executable()
if err != nil {
t.Fatal(err)
}
tmpdir := t.TempDir()
// Provide the names via the environment of the files the child is stubbed
// to write to.
// The exit file is created by the crashmonitor when it is finished.
telemetryExitFile := filepath.Join(tmpdir, "exit.telemetry")
// The telemetry file will contain the name of the incremented counter.
telemetryFile := filepath.Join(tmpdir, "fake.telemetry")
cmd := exec.Command(exe)
cmd.Env = append(os.Environ(),
"CRASHMONITOR_TEST_ENTRYPOINT="+entrypoint,
"TELEMETRY_FILE="+telemetryFile,
"TELEMETRY_EXIT_FILE="+telemetryExitFile)
cmd.Stderr = new(bytes.Buffer)
cmd.Run() // failure is expected
stderr := cmd.Stderr.(*bytes.Buffer).Bytes()
if true { // debugging
t.Logf("stderr: %s", stderr)
}
return telemetryFile, telemetryExitFile, stderr
}
// sanitize redacts the line numbers that we don't control from a counter name.
func sanitize(name string) string {
lines := strings.Split(name, "\n")
for i, line := range lines {
if symbol, _, ok := strings.Cut(line, ":"); ok &&
!strings.HasPrefix(line, "golang.org/x/telemetry/internal/crashmonitor") {
lines[i] = symbol + ":--"
}
}
return strings.Join(lines, "\n")
}