• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

stacklok / toolhive-studio / 23743484240

30 Mar 2026 11:56AM UTC coverage: 60.372% (+1.3%) from 59.052%
23743484240

Pull #1783

github

web-flow
Merge f53b61be9 into d88b4bdbd
Pull Request #1783: feat(registry): handle authentication for registry server api

2974 of 5204 branches covered (57.15%)

219 of 251 new or added lines in 25 files covered. (87.25%)

4 existing lines in 4 files now uncovered.

4855 of 7764 relevant lines covered (62.53%)

125.43 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.21
/main/src/toolhive-manager.ts
1
import { spawn } from 'node:child_process'
7✔
2
import { existsSync } from 'node:fs'
7✔
3
import path from 'node:path'
4
import net from 'node:net'
5
import { app } from 'electron'
6
import { updateTrayStatus } from './system-tray'
7
import log from './logger'
8
import * as Sentry from '@sentry/electron/main'
9
import { getQuittingState } from './app-state'
10
import {
11
  REGISTRY_AUTH_REQUIRED,
12
  type ToolhiveProcessError,
13
  type ToolhiveStatus,
14
} from '../../common/types/toolhive-status'
15

16
const binName = process.platform === 'win32' ? 'thv.exe' : 'thv'
7!
17
const binPath = app.isPackaged
7!
18
  ? path.join(
19
      process.resourcesPath,
20
      'bin',
21
      `${process.platform}-${process.arch}`,
22
      binName
23
    )
24
  : path.resolve(
25
      __dirname,
26
      '..',
27
      '..',
28
      'bin',
29
      `${process.platform}-${process.arch}`,
30
      binName
31
    )
32

33
let toolhiveProcess: ReturnType<typeof spawn> | undefined
34
let toolhivePort: number | undefined
35
let toolhiveMcpPort: number | undefined
36
let isRestarting = false
7✔
37
let killTimer: NodeJS.Timeout | undefined
38
let processError: ToolhiveProcessError | undefined
39

40
export function getToolhivePort(): number | undefined {
41
  return toolhivePort
3✔
42
}
43

44
export function getToolhiveMcpPort(): number | undefined {
45
  return toolhiveMcpPort
3✔
46
}
47

48
export function isToolhiveRunning(): boolean {
49
  const isRunning = !!toolhiveProcess && !toolhiveProcess.killed
6✔
50
  return isRunning
6✔
51
}
52

53
export function getToolhiveStatus(): ToolhiveStatus {
NEW
54
  return {
×
55
    isRunning: isToolhiveRunning(),
56
    processError,
57
  }
58
}
59

60
/**
61
 * Returns whether the app is using a custom ToolHive port (externally managed thv).
62
 */
63
export function isUsingCustomPort(): boolean {
64
  return !app.isPackaged && !!process.env.THV_PORT
29✔
65
}
66

67
async function findFreePort(
68
  minPort?: number,
69
  maxPort?: number
70
): Promise<number> {
71
  const checkPort = (port: number): Promise<boolean> => {
56✔
72
    return new Promise((resolve) => {
44✔
73
      const server = net.createServer()
44✔
74
      server.listen(port, () => {
44✔
75
        server.close(() => resolve(true))
27✔
76
      })
77
      server.on('error', () => resolve(false))
44✔
78
    })
79
  }
80

81
  const getRandomPort = (): Promise<number> => {
56✔
82
    return new Promise((resolve, reject) => {
29✔
83
      const server = net.createServer()
29✔
84
      server.listen(0, () => {
29✔
85
        const address = server.address()
29✔
86
        if (typeof address === 'object' && address && address.port) {
29!
87
          const port = address.port
29✔
88
          server.close(() => resolve(port))
29✔
89
        } else {
90
          reject(new Error('Failed to get random port'))
×
91
        }
92
      })
93
      server.on('error', reject)
29✔
94
    })
95
  }
96

97
  // If no range specified, use OS assignment directly
98
  if (!minPort || !maxPort) {
56✔
99
    return await getRandomPort()
28✔
100
  }
101

102
  // Try random ports within range for better distribution
103
  const attempts = Math.min(20, maxPort - minPort + 1)
28✔
104
  const triedPorts = new Set<number>()
28✔
105

106
  for (let i = 0; i < attempts; i++) {
28✔
107
    const port = Math.floor(Math.random() * (maxPort - minPort + 1)) + minPort
47✔
108

109
    if (triedPorts.has(port)) continue
47✔
110
    triedPorts.add(port)
44✔
111

112
    if (await checkPort(port)) {
44✔
113
      return port
27✔
114
    }
115
  }
116

117
  // Fallback to OS-assigned random port
118
  log.warn(
1✔
119
    `No free port found in range ${minPort}-${maxPort}, falling back to random port`
120
  )
121
  return await getRandomPort()
1✔
122
}
123

