Files
anomalyco_opencode/packages/opencode/test/tool/write-lsp-spawn-hang.test.ts
T
Kit Langton 617c9841ef test: add reproducer for Npm.which-forever hang in Pyright spawn
Adds a second reproducer covering the 'forever' branch of issue #22872:
when Pyright.spawn calls Npm.which('pyright') and the npm registry is
unreachable (sandboxed container), arborist.reify blocks indefinitely
with no timeout.

Changes:

- Adds optional Info.spawnEffect alongside the existing async Info.spawn.
  spawnEffect returns an Effect that can yield from Npm.Service, making
  npm lookups injectable for tests.
- Migrates Pyright to use spawnEffect, pulling the venv probing logic
  into a reusable pyrightVenvInitialization helper. The legacy async
  spawn stays for backwards compatibility.
- Threads Npm.Service through LSP.layer so getClients captures a stable
  reference and uses it for any server that provides spawnEffect.
- Adds test/tool/write-lsp-spawn-hang.test.ts — mocks Npm.Service.which
  with Effect.never and asserts the write tool still returns in < 10s.
  Fails today (hangs forever); the fix must bound the touchFile tail
  so the tool cannot wait on a wedged LSP spawn.

The two reproducers now cover both hang branches:
- write-lsp-hang.test.ts: 45s LSPClient.create initialize timeout
- write-lsp-spawn-hang.test.ts: unbounded Npm.which arborist.reify
2026-04-16 17:03:19 -04:00

123 lines
4.3 KiB
TypeScript

import { afterEach, beforeAll, afterAll, describe, expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import path from "path"
import fs from "fs/promises"
import { Npm } from "@opencode-ai/shared/npm"
import { Config } from "../../src/config"
import { WriteTool } from "../../src/tool/write"
import { Instance } from "../../src/project/instance"
import * as LSP from "../../src/lsp/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 the "forever" branch of issue #22872 — in a sandboxed
// container with no network and no cached pyright binary, Pyright.spawn
// calls `Npm.Service.which("pyright")` which internally uses
// `arborist.reify()` with no timeout. If the npm registry is
// unreachable, that promise never resolves and the write tool blocks
// indefinitely.
//
// Here we mock Npm.Service so `which("pyright")` returns Effect.never,
// simulating the unbounded network block. The write tool must still
// return quickly for the fix to be correct — shortening the 45s
// LSPClient.create initialize timeout would NOT help this case, so
// the fix must bound the touchFile enrichment tail itself.
const ctx = {
sessionID: SessionID.make("ses_test-write-lsp-spawn-hang"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
// Ensure pyright-langserver isn't picked up from the user's real PATH
// during the test — we want the spawn to fall through to Npm.which.
let savedPath: string | undefined
beforeAll(() => {
savedPath = process.env.PATH
process.env.PATH = ""
})
afterAll(() => {
process.env.PATH = savedPath
})
afterEach(async () => {
await Instance.disposeAll()
})
const hangingNpm = Layer.mock(Npm.Service)({
add: () => Effect.never,
install: () => Effect.never,
outdated: () => Effect.succeed(false),
which: () => Effect.never as unknown as Effect.Effect<Option.Option<string>>,
})
// Build the LSP layer with the hanging Npm mock in place of the real one.
// LSP.defaultLayer pre-provides the real EffectNpm.defaultLayer which would
// shadow any outer provide, so we wire the mock directly into LSP.layer.
const lspWithHangingNpm = LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(hangingNpm))
const it = testEffect(
Layer.mergeAll(
lspWithHangingNpm,
AppFileSystem.defaultLayer,
FileTime.defaultLayer,
Bus.layer,
Format.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Truncate.defaultLayer,
Agent.defaultLayer,
),
)
const init = Effect.fn("WriteLspSpawnHangTest.init")(function* () {
const info = yield* WriteTool
return yield* info.init()
})
const run = Effect.fn("WriteLspSpawnHangTest.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 spawn hang — issue #22872 forever branch)", () => {
it.live(
"completes promptly when Npm.Service.which hangs forever during LSP spawn",
() =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "hello.py")
const started = Date.now()
const result = yield* run({ filePath: filepath, content: "print('hi')" })
const elapsed = Date.now() - started
// File is on disk even though LSP spawn is wedged.
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("print('hi')")
expect(result.output).toContain("Wrote file successfully")
// The LSP spawn path is now blocked forever (Npm.Service.which
// returns Effect.never). The write tool must not wait on it.
expect(elapsed).toBeLessThan(10_000)
}),
),
15_000,
)
})