Files
zero/internal/lsp/server.go
KRATOS a981120432 Audit tail: remaining medium/low correctness fixes (CLI, config, OAuth, tools, LSP, TUI) (#275)
* audit tail: fix remaining medium/low correctness findings

A sweep of the remaining audit findings across CLI, config, OAuth, tools,
LSP, and the TUI. Each fix has a regression test; build/vet/gofmt clean and
the touched packages' suites pass.

CLI
- `zero -p "<prompt>"` now forwards the prompt as the inline `--prompt=<value>`
  form so a prompt whose first character is a dash (e.g. `zero -p "-foo"`) is
  taken verbatim instead of being rejected as a missing flag value (matches the
  cron path).

Config
- mergeProfile no longer drops parseThinkTags across profile merges; an explicit
  value carries over and a nil (unset) never clobbers an existing one.

OAuth
- Refresh carries the existing token_type forward when the refresh response omits
  it (previously silently lost).
- RefreshScheduler.Stop resets started/done so the scheduler can be started again
  instead of being permanently inert after the first Stop.

Tools
- apply_patch header parsing takes the whole post-prefix value (dropping a trailing
  tab timestamp and undoing git's C-style quoting) instead of splitting on spaces,
  so a diff target whose name contains spaces is recognized.

LSP
- Server.Shutdown honors its context (kills immediately on cancel rather than
  waiting out the full grace window), and Manager.Shutdown shuts servers down
  concurrently so total shutdown is bounded by one grace window, not N×grace.

TUI
- /theme auto re-probes the terminal background (RequestBackgroundColor) instead
  of reusing a stale startup reading, so switching to auto re-detects light/dark.
- Specialist card: wire the tool-call count from progress events (was always 0)
  and omit the token segment when the total is 0 rather than show "0 tokens".
- Resume: extract the tool-call row id the same way as the result row and the
  specialist pre-pass (toolCallId first), so call/result dedup and the failed-Task
  visibility rule key on the same string.
- Plan panel: height takes an injected clock (testable, consistent with render),
  and duplicate-content steps inherit distinct prior timestamps positionally.

* tui: test that /theme auto re-probes the terminal background (M17)

The M17 fix lives in handleSubmit's command dispatch (`return m,
tea.RequestBackgroundColor` when the mode is auto), which no test exercised —
only the handleThemeCommand helper was covered. Add a dispatch-level test:
/theme auto must return the background re-probe command, /theme dark must not.
Verified to fail if the dispatch is reverted to `return m, nil`.
2026-06-20 22:21:48 +05:30

123 lines
3.5 KiB
Go

package lsp
import (
"context"
"errors"
"io"
"os"
"os/exec"
"time"
)
// shutdownGrace bounds how long Shutdown waits for the server to exit cleanly
// before killing the process.
const shutdownGrace = 2 * time.Second
// Server is a spawned language-server process plus the JSON-RPC client speaking
// to it over stdio, with the LSP initialize handshake already completed.
type Server struct {
cmd *exec.Cmd
client *Client
stdin io.WriteCloser
}
// StartServer spawns the given command, wires a Client to its stdio, and performs
// the LSP initialize/initialized handshake rooted at rootPath. On any failure the
// process is torn down before returning.
func StartServer(ctx context.Context, command []string, rootPath string) (*Server, error) {
if len(command) == 0 {
return nil, errors.New("empty language-server command")
}
cmd := exec.Command(command[0], command[1:]...)
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
// The server's stderr is diagnostic noise for our purposes; discard it so it
// can't block on a full pipe.
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
return nil, err
}
server := &Server{cmd: cmd, client: NewClient(stdout, stdin), stdin: stdin}
if err := performInitialize(ctx, server.client, rootPath); err != nil {
_ = server.Shutdown(context.Background())
return nil, err
}
return server, nil
}
// Client exposes the JSON-RPC client for document sync / diagnostics (stage 03).
func (s *Server) Client() *Client {
return s.client
}
// Shutdown sends the LSP shutdown request + exit notification, then waits briefly
// for the process to exit and kills it otherwise. Best-effort: it never returns
// an error for a server that simply ignored the handshake.
func (s *Server) Shutdown(ctx context.Context) error {
_, _ = s.client.Call(ctx, "shutdown", nil)
_ = s.client.Notify(ctx, "exit", nil)
_ = s.client.Close()
_ = s.stdin.Close()
if s.cmd == nil || s.cmd.Process == nil {
return nil
}
done := make(chan struct{})
go func() {
_ = s.cmd.Wait()
close(done)
}()
select {
case <-done:
case <-ctx.Done():
// Caller's deadline/cancellation fired: kill now instead of waiting out the
// full grace window. Without this, Shutdown could block up to shutdownGrace
// per server regardless of the context (L19).
_ = s.cmd.Process.Kill()
<-done
case <-time.After(shutdownGrace):
_ = s.cmd.Process.Kill()
<-done
}
return nil
}
// performInitialize runs the LSP handshake on an already-connected client. It is
// separate from StartServer so it can be tested against a stub server over an
// in-memory pipe without spawning a real process.
func performInitialize(ctx context.Context, client *Client, rootPath string) error {
params := InitializeParams{
ProcessID: os.Getpid(),
RootURI: PathToURI(rootPath),
Capabilities: clientCapabilities(),
}
if _, err := client.Call(ctx, "initialize", params); err != nil {
return err
}
return client.Notify(ctx, "initialized", map[string]any{})
}
// clientCapabilities advertises the minimal capabilities ZERO needs: full-text
// document sync and diagnostics via publishDiagnostics.
func clientCapabilities() map[string]any {
return map[string]any{
"textDocument": map[string]any{
"synchronization": map[string]any{
"didSave": true,
"willSave": false,
"dynamicRegistration": false,
},
"publishDiagnostics": map[string]any{
"relatedInformation": true,
},
},
}
}