BackendLauncher.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. import { ChildProcess, spawn } from "child_process"
  2. import * as vscode from "vscode"
  3. import { ResourceExtractor } from "./ResourceExtractor"
  4. import { ErrorCategory, errorHandler, ErrorSeverity } from "../utils/ErrorHandler"
  5. import { logger } from "../globals"
  6. /**
  7. * Backend process management - mirrors BackendLauncher.kt
  8. * Handles opencode backend process lifecycle, binary extraction, and connection management
  9. */
  10. export interface BackendConnection {
  11. port: number
  12. uiBase: string
  13. process: ChildProcess
  14. }
  15. export class BackendLauncher {
  16. private currentProcess?: ChildProcess
  17. private currentConnection?: Omit<BackendConnection, "process">
  18. /**
  19. * Launch the opencode backend process
  20. * @param workspaceRoot Optional workspace root directory
  21. * @returns Promise resolving to backend connection info
  22. */
  23. async launchBackend(workspaceRoot?: string, options?: { forceNew?: boolean }): Promise<BackendConnection> {
  24. // Reuse existing running backend if available
  25. if (!options?.forceNew && this.currentProcess && this.currentConnection && this.isRunning()) {
  26. return { ...this.currentConnection, process: this.currentProcess } as BackendConnection
  27. }
  28. try {
  29. // Extract binary for current platform
  30. const binaryPath = await this.extractBinary()
  31. logger.appendLine(`Using binary: ${binaryPath}`)
  32. // Build command arguments
  33. const args = this.buildCommandArgs(binaryPath)
  34. const cwd = workspaceRoot || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd()
  35. if (options?.forceNew) {
  36. // Start an independent backend without touching the current shared one
  37. logger.appendLine(`Starting additional backend process: ${args.join(" ")}`)
  38. const childProcess = spawn(args[0], args.slice(1), {
  39. cwd,
  40. stdio: ["pipe", "pipe", "pipe"],
  41. env: { ...process.env },
  42. })
  43. // Parse connection and set up error handling
  44. const connection = await this.parseConnectionInfo(childProcess)
  45. this.setupErrorHandling(childProcess)
  46. logger.appendLine(`Additional backend started successfully on port ${connection.port}`)
  47. // Do NOT update currentProcess/currentConnection for additional backend
  48. return { ...connection, process: childProcess }
  49. }
  50. // For shared backend: terminate any existing and start new
  51. this.terminate()
  52. logger.appendLine(`Starting backend process: ${args.join(" ")}`)
  53. const childProcess = spawn(args[0], args.slice(1), {
  54. cwd,
  55. stdio: ["pipe", "pipe", "pipe"],
  56. env: { ...process.env },
  57. })
  58. this.currentProcess = childProcess
  59. // Parse connection info from stdout
  60. const connection = await this.parseConnectionInfo(childProcess)
  61. // Set up error handling
  62. this.setupErrorHandling(childProcess)
  63. logger.appendLine(`Backend started successfully on port ${connection.port}`)
  64. // Cache current connection (shared)
  65. this.currentConnection = connection
  66. return {
  67. ...connection,
  68. process: childProcess,
  69. }
  70. } catch (error) {
  71. logger.appendLine(`Failed to launch backend: ${error}`)
  72. // Try fallback without custom command if it was configured
  73. const customCommand = this.getCustomCommand()
  74. if (customCommand.trim()) {
  75. logger.appendLine("Attempting fallback without custom command...")
  76. try {
  77. return await this.launchBackendFallback(workspaceRoot)
  78. } catch (fallbackError) {
  79. // Handle both original and fallback errors
  80. await errorHandler.handleBackendLaunchError(
  81. fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError)),
  82. {
  83. originalError: error instanceof Error ? error.message : String(error),
  84. customCommand,
  85. workspaceRoot,
  86. attemptedFallback: true,
  87. },
  88. )
  89. throw fallbackError
  90. }
  91. }
  92. // Handle the original error
  93. await errorHandler.handleBackendLaunchError(error instanceof Error ? error : new Error(String(error)), {
  94. customCommand,
  95. workspaceRoot,
  96. attemptedFallback: false,
  97. })
  98. throw error
  99. }
  100. }
  101. /**
  102. * Launch backend without custom command as fallback
  103. * @param workspaceRoot Optional workspace root directory
  104. * @returns Promise resolving to backend connection info
  105. */
  106. private async launchBackendFallback(workspaceRoot?: string): Promise<BackendConnection> {
  107. try {
  108. const binaryPath = await this.extractBinary()
  109. const args = this.buildCommandArgs(binaryPath, true) // Skip custom command
  110. logger.appendLine(`Starting fallback backend process: ${args.join(" ")}`)
  111. const cwd = workspaceRoot || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd()
  112. const childProcess = spawn(args[0], args.slice(1), {
  113. cwd,
  114. stdio: ["pipe", "pipe", "pipe"],
  115. env: { ...process.env },
  116. })
  117. this.currentProcess = childProcess
  118. const connection = await this.parseConnectionInfo(childProcess)
  119. this.setupErrorHandling(childProcess)
  120. logger.appendLine(`Fallback backend started successfully on port ${connection.port}`)
  121. // Cache current connection
  122. this.currentConnection = connection
  123. return {
  124. ...connection,
  125. process: childProcess,
  126. }
  127. } catch (fallbackError) {
  128. logger.appendLine(`Fallback backend launch also failed: ${fallbackError}`)
  129. await errorHandler.handleBackendLaunchError(
  130. fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError)),
  131. {
  132. isFallback: true,
  133. workspaceRoot,
  134. },
  135. )
  136. throw fallbackError
  137. }
  138. }
  139. /**
  140. * Extract the appropriate binary for the current OS/architecture
  141. * @returns Promise resolving to the path of the extracted binary
  142. */
  143. private async extractBinary(): Promise<string> {
  144. // Check for environment override first
  145. const override = process.env.OPENCODE_BIN
  146. if (override && override.trim()) {
  147. logger.appendLine(`Using binary override: ${override}`)
  148. return override.trim()
  149. }
  150. // Get extension path
  151. const extension = vscode.extensions.getExtension("paviko.opencode-ux-plus")
  152. if (!extension) {
  153. throw new Error("Extension not found")
  154. }
  155. return ResourceExtractor.extractBinary(extension.extensionPath)
  156. }
  157. /**
  158. * Build command arguments for the backend process
  159. * @param binaryPath Path to the binary executable
  160. * @param skipCustomCommand Whether to skip custom command (for fallback)
  161. * @returns Array of command arguments
  162. */
  163. private buildCommandArgs(binaryPath: string, skipCustomCommand = false): string[] {
  164. const args = [binaryPath, "serve"]
  165. if (!skipCustomCommand) {
  166. const customCommand = this.getCustomCommand()
  167. if (customCommand.trim()) {
  168. const extraArgs = this.parseCommandArgs(customCommand.trim())
  169. if (extraArgs.length > 0) {
  170. args.push(...extraArgs)
  171. logger.appendLine(`Using extra serve args: '${extraArgs.join(" ")}'`)
  172. }
  173. } else {
  174. logger.appendLine("Using default serve args")
  175. }
  176. }
  177. return args
  178. }
  179. /**
  180. * Get custom command from settings
  181. * @returns Custom command string
  182. */
  183. private getCustomCommand(): string {
  184. const config = vscode.workspace.getConfiguration("opencode")
  185. return config.get<string>("customCommand", "")
  186. }
  187. private parseCommandArgs(value: string): string[] {
  188. const args: string[] = []
  189. const regex = /"([^"]*)"|'([^']*)'|(\S+)/g
  190. let match: RegExpExecArray | null
  191. while ((match = regex.exec(value)) !== null) {
  192. if (match[1] !== undefined) {
  193. args.push(match[1])
  194. } else if (match[2] !== undefined) {
  195. args.push(match[2])
  196. } else if (match[3] !== undefined) {
  197. args.push(match[3])
  198. }
  199. }
  200. return args
  201. }
  202. /**
  203. * Parse connection information from backend stdout
  204. * @param process The spawned backend process
  205. * @returns Promise resolving to connection info
  206. */
  207. private async parseConnectionInfo(process: ChildProcess): Promise<Omit<BackendConnection, "process">> {
  208. return new Promise((resolve, reject) => {
  209. let stdoutData = ""
  210. let stderrData = ""
  211. let resolved = false
  212. const timeout = setTimeout(() => {
  213. if (!resolved) {
  214. resolved = true
  215. reject(new Error(`Timeout waiting for backend connection info. Stderr: ${stderrData}`))
  216. }
  217. }, 300000) // 300 second timeout
  218. process.stdout?.on("data", (data: Buffer) => {
  219. stdoutData += data.toString()
  220. const logLine = data.toString().trim()
  221. logger.appendLine(`Backend stdout: ${logLine}`)
  222. // Look for serve output
  223. const lines = stdoutData.split("\n")
  224. for (const line of lines) {
  225. const trimmed = line.trim()
  226. const match = trimmed.match(/opencode server listening on (https?:\/\/\S+)/i)
  227. if (match) {
  228. try {
  229. const serverUrl = new URL(match[1])
  230. const inferredPort = serverUrl.port ? Number(serverUrl.port) : serverUrl.protocol === "https:" ? 443 : 80
  231. const baseUrl = serverUrl.href.replace(/\/$/, "")
  232. const uiBase = `${baseUrl}/app`
  233. if (!resolved) {
  234. resolved = true
  235. clearTimeout(timeout)
  236. resolve({
  237. port: inferredPort,
  238. uiBase,
  239. })
  240. }
  241. return
  242. } catch (parseError) {
  243. logger.appendLine(`Failed to parse backend URL: ${parseError}`)
  244. }
  245. }
  246. }
  247. })
  248. process.stderr?.on("data", (data: Buffer) => {
  249. stderrData += data.toString()
  250. logger.appendLine(`Backend stderr: ${data.toString().trim()}`)
  251. })
  252. process.on("error", (error) => {
  253. if (!resolved) {
  254. resolved = true
  255. clearTimeout(timeout)
  256. reject(new Error(`Backend process error: ${error.message}`))
  257. }
  258. })
  259. process.on("exit", (code, signal) => {
  260. if (!resolved) {
  261. resolved = true
  262. clearTimeout(timeout)
  263. reject(new Error(`Backend process exited with code ${code}, signal ${signal}. Stderr: ${stderrData}`))
  264. }
  265. })
  266. })
  267. }
  268. /**
  269. * Set up error handling for the backend process
  270. * @param process The backend process
  271. */
  272. private setupErrorHandling(process: ChildProcess): void {
  273. process.on("error", async (error) => {
  274. logger.appendLine(`Backend process error: ${error.message}`)
  275. await errorHandler.handleError(
  276. errorHandler.createErrorContext(
  277. ErrorCategory.BACKEND_LAUNCH,
  278. ErrorSeverity.ERROR,
  279. "BackendLauncher",
  280. "process_error",
  281. error,
  282. {
  283. pid: process.pid,
  284. killed: process.killed,
  285. },
  286. ),
  287. )
  288. })
  289. process.on("exit", async (code, signal) => {
  290. logger.appendLine(`Backend process exited with code ${code}, signal ${signal}`)
  291. if (code !== 0 && code !== null) {
  292. await errorHandler.handleError(
  293. errorHandler.createErrorContext(
  294. ErrorCategory.BACKEND_LAUNCH,
  295. ErrorSeverity.WARNING,
  296. "BackendLauncher",
  297. "process_exit",
  298. new Error(`Backend process exited unexpectedly with code ${code}`),
  299. {
  300. exitCode: code,
  301. signal,
  302. pid: process.pid,
  303. },
  304. ),
  305. )
  306. }
  307. // Clear current process reference
  308. if (this.currentProcess === process) {
  309. this.currentProcess = undefined
  310. this.currentConnection = undefined
  311. }
  312. })
  313. // Log stdout/stderr for debugging
  314. process.stdout?.on("data", (data: Buffer) => {
  315. const output = data.toString().trim()
  316. if (output && !output.startsWith("{")) {
  317. // Don't log JSON connection info again
  318. logger.appendLine(`Backend: ${output}`)
  319. }
  320. })
  321. process.stderr?.on("data", (data: Buffer) => {
  322. const output = data.toString().trim()
  323. logger.appendLine(`Backend error: ${output}`)
  324. // Handle critical stderr messages
  325. if (output.toLowerCase().includes("permission denied") || output.toLowerCase().includes("access denied")) {
  326. errorHandler.handleError(
  327. errorHandler.createErrorContext(
  328. ErrorCategory.PERMISSION,
  329. ErrorSeverity.ERROR,
  330. "BackendLauncher",
  331. "permission_error",
  332. new Error(`Permission error: ${output}`),
  333. { stderr: output },
  334. ),
  335. )
  336. } else if (output.toLowerCase().includes("port") && output.toLowerCase().includes("use")) {
  337. errorHandler.handleError(
  338. errorHandler.createErrorContext(
  339. ErrorCategory.NETWORK,
  340. ErrorSeverity.WARNING,
  341. "BackendLauncher",
  342. "port_conflict",
  343. new Error(`Port conflict: ${output}`),
  344. { stderr: output },
  345. ),
  346. )
  347. }
  348. })
  349. }
  350. /**
  351. * Terminate the backend process
  352. */
  353. terminate(): void {
  354. if (this.currentProcess) {
  355. logger.appendLine("Terminating backend process...")
  356. // Try graceful shutdown first
  357. this.currentProcess.kill("SIGTERM")
  358. // Force kill after timeout
  359. setTimeout(() => {
  360. if (this.currentProcess && !this.currentProcess.killed) {
  361. logger.appendLine("Force killing backend process...")
  362. this.currentProcess.kill("SIGKILL")
  363. }
  364. }, 5000)
  365. this.currentProcess = undefined
  366. this.currentConnection = undefined
  367. }
  368. }
  369. /**
  370. * Check if backend is currently running
  371. * @returns True if backend process is active
  372. */
  373. isRunning(): boolean {
  374. return this.currentProcess !== undefined && !this.currentProcess.killed
  375. }
  376. }