lsp: send processId null, because a pid is only a name in its own namespace

TypeScript, JavaScript and Python answered exactly one question per root and
then went silent. PHP managed two. The daemon reported `server stopped`, and
0.1.10 finally attached the missing half of that sentence: `[exit status 1]`.
Not a signal, so nothing killed them — they LEFT.

processId is a watchdog. The spec says a server whose client process has died
should exit, and every server built on vscode-languageserver implements it by
polling kill(pid, 0) every few seconds. This daemon sent its own pid, which
means something only inside its own pid namespace — and the jail gives each
server a NEW one, where that number belongs to nothing at all. So each of them
looked for its client, correctly found nothing, and exited. They were obeying
the protocol; we were the ones lying to them.

null is the spec's way to say nobody is watching, and the jail is what makes it
free: the server is PID 1 of its namespace, so Close kills it and everything it
forked, and a daemon that dies takes the whole namespace with it. The lifetime
was never the pid's to enforce.

gopls, clangd, jdtls and ruby-lsp do not poll, which is why Go, C, C++, Java and
Ruby scored 5/5 the whole time and the fault read as TypeScript's. It was never
about package.json, or a lockfile, or address space.

Measured, not assumed: the acceptance probe now fires five sequential queries at
one WARM root per language. Asking once proves a server can resolve and proves
nothing about whether it is still there for the second question — which is the
state every caller is in after the first, and the entire reason the pool exists.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-08-09 23:58:04 -07:00
co-authored by Hanzo Dev
parent 0060fa0179
commit ebe2034722
3 changed files with 65 additions and 2 deletions
+1 -1
View File
@@ -76,7 +76,7 @@ test:
# a number at or below what is published publishes the next patch above the
# registry. So this line is the floor, not the output, and it can never mint a
# tag that already covers different bytes.
version: 0.1.10
version: 0.1.11
images:
- name: lsp
+17 -1
View File
@@ -238,7 +238,23 @@ func (c *Conn) handshake(ctx context.Context, l Lang, root string) error {
uri := pathURI(root)
params := map[string]any{
"processId": os.Getpid(),
// NULL, and this is the whole reason TypeScript, JavaScript and Python
// answered exactly one question each and then vanished.
//
// processId is a WATCHDOG: the spec says a server whose parent has died
// should exit, and every server built on vscode-languageserver implements
// that by polling `kill(pid, 0)` every few seconds. This daemon used to
// send its own pid — which is a number in ITS pid namespace, and the
// server runs in a jail with a NEW ONE, where that number belongs to
// nothing. So the server looked for its client, correctly concluded it
// had died, and exited 1 a few seconds in. gopls does not poll, which is
// why Go alone survived and why this looked like a TypeScript bug.
//
// null is the spec's own way to say "nobody is watching", and it costs
// nothing here: the jail already ties the server's life to ours. It is
// PID 1 of that namespace, so Conn.Close kills it and everything it
// forked, and a daemon that dies takes the namespace with it.
"processId": nil,
"rootUri": uri,
"rootPath": root,
"capabilities": map[string]any{
+47
View File
@@ -36,6 +36,8 @@ type fake struct {
// push, when set, is published as a diagnostics notification after didOpen,
// which is how a real server delivers them: unsolicited, not as a response.
push map[string]any
// init is the initialize params as they arrived on the wire.
init json.RawMessage
}
// serve reads frames from r and writes answers to w until r closes.
@@ -50,6 +52,7 @@ func (f *fake) serve(r io.Reader, w io.WriteCloser) {
var m struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params"`
}
if json.Unmarshal(body, &m) != nil {
continue
@@ -57,6 +60,9 @@ func (f *fake) serve(r io.Reader, w io.WriteCloser) {
f.mu.Lock()
f.seen = append(f.seen, m.Method)
if m.Method == "initialize" {
f.init = m.Params
}
f.mu.Unlock()
if m.Method == "exit" {
@@ -117,6 +123,47 @@ func sample(t *testing.T, name, body string) (string, string) {
// TestDefinitionDrivesTheProtocol is the core claim: given a position, the client
// performs initialize → initialized → didOpen → definition, in that order, and
// parses the Location the server answered with.
// TestNobodyIsWatchingTheClient pins the field that cost three languages every
// question after their first.
//
// processId is a watchdog: the spec has a server exit when its client's process
// is gone, and every server built on vscode-languageserver polls kill(pid, 0) to
// find out. A pid is only a name inside its own namespace, and the jail gives
// each server a NEW one — so this daemon's pid names nothing there, the server
// concluded its client had died, and it exited 1 within seconds. TypeScript,
// JavaScript and Python answered exactly one query each; gopls does not poll,
// so Go looked fine and the bug looked like TypeScript's.
//
// null is the spec's "nobody is watching", and the jail is what makes it safe:
// the server is PID 1 of its namespace, so it dies with us either way.
func TestNobodyIsWatchingTheClient(t *testing.T) {
dir, _ := sample(t, "main.go", "package main\n")
f := &fake{reply: map[string]any{"initialize": map[string]any{"capabilities": map[string]any{}}}}
c := dial(t, f)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := c.handshake(ctx, table["go"], dir); err != nil {
t.Fatalf("handshake: %v", err)
}
f.mu.Lock()
raw := f.init
f.mu.Unlock()
var got map[string]json.RawMessage
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("initialize params: %v", err)
}
pid, ok := got["processId"]
if !ok {
t.Fatal("initialize sent no processId at all; the spec wants the field, valued null")
}
if string(pid) != "null" {
t.Errorf("processId = %s, want null — a pid from another namespace is a watchdog that always fires", pid)
}
}
func TestDefinitionDrivesTheProtocol(t *testing.T) {
dir, abs := sample(t, "main.go", "package main\n\nfunc main() {}\n")
target := filepath.Join(dir, "other.go")