| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122 |
- 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,
- )
- })
|