From 80eb1af00dab0cc8f4440bbe1a1c3c9e74c6ae69 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 01:37:23 +0000 Subject: [PATCH] test(lsp): wait for a killed server before reading its goroutine dump Co-Authored-By: jason.han --- changes/unreleased/lsp-silent-server-stderr.fixed.md | 1 + cmd/sysml-lsp/lifecycle_test.go | 12 ++++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 changes/unreleased/lsp-silent-server-stderr.fixed.md diff --git a/changes/unreleased/lsp-silent-server-stderr.fixed.md b/changes/unreleased/lsp-silent-server-stderr.fixed.md new file mode 100644 index 000000000..b6a4b90f8 --- /dev/null +++ b/changes/unreleased/lsp-silent-server-stderr.fixed.md @@ -0,0 +1 @@ +- **The language-server lifecycle tests wait for a killed server before reading its stderr.** `TestSilentServerFailsWithinDeadline` read the captured stderr as soon as the server's stdout closed, before `os/exec` had finished copying the goroutine dump the `SIGQUIT` produced, so the dump was sometimes empty and the test failed at random under load. diff --git a/cmd/sysml-lsp/lifecycle_test.go b/cmd/sysml-lsp/lifecycle_test.go index 41612f502..0944b4de0 100644 --- a/cmd/sysml-lsp/lifecycle_test.go +++ b/cmd/sysml-lsp/lifecycle_test.go @@ -163,7 +163,7 @@ func (s *session) readBy(deadline time.Time) map[string]any { line, err := s.stdout.ReadString('\n') if err != nil { if s.killed.Load() { - s.fail("read header: %v (server killed after %s of silence)\nstderr: %s", err, time.Since(start).Round(time.Millisecond), s.stderr.String()) + s.fail("read header: %v (server killed after %s of silence)\nstderr: %s", err, time.Since(start).Round(time.Millisecond), s.killedStderr()) } else { s.fail("read header: %v\nstderr: %s", err, s.stderr.String()) } @@ -193,7 +193,7 @@ func (s *session) readBy(deadline time.Time) map[string]any { body := make([]byte, length) if _, err := io.ReadFull(s.stdout, body); err != nil { if s.killed.Load() { - s.fail("read body: %v (server killed after %s of silence)\nstderr: %s", err, time.Since(start).Round(time.Millisecond), s.stderr.String()) + s.fail("read body: %v (server killed after %s of silence)\nstderr: %s", err, time.Since(start).Round(time.Millisecond), s.killedStderr()) } else { s.fail("read body: %v", err) } @@ -224,6 +224,14 @@ func (s *session) dumpAndKill() { time.AfterFunc(5*time.Second, func() { _ = s.cmd.Process.Kill() }) } +// killedStderr returns everything a killed server wrote to stderr, goroutine dump +// included: os/exec finishes copying the pipe into cmd.Stderr only in Wait. +func (s *session) killedStderr() string { + s.t.Helper() + s.waitStatus(10 * time.Second) + return s.stderr.String() +} + // waitStatus waits for the process to end and returns its exit status. A server // still running when the timeout expires is a leaked server, and a failure. func (s *session) waitStatus(timeout time.Duration) int {