mirror of
https://github.com/hanzoai/lsp.git
synced 2026-09-30 22:52:48 +00:00
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:
@@ -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
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user