fixture-server.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. /**
  2. * A scriptable fake LSP server over stdio for lsp-local tests. It speaks the real
  3. * `Content-Length`-framed base protocol so it exercises the client's framing, initialize handshake,
  4. * transient open/close, request mapping, and teardown — without a real language server.
  5. *
  6. * Behavior is driven by env vars so one file backs many scenarios:
  7. * - LSP_FAKE_ENCODING: advertised positionEncoding (default utf-16; "utf-8" forces a mismatch).
  8. * - LSP_FAKE_SYNC: textDocumentSync value as JSON (default 1/Full).
  9. * - LSP_FAKE_CAPS: JSON of extra capability flags merged into the defaults.
  10. * - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request.
  11. * - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests).
  12. * - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
  13. * - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request,
  14. * simulating a server that dies while idle so the pool holds a dead instance (eviction test).
  15. * - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
  16. * - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
  17. * - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
  18. * - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
  19. * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
  20. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
  21. * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
  22. * "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
  23. * - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response.
  24. * - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply.
  25. *
  26. * Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
  27. */
  28. import { appendFileSync } from 'node:fs'
  29. const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
  30. const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
  31. const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
  32. const hang = process.env.LSP_FAKE_HANG === '1'
  33. const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
  34. const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
  35. const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
  36. const openMarker = process.env.LSP_FAKE_OPEN_MARKER
  37. const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
  38. const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
  39. const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
  40. const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
  41. const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
  42. const onOpen = process.env.LSP_FAKE_ON_OPEN
  43. const errorReply = process.env.LSP_FAKE_ERROR === '1'
  44. const garbage = process.env.LSP_FAKE_GARBAGE === '1'
  45. let serverRequestId = 10_000
  46. const pendingServerRequests = new Map<number, string>()
  47. process.on('SIGTERM', () => {
  48. markExit('TERM')
  49. process.exit(0)
  50. })
  51. function resultFor(method: string): unknown {
  52. switch (method) {
  53. case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
  54. case 'textDocument/references': return envJson('LSP_FAKE_REFS', null)
  55. case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null)
  56. case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null)
  57. default: return null
  58. }
  59. }
  60. function envJson(name: string, fallback: unknown): unknown {
  61. const raw = process.env[name]
  62. return raw === undefined ? fallback : JSON.parse(raw)
  63. }
  64. let buffer = Buffer.alloc(0)
  65. process.stdin.on('data', (chunk: Buffer) => {
  66. buffer = Buffer.concat([buffer, chunk])
  67. for (;;) {
  68. const sep = buffer.indexOf('\r\n\r\n')
  69. if (sep < 0) break
  70. const header = buffer.toString('ascii', 0, sep)
  71. const match = /content-length:\s*(\d+)/i.exec(header)
  72. if (!match) { buffer = buffer.subarray(sep + 4); continue }
  73. const length = Number(match[1])
  74. const start = sep + 4
  75. if (buffer.length < start + length) break
  76. const body = buffer.toString('utf8', start, start + length)
  77. buffer = buffer.subarray(start + length)
  78. handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown })
  79. }
  80. })
  81. function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void {
  82. const { id, method } = message
  83. // A frame with an id but no method is the client's REPLY to a server→client request; log it.
  84. if (method === undefined && id !== undefined && pendingServerRequests.has(id)) {
  85. const kind = pendingServerRequests.get(id)
  86. pendingServerRequests.delete(id)
  87. process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`)
  88. return
  89. }
  90. if (method === 'initialize') {
  91. if (garbage) process.stdout.write('this is not a framed message\r\n')
  92. send({
  93. id,
  94. result: {
  95. capabilities: {
  96. positionEncoding: enc,
  97. textDocumentSync: sync,
  98. definitionProvider: true,
  99. referencesProvider: true,
  100. implementationProvider: true,
  101. hoverProvider: true,
  102. ...(extraCaps as Record<string, unknown>),
  103. },
  104. },
  105. })
  106. return
  107. }
  108. if (method === 'shutdown') {
  109. if (noShutdown) return
  110. send({ id, result: null })
  111. return
  112. }
  113. if (method === 'exit') {
  114. markExit('EXIT')
  115. if (exitDelayMs > 0) {
  116. setTimeout(() => {
  117. markExit('CLEAN')
  118. process.exit(0)
  119. }, exitDelayMs)
  120. return
  121. }
  122. markExit('CLEAN')
  123. process.exit(0)
  124. }
  125. if (method === 'textDocument/didOpen') {
  126. if (crashOnOpen) process.exit(1)
  127. if (openMarker !== undefined) {
  128. const params = message.params as { textDocument?: { text?: unknown } } | undefined
  129. appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`)
  130. }
  131. if (onOpen !== undefined) emitServerRequest(onOpen)
  132. return
  133. }
  134. if (method === 'initialized') {
  135. if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
  136. if (pauseStdinAfterInitialized) process.stdin.pause()
  137. return
  138. }
  139. if (method === 'textDocument/didClose') return
  140. if (method?.startsWith('textDocument/')) {
  141. if (hang) return
  142. const reply = (): void => {
  143. if (errorReply) {
  144. send({ id, error: { code: -32000, message: 'server refused the request' } })
  145. } else {
  146. send({ id, result: resultFor(method) })
  147. }
  148. // Simulate an idle death: answer this request, then exit before the next one arrives so the
  149. // pool is left holding a dead instance.
  150. if (exitAfterReply) setTimeout(() => process.exit(0), 20)
  151. }
  152. if (replyDelayMs > 0) setTimeout(reply, replyDelayMs)
  153. else reply()
  154. return
  155. }
  156. // Unknown request with an id: answer null so the client never stalls.
  157. if (id !== undefined) send({ id, result: null })
  158. }
  159. /** Append one teardown event when the fixture is configured to expose process ordering. */
  160. function markExit(event: string): void {
  161. if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
  162. }
  163. /** Emit a server→client request and log the client's reply to stderr for the test to assert. */
  164. function emitServerRequest(kind: string): void {
  165. if (kind === 'notification') {
  166. send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } })
  167. return
  168. }
  169. const id = serverRequestId++
  170. const method = kind === 'configuration'
  171. ? 'workspace/configuration'
  172. : kind === 'applyEdit'
  173. ? 'workspace/applyEdit'
  174. : kind === 'lifecycle'
  175. ? 'client/registerCapability'
  176. : 'window/showMessageRequest'
  177. const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {}
  178. pendingServerRequests.set(id, method)
  179. send({ id, method, params })
  180. }
  181. function send(message: Record<string, unknown>): void {
  182. const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8')
  183. process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]))
  184. }
  185. // Keep the event loop alive.
  186. process.stdin.resume()
  187. if (pauseStdinAfterInitialized) {
  188. setInterval(() => {}, 1000)
  189. }