From 4c52af2a658a9ba608ab48e6d0067cfc4da8be67 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 16 Apr 2026 15:50:06 -0400 Subject: [PATCH] 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. --- .../test/fixture/lsp/hanging-lsp-server.js | 44 ++++++++ .../opencode/test/tool/write-lsp-hang.test.ts | 104 ++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 packages/opencode/test/fixture/lsp/hanging-lsp-server.js create mode 100644 packages/opencode/test/tool/write-lsp-hang.test.ts diff --git a/packages/opencode/test/fixture/lsp/hanging-lsp-server.js b/packages/opencode/test/fixture/lsp/hanging-lsp-server.js new file mode 100644 index 0000000000..73b3cb2fee --- /dev/null +++ b/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) +}) diff --git a/packages/opencode/test/tool/write-lsp-hang.test.ts b/packages/opencode/test/tool/write-lsp-hang.test.ts new file mode 100644 index 0000000000..a5a3f3f904 --- /dev/null +++ b/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, + 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, + ) +})