write-lsp-spawn-hang.test.ts 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. import { afterEach, beforeAll, afterAll, describe, expect } from "bun:test"
  2. import { Effect, Layer, Option } from "effect"
  3. import path from "path"
  4. import fs from "fs/promises"
  5. import { Npm } from "@opencode-ai/shared/npm"
  6. import { Config } from "../../src/config"
  7. import { WriteTool } from "../../src/tool/write"
  8. import { Instance } from "../../src/project/instance"
  9. import * as LSP from "../../src/lsp/lsp"
  10. import { AppFileSystem } from "@opencode-ai/shared/filesystem"
  11. import { FileTime } from "../../src/file/time"
  12. import { Bus } from "../../src/bus"
  13. import { Format } from "../../src/format"
  14. import { Truncate } from "../../src/tool"
  15. import { Tool } from "../../src/tool"
  16. import { Agent } from "../../src/agent/agent"
  17. import { SessionID, MessageID } from "../../src/session/schema"
  18. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  19. import { provideTmpdirInstance } from "../fixture/fixture"
  20. import { testEffect } from "../lib/effect"
  21. // Reproduces the "forever" branch of issue #22872 — in a sandboxed
  22. // container with no network and no cached pyright binary, Pyright.spawn
  23. // calls `Npm.Service.which("pyright")` which internally uses
  24. // `arborist.reify()` with no timeout. If the npm registry is
  25. // unreachable, that promise never resolves and the write tool blocks
  26. // indefinitely.
  27. //
  28. // Here we mock Npm.Service so `which("pyright")` returns Effect.never,
  29. // simulating the unbounded network block. The write tool must still
  30. // return quickly for the fix to be correct — shortening the 45s
  31. // LSPClient.create initialize timeout would NOT help this case, so
  32. // the fix must bound the touchFile enrichment tail itself.
  33. const ctx = {
  34. sessionID: SessionID.make("ses_test-write-lsp-spawn-hang"),
  35. messageID: MessageID.make(""),
  36. callID: "",
  37. agent: "build",
  38. abort: AbortSignal.any([]),
  39. messages: [],
  40. metadata: () => Effect.void,
  41. ask: () => Effect.void,
  42. }
  43. // Ensure pyright-langserver isn't picked up from the user's real PATH
  44. // during the test — we want the spawn to fall through to Npm.which.
  45. let savedPath: string | undefined
  46. beforeAll(() => {
  47. savedPath = process.env.PATH
  48. process.env.PATH = ""
  49. })
  50. afterAll(() => {
  51. process.env.PATH = savedPath
  52. })
  53. afterEach(async () => {
  54. await Instance.disposeAll()
  55. })
  56. const hangingNpm = Layer.mock(Npm.Service)({
  57. add: () => Effect.never,
  58. install: () => Effect.never,
  59. outdated: () => Effect.succeed(false),
  60. which: () => Effect.never as unknown as Effect.Effect<Option.Option<string>>,
  61. })
  62. // Build the LSP layer with the hanging Npm mock in place of the real one.
  63. // LSP.defaultLayer pre-provides the real EffectNpm.defaultLayer which would
  64. // shadow any outer provide, so we wire the mock directly into LSP.layer.
  65. const lspWithHangingNpm = LSP.layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(hangingNpm))
  66. const it = testEffect(
  67. Layer.mergeAll(
  68. lspWithHangingNpm,
  69. AppFileSystem.defaultLayer,
  70. FileTime.defaultLayer,
  71. Bus.layer,
  72. Format.defaultLayer,
  73. CrossSpawnSpawner.defaultLayer,
  74. Truncate.defaultLayer,
  75. Agent.defaultLayer,
  76. ),
  77. )
  78. const init = Effect.fn("WriteLspSpawnHangTest.init")(function* () {
  79. const info = yield* WriteTool
  80. return yield* info.init()
  81. })
  82. const run = Effect.fn("WriteLspSpawnHangTest.run")(function* (
  83. args: Tool.InferParameters<typeof WriteTool>,
  84. next: Tool.Context = ctx,
  85. ) {
  86. const tool = yield* init()
  87. return yield* tool.execute(args, next)
  88. })
  89. describe("tool.write (LSP spawn hang — issue #22872 forever branch)", () => {
  90. it.live(
  91. "completes promptly when Npm.Service.which hangs forever during LSP spawn",
  92. () =>
  93. provideTmpdirInstance((dir) =>
  94. Effect.gen(function* () {
  95. const filepath = path.join(dir, "hello.py")
  96. const started = Date.now()
  97. const result = yield* run({ filePath: filepath, content: "print('hi')" })
  98. const elapsed = Date.now() - started
  99. // File is on disk even though LSP spawn is wedged.
  100. const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
  101. expect(content).toBe("print('hi')")
  102. expect(result.output).toContain("Wrote file successfully")
  103. // The LSP spawn path is now blocked forever (Npm.Service.which
  104. // returns Effect.never). The write tool must not wait on it.
  105. expect(elapsed).toBeLessThan(10_000)
  106. }),
  107. ),
  108. 15_000,
  109. )
  110. })