124
export async function startToolhive(): Promise<void> {
125
  Sentry.withScope<Promise<void>>(async (scope) => {
29✔
126
    if (isUsingCustomPort()) {
29!
127
      const customPort = parseInt(process.env.THV_PORT!, 10)
×
128
      if (isNaN(customPort)) {
×
129
        log.error(
×
130
          `Invalid THV_PORT environment variable: ${process.env.THV_PORT}`
131
        )
132
        return
×
133
      }
134
      toolhivePort = customPort
×
135
      toolhiveMcpPort = process.env.THV_MCP_PORT
×
136
        ? parseInt(process.env.THV_MCP_PORT!, 10)
137
        : undefined
138
      log.info(`Using external ToolHive on port ${toolhivePort}`)
×
139
      return
×
140
    }
141

142
    if (!existsSync(binPath)) {
29✔
143
      log.error(`ToolHive binary not found at: ${binPath}`)
1✔
144
      return
1✔
145
    }
146

147
    processError = undefined
28✔
148
    toolhiveMcpPort = await findFreePort()
28✔
149
    toolhivePort = await findFreePort(50000, 50100)
28✔
150
    log.info(
28✔
151
      `Starting ToolHive from: ${binPath} on port ${toolhivePort}, MCP on port ${toolhiveMcpPort}`
152
    )
153

154
    toolhiveProcess = spawn(
28✔
155
      binPath,
156
      [
157
        'serve',
158
        '--openapi',
159
        '--experimental-mcp',
160
        '--experimental-mcp-host=127.0.0.1',
161
        `--experimental-mcp-port=${toolhiveMcpPort}`,
162
        '--host=127.0.0.1',
163
        `--port=${toolhivePort}`,
164
      ],
165
      {
166
        stdio: ['ignore', 'ignore', 'pipe'],
167
        detached: false,
168
        // Ensure child process is killed when parent exits
169
        // On Windows, this creates a job object to enforce cleanup
170
        windowsHide: true,
171
        env: {
172
          ...process.env,
173
          TOOLHIVE_SKIP_DESKTOP_CHECK: 'true',
174
        },
175
      }
176
    )
177
    log.info(`[startToolhive] Process spawned with PID: ${toolhiveProcess.pid}`)
28✔
178

179
    scope.addBreadcrumb({
28✔
180
      category: 'debug',
181
      message: `Starting ToolHive from: ${binPath} on port ${toolhivePort}, MCP on port ${toolhiveMcpPort}, PID: ${toolhiveProcess.pid}`,
182
    })
183

184
    updateTrayStatus(!!toolhiveProcess)
28✔
185

186
    // Capture and log stderr
187
    if (toolhiveProcess.stderr) {
28!
188
      log.info(`[ToolHive] Capturing stderr enabled`)
×
189
      toolhiveProcess.stderr.on('data', (data) => {
×
190
        const output = data.toString().trim()
×
191
        if (!output) return
×
192
        if (output.includes('A new version of ToolHive is available')) {
×
193
          return
×
194
        }
NEW
195
        if (output.includes('registry authentication required')) {
×
NEW
196
          processError = REGISTRY_AUTH_REQUIRED
×
197
        }
198
        log.info(`[ToolHive stderr] ${output}`)
×
199
        scope.addBreadcrumb({
×
200
          category: 'debug',
201
          message: `[ToolHive stderr] ${output}`,
202
          level: 'log',
203
        })
204
      })
205
    }
206

207
    toolhiveProcess.on('error', (error) => {
28✔
208
      log.error('Failed to start ToolHive: ', error)
1✔
209
      Sentry.captureMessage(
1✔
210
        `Failed to start ToolHive: ${JSON.stringify(error)}`,
211
        'fatal'
212
      )
213
      updateTrayStatus(false)
1✔
214
    })
215

216
    toolhiveProcess.on('exit', (code) => {
28✔
217
      log.warn(`ToolHive process exited with code: ${code}`)
4✔
218
      toolhiveProcess = undefined
4✔
219
      if (!isRestarting && !getQuittingState()) {
4✔
220
        updateTrayStatus(false)
2✔
221
        Sentry.captureMessage(
2✔
222
          `ToolHive process exited with code: ${code}`,
223
          'fatal'
224
        )
225
      }
226
    })
227
  })
228
}
229

