11/**
2- * RELEASE_TEST_CHECKLIST #146 - fixed model ports are single-owner .
2+ * RELEASE_TEST_CHECKLIST #146 - a held model port never dead-ends the app .
33 *
44 * The first owner is either the already-running healthy production llama-server or a separate
55 * process launching the only fake: a behaviour-faithful native llama-server boundary on the real
6- * production port. The contender is the production LLMService. Real lsof/ps parent ownership,
7- * loopback HTTP, GGUF validation, model resolution, startup refusal, error classification, and
8- * chat-health presentation remain real. Cleanup only owns processes this test spawned.
6+ * production port. The contender is the production LLMService, which - rather than refusing when
7+ * the preferred port is taken - scans upward for a free port and starts its own engine there, so
8+ * the app works even when something else holds :8439. Real lsof/ps parent ownership, loopback
9+ * HTTP, GGUF validation, model resolution, free-port fallback, and chat-health presentation
10+ * remain real. Cleanup only owns processes this test spawned.
911 */
1012import { afterAll , beforeAll , describe , expect , it , vi } from 'vitest'
1113import { execSync , spawn , type ChildProcess } from 'node:child_process'
@@ -73,10 +75,14 @@ const server = http.createServer((req, res) => {
7375server.listen(port, '127.0.0.1', () => {
7476 const address = server.address()
7577 const actualPort = typeof address === 'object' && address ? address.port : port
76- fs.appendFileSync(
77- process.env.OFFGRID_TEST_ENGINE_LOG,
78- String(process.pid) + ':' + String(actualPort) + '\\n'
79- )
78+ // Only the test-spawned FIRST owner sets this log; the production LLMService spawning the
79+ // same binary on its fallback port does NOT, so it must not crash on a missing log path.
80+ if (process.env.OFFGRID_TEST_ENGINE_LOG) {
81+ fs.appendFileSync(
82+ process.env.OFFGRID_TEST_ENGINE_LOG,
83+ String(process.pid) + ':' + String(actualPort) + '\\n'
84+ )
85+ }
8086})
8187process.on('SIGTERM', () => server.close(() => process.exit(0)))
8288`
@@ -233,19 +239,24 @@ afterAll(async () => {
233239} )
234240
235241describe ( 'model port ownership' , ( ) => {
236- it ( 'preserves the first live engine and explains the second-instance conflict (#146)' , async ( ) => {
242+ it ( 'preserves the first live engine and falls back to a free port for the second (#146)' , async ( ) => {
237243 const [ { llm } , { getSystemHealth } , { modelPortConflictReason } ] = await Promise . all ( [
238244 import ( '../llm' ) ,
239245 import ( '../setup' ) ,
240246 import ( '../llama-error' )
241247 ] )
242248 const conflict = modelPortConflictReason ( LLAMA_SERVER_PORT )
243249
244- await expect ( llm . init ( ) ) . rejects . toThrow ( conflict )
245- expect ( llm . isReady ( ) ) . toBe ( false )
246- expect ( llm . isStarting ( ) ) . toBe ( false )
247- expect ( llm . lastError ( ) ) . toBe ( conflict )
250+ // The preferred port is held by the first live engine. Rather than dead-ending on a
251+ // single-owner conflict, the second instance scans upward and starts its own engine on a
252+ // free port — the app just works even when something else holds :8439.
253+ await llm . init ( )
254+ expect ( llm . isReady ( ) ) . toBe ( true )
255+ expect ( llm . getPort ( ) ) . not . toBe ( LLAMA_SERVER_PORT )
256+ // The conflict reason is NOT surfaced — we moved instead of refusing.
257+ expect ( llm . lastError ( ) ) . not . toBe ( conflict )
248258
259+ // The FIRST engine is untouched: still alive, still the sole owner of the preferred port.
249260 expect ( processIsAlive ( enginePid ) ) . toBe ( true )
250261 expect ( await engineIsReady ( ) ) . toBe ( true )
251262 if ( liveOwner ) {
@@ -254,9 +265,14 @@ describe('model port ownership', () => {
254265 ] )
255266 }
256267
268+ // Chat health reports UP, on the fallback port — not down with a port-conflict detail.
257269 const chatHealth = ( await getSystemHealth ( ) ) . components . find (
258270 ( component ) => component . id === 'chat'
259271 )
260- expect ( chatHealth ) . toMatchObject ( { status : 'down' , detail : conflict , port : LLAMA_SERVER_PORT } )
272+ expect ( chatHealth ) . toMatchObject ( { status : 'ready' , port : llm . getPort ( ) } )
273+ expect ( chatHealth ?. detail ) . not . toBe ( conflict )
274+
275+ // Tear down the second engine this test started (the first owner is cleaned up in afterAll).
276+ await llm . unload ( )
261277 } )
262278} )
0 commit comments