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.
This commit is contained in:
parent
6c3b28db64
commit
4c52af2a65
2 changed files with 148 additions and 0 deletions
44
packages/opencode/test/fixture/lsp/hanging-lsp-server.js
Normal file
44
packages/opencode/test/fixture/lsp/hanging-lsp-server.js
Normal file
|
|
@ -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
packages/opencode/test/tool/write-lsp-hang.test.ts
Normal file
104
packages/opencode/test/tool/write-lsp-hang.test.ts
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue