fixture-server.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  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_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': {
  57. // LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover
  58. // contents — a test can assert exactly what env reached this process.
  59. const echoName = process.env.LSP_FAKE_ECHO_ENV
  60. if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` }
  61. return envJson('LSP_FAKE_HOVER', null)
  62. }
  63. default: return null
  64. }
  65. }
  66. function envJson(name: string, fallback: unknown): unknown {
  67. const raw = process.env[name]
  68. return raw === undefined ? fallback : JSON.parse(raw)
  69. }
  70. let buffer = Buffer.alloc(0)
  71. process.stdin.on('data', (chunk: Buffer) => {
  72. buffer = Buffer.concat([buffer, chunk])
  73. for (;;) {
  74. const sep = buffer.indexOf('\r\n\r\n')
  75. if (sep < 0) break
  76. const header = buffer.toString('ascii', 0, sep)
  77. const match = /content-length:\s*(\d+)/i.exec(header)
  78. if (!match) { buffer = buffer.subarray(sep + 4); continue }
  79. const length = Number(match[1])
  80. const start = sep + 4
  81. if (buffer.length < start + length) break
  82. const body = buffer.toString('utf8', start, start + length)
  83. buffer = buffer.subarray(start + length)
  84. handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown })
  85. }
  86. })
  87. function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void {
  88. const { id, method } = message
  89. // A frame with an id but no method is the client's REPLY to a server→client request; log it.
  90. if (method === undefined && id !== undefined && pendingServerRequests.has(id)) {
  91. const kind = pendingServerRequests.get(id)
  92. pendingServerRequests.delete(id)
  93. process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`)
  94. return
  95. }
  96. if (method === 'initialize') {
  97. if (garbage) process.stdout.write('this is not a framed message\r\n')
  98. send({
  99. id,
  100. result: {
  101. capabilities: {
  102. positionEncoding: enc,
  103. textDocumentSync: sync,
  104. definitionProvider: true,
  105. referencesProvider: true,
  106. implementationProvider: true,
  107. hoverProvider: true,
  108. ...(extraCaps as Record<string, unknown>),
  109. },
  110. },
  111. })
  112. return
  113. }
  114. if (method === 'shutdown') {
  115. if (noShutdown) return
  116. send({ id, result: null })
  117. return
  118. }
  119. if (method === 'exit') {
  120. markExit('EXIT')
  121. if (exitDelayMs > 0) {
  122. setTimeout(() => {
  123. markExit('CLEAN')
  124. process.exit(0)
  125. }, exitDelayMs)
  126. return
  127. }
  128. markExit('CLEAN')
  129. process.exit(0)
  130. }
  131. if (method === 'textDocument/didOpen') {
  132. if (crashOnOpen) process.exit(1)
  133. if (openMarker !== undefined) {
  134. const params = message.params as { textDocument?: { text?: unknown } } | undefined
  135. appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`)
  136. }
  137. if (onOpen !== undefined) emitServerRequest(onOpen)
  138. return
  139. }
  140. if (method === 'initialized') {
  141. if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
  142. if (pauseStdinAfterInitialized) process.stdin.pause()
  143. return
  144. }
  145. if (method === 'textDocument/didClose') return
  146. if (method?.startsWith('textDocument/')) {
  147. if (hang) return
  148. const reply = (): void => {
  149. if (errorReply) {
  150. send({ id, error: { code: -32000, message: 'server refused the request' } })
  151. } else {
  152. send({ id, result: resultFor(method) })
  153. }
  154. // Simulate an idle death: answer this request, then exit before the next one arrives so the
  155. // pool is left holding a dead instance.
  156. if (exitAfterReply) setTimeout(() => process.exit(0), 20)
  157. }
  158. if (replyDelayMs > 0) setTimeout(reply, replyDelayMs)
  159. else reply()
  160. return
  161. }
  162. // Unknown request with an id: answer null so the client never stalls.
  163. if (id !== undefined) send({ id, result: null })
  164. }
  165. /** Append one teardown event when the fixture is configured to expose process ordering. */
  166. function markExit(event: string): void {
  167. if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
  168. }
  169. /** Emit a server→client request and log the client's reply to stderr for the test to assert. */
  170. function emitServerRequest(kind: string): void {
  171. if (kind === 'notification') {
  172. send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } })
  173. return
  174. }
  175. const id = serverRequestId++
  176. const method = kind === 'configuration'
  177. ? 'workspace/configuration'
  178. : kind === 'applyEdit'
  179. ? 'workspace/applyEdit'
  180. : kind === 'lifecycle'
  181. ? 'client/registerCapability'
  182. : 'window/showMessageRequest'
  183. const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {}
  184. pendingServerRequests.set(id, method)
  185. send({ id, method, params })
  186. }
  187. function send(message: Record<string, unknown>): void {
  188. const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8')
  189. process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]))
  190. }
  191. process.stdin.resume()
  192. if (pauseStdinAfterInitialized) {
  193. setInterval(() => {}, 1000)
  194. }