fixture-server.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200
  1. /**
  2. * A scriptable fake LSP server over stdio for lsp-stdio 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_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
  16. * - LSP_FAKE_INITIALIZED_MARKER: records initialized receipt after any requested stdin pause.
  17. * - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
  18. * - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
  19. * - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
  20. * - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
  21. * "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
  22. * - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response.
  23. * - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply.
  24. *
  25. * Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
  26. */
  27. import { appendFileSync } from 'node:fs'
  28. const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
  29. const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
  30. const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
  31. const hang = process.env.LSP_FAKE_HANG === '1'
  32. const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
  33. const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
  34. const openMarker = process.env.LSP_FAKE_OPEN_MARKER
  35. const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
  36. const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
  37. const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
  38. const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
  39. const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
  40. const onOpen = process.env.LSP_FAKE_ON_OPEN
  41. const errorReply = process.env.LSP_FAKE_ERROR === '1'
  42. const garbage = process.env.LSP_FAKE_GARBAGE === '1'
  43. let serverRequestId = 10_000
  44. const pendingServerRequests = new Map<number, string>()
  45. process.on('SIGTERM', () => {
  46. markExit('TERM')
  47. process.exit(0)
  48. })
  49. function resultFor(method: string): unknown {
  50. switch (method) {
  51. case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
  52. case 'textDocument/references': return envJson('LSP_FAKE_REFS', null)
  53. case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null)
  54. case 'textDocument/hover': {
  55. // LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover
  56. // contents — a test can assert exactly what env reached this process.
  57. const echoName = process.env.LSP_FAKE_ECHO_ENV
  58. if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` }
  59. return envJson('LSP_FAKE_HOVER', null)
  60. }
  61. default: return null
  62. }
  63. }
  64. function envJson(name: string, fallback: unknown): unknown {
  65. const raw = process.env[name]
  66. return raw === undefined ? fallback : JSON.parse(raw)
  67. }
  68. let buffer = Buffer.alloc(0)
  69. process.stdin.on('data', (chunk: Buffer) => {
  70. buffer = Buffer.concat([buffer, chunk])
  71. for (;;) {
  72. const sep = buffer.indexOf('\r\n\r\n')
  73. if (sep < 0) break
  74. const header = buffer.toString('ascii', 0, sep)
  75. const match = /content-length:\s*(\d+)/i.exec(header)
  76. if (!match) { buffer = buffer.subarray(sep + 4); continue }
  77. const length = Number(match[1])
  78. const start = sep + 4
  79. if (buffer.length < start + length) break
  80. const body = buffer.toString('utf8', start, start + length)
  81. buffer = buffer.subarray(start + length)
  82. handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown })
  83. }
  84. })
  85. function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void {
  86. const { id, method } = message
  87. // A frame with an id but no method is the client's REPLY to a server→client request; log it.
  88. if (method === undefined && id !== undefined && pendingServerRequests.has(id)) {
  89. const kind = pendingServerRequests.get(id)
  90. pendingServerRequests.delete(id)
  91. process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`)
  92. return
  93. }
  94. if (method === 'initialize') {
  95. if (garbage) process.stdout.write('this is not a framed message\r\n')
  96. send({
  97. id,
  98. result: {
  99. capabilities: {
  100. positionEncoding: enc,
  101. textDocumentSync: sync,
  102. definitionProvider: true,
  103. referencesProvider: true,
  104. implementationProvider: true,
  105. hoverProvider: true,
  106. ...(extraCaps as Record<string, unknown>),
  107. },
  108. },
  109. })
  110. return
  111. }
  112. if (method === 'shutdown') {
  113. if (noShutdown) return
  114. send({ id, result: null })
  115. return
  116. }
  117. if (method === 'exit') {
  118. markExit('EXIT')
  119. if (exitDelayMs > 0) {
  120. setTimeout(() => {
  121. markExit('CLEAN')
  122. process.exit(0)
  123. }, exitDelayMs)
  124. return
  125. }
  126. markExit('CLEAN')
  127. process.exit(0)
  128. }
  129. if (method === 'textDocument/didOpen') {
  130. if (crashOnOpen) process.exit(1)
  131. if (openMarker !== undefined) {
  132. const params = message.params as { textDocument?: { text?: unknown } } | undefined
  133. appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`)
  134. }
  135. if (onOpen !== undefined) emitServerRequest(onOpen)
  136. return
  137. }
  138. if (method === 'initialized') {
  139. if (pauseStdinAfterInitialized) process.stdin.pause()
  140. if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
  141. return
  142. }
  143. if (method === 'textDocument/didClose') return
  144. if (method?.startsWith('textDocument/')) {
  145. if (hang) return
  146. if (errorReply) {
  147. send({ id, error: { code: -32000, message: 'server refused the request' } })
  148. } else {
  149. send({ id, result: resultFor(method) })
  150. }
  151. // Simulate an idle death: answer this request, then exit before the next one arrives so the
  152. // pool is left holding a dead instance.
  153. if (exitAfterReply) setTimeout(() => process.exit(0), 20)
  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. process.stdin.resume()
  186. if (pauseStdinAfterInitialized) {
  187. setInterval(() => {}, 1000)
  188. }