Sfoglia il codice sorgente

test: add reproducer for write tool hanging on slow LSP initialize (#22872)

Adds a failing regression test that reproduces the write tool hang
reported in #22872. The write tool calls lsp.touchFile + lsp.diagnostics
to enrich its output; if a matching LSP server spawns but never responds
to the initialize request, the tool blocks on LSPClient.create's 45s
withTimeout.

The test configures a fake LSP server (hanging-lsp-server.js) that
swallows every message and never replies, asserts the file is still
written correctly, and checks the tool returns within 10s. On dev today
the assertion fails with ~45s actual, proving the hang. The fix should
make this green by bounding the diagnostic-enrichment tail.
Kit Langton 4 mesi fa
parent
commit
4c52af2a65

+ 44 - 0
packages/opencode/test/fixture/lsp/hanging-lsp-server.js

@@ -0,0 +1,44 @@
+// Fake LSP server that intentionally never responds to `initialize`.
+// Used by tests that reproduce hangs in the LSP touchFile flow when an
+// LSP server process spawns successfully but the handshake stalls. The
+// process also ignores SIGTERM for a short period to surface any teardown
+// issues, but exits cleanly on SIGKILL.
+
+let readBuffer = Buffer.alloc(0)
+
+function decodeFrames(buffer) {
+  const results = []
+  let idx
+  while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) {
+    const header = buffer.slice(0, idx).toString("utf8")
+    const m = /Content-Length:\s*(\d+)/i.exec(header)
+    const len = m ? parseInt(m[1], 10) : 0
+    const bodyStart = idx + 4
+    const bodyEnd = bodyStart + len
+    if (buffer.length < bodyEnd) break
+    results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"))
+    buffer = buffer.slice(bodyEnd)
+  }
+  return { messages: results, rest: buffer }
+}
+
+process.stdin.on("data", (chunk) => {
+  readBuffer = Buffer.concat([readBuffer, chunk])
+  const { messages, rest } = decodeFrames(readBuffer)
+  readBuffer = rest
+  // Swallow everything — including `initialize`. Never reply.
+  for (const _ of messages) {
+    // no-op
+  }
+})
+
+// Keep the process alive until parent terminates us or closes stdin.
+const keepalive = setInterval(() => {}, 60_000)
+process.stdin.on("end", () => {
+  clearInterval(keepalive)
+  process.exit(0)
+})
+process.stdin.on("close", () => {
+  clearInterval(keepalive)
+  process.exit(0)
+})

+ 104 - 0
packages/opencode/test/tool/write-lsp-hang.test.ts

@@ -0,0 +1,104 @@
+import { afterEach, describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
+import path from "path"
+import fs from "fs/promises"
+import { WriteTool } from "../../src/tool/write"
+import { Instance } from "../../src/project/instance"
+import { LSP } from "../../src/lsp"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import { FileTime } from "../../src/file/time"
+import { Bus } from "../../src/bus"
+import { Format } from "../../src/format"
+import { Truncate } from "../../src/tool"
+import { Tool } from "../../src/tool"
+import { Agent } from "../../src/agent/agent"
+import { SessionID, MessageID } from "../../src/session/schema"
+import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
+import { provideTmpdirInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+// Reproduces issue #22872 — the write tool hangs when an LSP server for the
+// file's extension spawns successfully but never answers the `initialize`
+// request. The fake LSP here swallows every message, mimicking pyright in
+// the reporter's Docker container. If the write tool correctly bounds the
+// diagnostic-enrichment tail (lsp.touchFile + lsp.diagnostics) the whole
+// call should finish quickly, well before the 45s LSPClient.create timeout.
+
+const HANGING_SERVER = path.resolve(__dirname, "..", "fixture", "lsp", "hanging-lsp-server.js")
+
+const ctx = {
+  sessionID: SessionID.make("ses_test-write-lsp-hang"),
+  messageID: MessageID.make(""),
+  callID: "",
+  agent: "build",
+  abort: AbortSignal.any([]),
+  messages: [],
+  metadata: () => Effect.void,
+  ask: () => Effect.void,
+}
+
+afterEach(async () => {
+  await Instance.disposeAll()
+})
+
+const it = testEffect(
+  Layer.mergeAll(
+    LSP.defaultLayer,
+    AppFileSystem.defaultLayer,
+    FileTime.defaultLayer,
+    Bus.layer,
+    Format.defaultLayer,
+    CrossSpawnSpawner.defaultLayer,
+    Truncate.defaultLayer,
+    Agent.defaultLayer,
+  ),
+)
+
+const init = Effect.fn("WriteLspHangTest.init")(function* () {
+  const info = yield* WriteTool
+  return yield* info.init()
+})
+
+const run = Effect.fn("WriteLspHangTest.run")(function* (
+  args: Tool.InferParameters<typeof WriteTool>,
+  next: Tool.Context = ctx,
+) {
+  const tool = yield* init()
+  return yield* tool.execute(args, next)
+})
+
+describe("tool.write (LSP hang — issue #22872)", () => {
+  it.live(
+    "completes promptly when the LSP server for this extension never finishes initialize",
+    () =>
+      provideTmpdirInstance(
+        (dir) =>
+          Effect.gen(function* () {
+            const filepath = path.join(dir, "hello.hang")
+            const started = Date.now()
+            const result = yield* run({ filePath: filepath, content: "print('hi')" })
+            const elapsed = Date.now() - started
+
+            // On disk content is correct.
+            const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
+            expect(content).toBe("print('hi')")
+            expect(result.output).toContain("Wrote file successfully")
+
+            // Regression guard: touchFile/diagnostics must not block the tool
+            // on the 45s LSPClient.create initialize timeout.
+            expect(elapsed).toBeLessThan(10_000)
+          }),
+        {
+          config: {
+            lsp: {
+              "hang-ls": {
+                command: ["node", HANGING_SERVER],
+                extensions: [".hang"],
+              },
+            },
+          },
+        },
+      ),
+    60_000,
+  )
+})