Skip to content

Commit 77de3e8

Browse files
feat(net): llama-server falls back to a free port when :8439 is taken (increment 2)
prepareModelPort used to THROW a hard conflict when another live app owned :8439 (LM Studio, a 2nd instance) — dead-ending the engine. Now it scans upward (pickFreePort) and binds the next free port; it only surfaces the conflict if the whole window is occupied. llm.getPort() exposes the LIVE port, and the gateway (model-server) now proxies to llm.getPort() per-request instead of a fixed UPSTREAM_PORT const — so an engine that moved to 8440 still gets its traffic. The app talks to this.port directly, so it follows automatically. Tests: free-port behavioral (selection + real socket probe, 7); port-fallback-wiring source guards (fallback-not-throw + this.port=free + getPort + gateway reads llm.getPort(), no stale const) (5). llm.ts/model-server.ts are coverage-excluded native shells — pure logic lives in free-port (tested).
1 parent a46d626 commit 77de3e8

3 files changed

Lines changed: 76 additions & 9 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// @vitest-environment node
2+
// llm.ts and model-server.ts are coverage-excluded native/spawn/IPC shells; the selection logic is
3+
// behaviorally tested in free-port.test.ts. These source guards lock the WIRING so it can't silently
4+
// regress: (1) a port conflict falls back to a free port instead of throwing; (2) the gateway proxies
5+
// to the LIVE engine port, never a fixed constant.
6+
import { describe, it, expect } from 'vitest'
7+
import { readFileSync } from 'node:fs'
8+
import { join } from 'node:path'
9+
10+
const read = (rel: string): string => readFileSync(join(__dirname, '..', rel), 'utf8')
11+
12+
describe('llm.ts — port conflict falls back to a free port', () => {
13+
const src = read('llm.ts')
14+
15+
it('scans for a free port with pickFreePort when another app owns the preferred one', () => {
16+
expect(src).toMatch(/pickFreePort\(this\.port/)
17+
// The chosen free port becomes the live port.
18+
expect(src).toMatch(/this\.port = free/)
19+
})
20+
21+
it('only surfaces the hard conflict when NO free port is found (not on first collision)', () => {
22+
// The throw is gated behind `free === null`, not fired unconditionally on liveOwners.
23+
expect(src).toMatch(/if \(free === null\)[\s\S]*?throw new Error/)
24+
})
25+
26+
it('exposes the live port via getPort()', () => {
27+
expect(src).toMatch(/getPort\(\): number\s*{\s*return this\.port/)
28+
})
29+
})
30+
31+
describe('model-server.ts — gateway proxies to the LIVE engine port', () => {
32+
const src = read('model-server.ts')
33+
34+
it('reads llm.getPort() for the upstream, not a fixed constant', () => {
35+
expect(src).toMatch(/upstreamPort = \(\): number => llm\.getPort\(\)/)
36+
// No lingering fixed-constant upstream.
37+
expect(src).not.toMatch(/const UPSTREAM_PORT = LLAMA_SERVER_PORT/)
38+
})
39+
40+
it('every upstream request targets upstreamPort(), never a stale UPSTREAM_PORT', () => {
41+
expect(src).not.toMatch(/\bUPSTREAM_PORT\b/)
42+
expect(src).toMatch(/port: upstreamPort\(\)/)
43+
})
44+
})

src/main/llm.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { buildMessages, imageMime, thinkingPayload, type DecodedImage } from './llm/chat-payload'
3030
import { isValidGgufFile } from './models/gguf'
3131
import { readGgufContextLength } from './models/gguf-metadata'
32+
import { pickFreePort, isPortFree } from './free-port'
3233
import { postCompletionOnce } from './llm/http-post'
3334
import { streamCompletion, type StreamResult } from './llm/stream'
3435
import {
@@ -205,6 +206,13 @@ export class LLMService {
205206
return this.trainedContext()
206207
}
207208

209+
/** The port llama-server is actually on. Usually LLAMA_SERVER_PORT, but prepareModelPort moves it
210+
* to a free port when another app owns the preferred one — so consumers (the gateway upstream)
211+
* must read this LIVE value, never the constant. */
212+
getPort(): number {
213+
return this.port
214+
}
215+
208216
private safeCtxSize(requestedRaw: number): number {
209217
// First cap to the model's trained window (pure), THEN clamp to what RAM can hold.
210218
const trained = this.trainedContext()
@@ -785,9 +793,21 @@ export class LLMService {
785793
private async prepareModelPort(): Promise<void> {
786794
const ownership = this.reapOrphansOnPort(this.port)
787795
if (ownership.liveOwners.length > 0) {
788-
this.lastErrorMsg = modelPortConflictReason(this.port)
789-
console.error(`[LLMService] ${this.lastErrorMsg}`)
790-
throw new Error(this.lastErrorMsg)
796+
// Another LIVE app owns our preferred port (LM Studio on :8439, a second Off Grid instance).
797+
// Don't fight it or dead-end — scan upward for the next free port and move there. The gateway
798+
// proxies to llm.getPort() (live), and the app talks to this.port directly, so both follow.
799+
const free = await pickFreePort(this.port, (p) => isPortFree(p))
800+
if (free === null) {
801+
this.lastErrorMsg = modelPortConflictReason(this.port)
802+
console.error(`[LLMService] ${this.lastErrorMsg}`)
803+
throw new Error(this.lastErrorMsg)
804+
}
805+
console.warn(
806+
`[LLMService] port ${this.port} is owned by another app — falling back to free port ${free}`
807+
)
808+
this.port = free
809+
this.lastErrorMsg = null
810+
return
791811
}
792812
if (ownership.killed > 0) {
793813
await new Promise((resolve) => setTimeout(resolve, 400))

src/main/model-server.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { embeddings } from './embeddings'
3434
import { docsText, docsHtml, openApiSpec } from './api-docs'
3535
import { handleMcpRequest } from './mcp-server'
3636
import { llm, type LlmSettings } from './llm'
37-
import { LLAMA_SERVER_PORT, GATEWAY_HOST, GATEWAY_PORT } from '../shared/ports'
37+
import { GATEWAY_HOST, GATEWAY_PORT } from '../shared/ports'
3838
import { retryWithDeadline } from './lib/retry'
3939
import { resolveDims } from './model-server/dimensions'
4040
import { guardProxyStreams } from './stream-guards'
@@ -56,7 +56,10 @@ import { safeProxyResponse } from './model-server/proxy-response'
5656
import { writeDiagnosticLog } from './diagnostics-log'
5757

5858
const UPSTREAM_HOST = '127.0.0.1'
59-
const UPSTREAM_PORT = LLAMA_SERVER_PORT // bundled llama-server (see llm.ts)
59+
// The upstream llama-server port is LIVE, not fixed: llm.getPort() moves off LLAMA_SERVER_PORT when
60+
// another app owns it (see llm.prepareModelPort). Read it per-request so the gateway always proxies
61+
// to wherever the engine actually bound. (LLAMA_SERVER_PORT stays the PREFERRED default in llm.)
62+
const upstreamPort = (): number => llm.getPort()
6063
const MAX_UPLOAD = 200 * 1024 * 1024 // 200MB upload cap (audio / init image)
6164

6265
let server: http.Server | null = null
@@ -197,7 +200,7 @@ function proxyToLlama(
197200
bodyOverride?: Buffer,
198201
retryUntil = 0
199202
): void {
200-
const headers = { ...req.headers, host: `${UPSTREAM_HOST}:${UPSTREAM_PORT}` }
203+
const headers = { ...req.headers, host: `${UPSTREAM_HOST}:${upstreamPort()}` }
201204
if (bodyOverride) {
202205
headers['content-length'] = String(bodyOverride.length)
203206
delete headers['transfer-encoding']
@@ -209,7 +212,7 @@ function proxyToLlama(
209212
const proxyReq = http.request(
210213
{
211214
hostname: UPSTREAM_HOST,
212-
port: UPSTREAM_PORT,
215+
port: upstreamPort(),
213216
path: req.url,
214217
method: req.method,
215218
headers
@@ -381,7 +384,7 @@ function callLlamaJson(bodyObj: Record<string, unknown>, retryUntil: number): Pr
381384
const upstream = http.request(
382385
{
383386
hostname: UPSTREAM_HOST,
384-
port: UPSTREAM_PORT,
387+
port: upstreamPort(),
385388
path: '/v1/chat/completions',
386389
method: 'POST',
387390
headers: { 'content-type': 'application/json', 'content-length': String(payload.length) }
@@ -535,7 +538,7 @@ async function handleEmbeddings(
535538
function fetchUpstreamModels(): Promise<Record<string, unknown>> {
536539
return new Promise((resolve) => {
537540
const r = http.request(
538-
{ hostname: UPSTREAM_HOST, port: UPSTREAM_PORT, path: '/v1/models', method: 'GET' },
541+
{ hostname: UPSTREAM_HOST, port: upstreamPort(), path: '/v1/models', method: 'GET' },
539542
(pr) => {
540543
let b = ''
541544
pr.on('data', (d) => (b += d))

0 commit comments

Comments
 (0)