230
export async function restartToolhive(): Promise<void> {
231
  if (isRestarting) {
4✔
232
    log.info('Restart already in progress, skipping...')
2✔
233
    return
2✔
234
  }
235

236
  isRestarting = true
2✔
237
  log.info('Restarting ToolHive...')
2✔
238

239
  try {
2✔
240
    // Stop existing process if running
241
    if (toolhiveProcess && !toolhiveProcess.killed) {
2!
242
      log.info('Stopping existing ToolHive process...')
2✔
243
      toolhiveProcess.kill()
2✔
244
    }
245

246
    // Start new process
247
    await startToolhive()
2✔
248
    log.info('ToolHive restarted successfully')
2✔
249
  } catch (error) {
250
    log.error('Failed to restart ToolHive: ', error)
×
251
    Sentry.captureMessage(
×
252
      `Failed to restart ToolHive: ${JSON.stringify(error)}`,
253
      'fatal'
254
    )
255
  } finally {
256
    // avoid another restart until process is stabilized
257
    setTimeout(() => {
2✔
258
      isRestarting = false
1✔
259
    }, 5000)
260
  }
261
}
262

263
/** Attempt to kill a process, returning true on success */
264
function tryKillProcess(
265
  process: ReturnType<typeof spawn>,
266
  signal: NodeJS.Signals,
267
  logPrefix: string
268
): boolean {
269
  try {
25✔
270
    const result = process.kill(signal)
25✔
271
    log.info(`${logPrefix} ${signal} sent, result: ${result}`)
25✔
272
    return result
25✔
273
  } catch (err) {
274
    log.error(`${logPrefix} Failed to send ${signal}:`, err)
1✔
275
    return false
1✔
276
  }
277
}
278

279
/** Schedule delayed SIGKILL if process doesn't exit gracefully */
280
function scheduleForceKill(
281
  process: ReturnType<typeof spawn>,
282
  pid: number
283
): void {
284
  killTimer = setTimeout(() => {
18✔
285
    killTimer = undefined
4✔
286

287
    if (!process.killed) {
4✔
288
      log.warn(
3✔
289
        `[stopToolhive] Process ${pid} did not exit gracefully, forcing SIGKILL...`
290
      )
291
      tryKillProcess(process, 'SIGKILL', '[stopToolhive]')
3✔
292
    }
293
  }, 2000)
294
}
295

296
export function stopToolhive(options?: { force?: boolean }): void {
297
  const force = options?.force ?? false
37✔
298

299
  // Clear any pending kill timer
300
  if (killTimer) {
37✔
301
    clearTimeout(killTimer)
13✔
302
    killTimer = undefined
13✔
303
  }
304

305
  // Early return if no process to stop
306
  if (!toolhiveProcess || toolhiveProcess.killed) {
37✔
307
    log.info(
16✔
308
      `[stopToolhive] No process to stop (process=${!!toolhiveProcess}, killed=${toolhiveProcess?.killed})`
309
    )
310
    return
16✔
311
  }
312

313
  const pidToKill = toolhiveProcess.pid
21✔
314
  log.info(`Stopping ToolHive process (PID: ${pidToKill})...`)
21✔
315

316
  // Capture process reference before clearing global
317
  const processToKill = toolhiveProcess
21✔
318
  toolhiveProcess = undefined
21✔
319

320
  // Attempt to kill the process
321
  const signal: NodeJS.Signals = force ? 'SIGKILL' : 'SIGTERM'
21✔
322
  const killed = tryKillProcess(processToKill, signal, '[stopToolhive]')
37✔
323

324
  // If graceful shutdown failed, try force kill immediately
325
  if (!killed) {
37✔
326
    tryKillProcess(processToKill, 'SIGKILL', '[stopToolhive]')
1✔
327
    log.info(`[stopToolhive] Process cleanup completed`)
1✔
328
    return
1✔
329
  }
330

331
  // For graceful shutdown, schedule delayed force kill
332
  if (!force && pidToKill !== undefined) {
20✔
333
    scheduleForceKill(processToKill, pidToKill)
18✔
334
  }
335

336
  log.info(`[stopToolhive] Process cleanup completed`)
20✔
337
}
338

339
export { binPath }
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc