From fd3aec053f3f2198a3d1f3c6dc9b9b4c1f743e46 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:03:10 -0800 Subject: [PATCH 1/2] Exit LSP process if parent process stops existing --- cmd/tsgo/lsp.go | 48 +++++++++++++++++++++++++++++++++++++++--- internal/lsp/server.go | 12 ++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/cmd/tsgo/lsp.go b/cmd/tsgo/lsp.go index ae3d7ec7689..47fea03e0d7 100644 --- a/cmd/tsgo/lsp.go +++ b/cmd/tsgo/lsp.go @@ -9,6 +9,7 @@ import ( "os/signal" "runtime" "syscall" + "time" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/core" @@ -45,6 +46,9 @@ func runLSP(args []string) int { defaultLibraryPath := bundled.LibPath() typingsLocation := getGlobalTypingsCacheLocation() + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + s := lsp.NewServer(&lsp.ServerOptions{ In: lsp.ToReader(os.Stdin), Out: lsp.ToWriter(os.Stdout), @@ -58,11 +62,11 @@ func runLSP(args []string) int { cmd.Dir = cwd return cmd.Output() }, + SetParentProcessId: func(parentPID int) { + startParentProcessWatchdog(ctx, parentPID) + }, }) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - if err := s.Run(ctx); err != nil { fmt.Fprintln(os.Stderr, err) return 1 @@ -136,3 +140,41 @@ func getNonWindowsCacheLocation() string { } return tspath.CombinePaths(homePath, cacheFolder) } + +// startParentProcessWatchdog starts a goroutine that monitors the parent process +// and exits the current process if the parent dies. This prevents orphaned language +// server processes when the editor crashes or is killed. +func startParentProcessWatchdog(ctx context.Context, parentPID int) { + if parentPID <= 0 { + return + } + go func() { + tick := time.Tick(5 * time.Second) + for { + select { + case <-ctx.Done(): + return + case <-tick: + if !isProcessAlive(parentPID) { + fmt.Fprintf(os.Stderr, "Parent process %d has exited, shutting down.\n", parentPID) + os.Exit(1) + } + } + } + }() +} + +// isProcessAlive checks if a process with the given PID is still running. +func isProcessAlive(pid int) bool { + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + // On Unix, FindProcess always succeeds. We need to send signal 0 to check if + // the process exists. On Windows, FindProcess already validates the process exists. + if runtime.GOOS == "windows" { + return true + } + err = proc.Signal(syscall.Signal(0)) + return err == nil +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 21b7fd85bb2..2b10e02cb3e 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -39,6 +39,9 @@ type ServerOptions struct { TypingsLocation string ParseCache *project.ParseCache NpmInstall func(cwd string, args []string) ([]byte, error) + // SetParentProcessId is called once the server knows its parent process ID. + // The callback should start monitoring the parent and exit the process if the parent dies. + SetParentProcessId func(parentPID int) } func NewServer(opts *ServerOptions) *Server { @@ -60,6 +63,7 @@ func NewServer(opts *ServerOptions) *Server { typingsLocation: opts.TypingsLocation, parseCache: opts.ParseCache, npmInstall: opts.NpmInstall, + startWatchdog: opts.SetParentProcessId, initComplete: make(chan struct{}), } s.logger = newLogger(s) @@ -172,7 +176,8 @@ type Server struct { // parseCache can be passed in so separate tests can share ASTs parseCache *project.ParseCache - npmInstall func(cwd string, args []string) ([]byte, error) + npmInstall func(cwd string, args []string) ([]byte, error) + startWatchdog func(parentPID int) } func (s *Server) Session() *project.Session { return s.session } @@ -779,6 +784,11 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ s.logger.SetVerbose(true) } + // Start watching the parent process if a watchdog is configured and parent PID is provided. + if s.startWatchdog != nil && params.ProcessId.Integer != nil { + s.startWatchdog(int(*params.ProcessId.Integer)) + } + response := &lsproto.InitializeResult{ ServerInfo: &lsproto.ServerInfo{ Name: "typescript-go", From fc0f0fb0080bac567f5ad3be82194410b76502cb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 12 Jan 2026 11:06:00 -0800 Subject: [PATCH 2/2] Silly comments --- internal/lsp/server.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 2b10e02cb3e..770e9a808cf 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -39,8 +39,6 @@ type ServerOptions struct { TypingsLocation string ParseCache *project.ParseCache NpmInstall func(cwd string, args []string) ([]byte, error) - // SetParentProcessId is called once the server knows its parent process ID. - // The callback should start monitoring the parent and exit the process if the parent dies. SetParentProcessId func(parentPID int) } @@ -784,7 +782,6 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ s.logger.SetVerbose(true) } - // Start watching the parent process if a watchdog is configured and parent PID is provided. if s.startWatchdog != nil && params.ProcessId.Integer != nil { s.startWatchdog(int(*params.ProcessId.Integer)) }