fix(desktop): isolate backend port from the web console

This commit is contained in:
zhayujie
2026-06-30 10:48:33 +08:00
parent 0a762b8c08
commit ca876b0c65
4 changed files with 83 additions and 31 deletions

View File

@@ -1157,7 +1157,10 @@ class WebChannel(ChatChannel):
def startup(self): def startup(self):
configured_host = conf().get("web_host", "") configured_host = conf().get("web_host", "")
host = configured_host or ("0.0.0.0" if _is_password_enabled() else "127.0.0.1") host = configured_host or ("0.0.0.0" if _is_password_enabled() else "127.0.0.1")
port = conf().get("web_port", 9899) # The desktop app passes its chosen port via COW_WEB_PORT so its backend
# never collides with a source-run web console (default 9899). This makes
# the port a single source of truth owned by the Electron shell.
port = int(os.environ.get("COW_WEB_PORT") or conf().get("web_port", 9899))
is_public_bind = host in ("0.0.0.0", "::") is_public_bind = host in ("0.0.0.0", "::")
self._cleanup_stale_voice_recordings() self._cleanup_stale_voice_recordings()

View File

@@ -4,16 +4,23 @@ import path from 'path'
import os from 'os' import os from 'os'
import fs from 'fs' import fs from 'fs'
import http from 'http' import http from 'http'
import net from 'net'
// Writable data dir for the packaged app (config.json, run.log, user data). // Writable data dir for the packaged app (config.json, run.log, user data).
// Lives in the user's home so it survives app updates and avoids writing into // Lives in the user's home so it survives app updates and avoids writing into
// the read-only app bundle. Source/dev runs keep using the repo CWD instead. // the read-only app bundle. Source/dev runs keep using the repo CWD instead.
const COW_DATA_DIR = path.join(os.homedir(), '.cow') const COW_DATA_DIR = path.join(os.homedir(), '.cow')
// Preferred port for the desktop backend. Deliberately not 9899 (the web
// console's default) so a source-run `python app.py` never collides with the
// packaged app. It's only a *preference*: if it's taken we bind a free port
// chosen by the OS, so we never hard-depend on any single port being free.
const PREFERRED_PORT = 9876
export class PythonBackend extends EventEmitter { export class PythonBackend extends EventEmitter {
private process: ChildProcess | null = null private process: ChildProcess | null = null
private backendPath: string private backendPath: string
private port: number = 9899 private port: number = PREFERRED_PORT
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped' private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
constructor(backendPath: string) { constructor(backendPath: string) {
@@ -66,23 +73,69 @@ export class PythonBackend extends EventEmitter {
} }
/** /**
* Resolve config.json from the given data dir to read the web port. The * Read an explicit `web_port` from config.json, if the user pinned one. The
* packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the * packaged build keeps config in COW_DATA_DIR (~/.cow); dev reads it from the
* repo path. Returns the default port when no config (or web_port) is found. * repo path. Returns null when unset, so the caller can auto-pick a free port
* instead of fighting over a fixed one.
*/ */
private readPort(dataDir: string): number { private readConfiguredPort(dataDir: string): number | null {
try { try {
const configPath = path.join(dataDir, 'config.json') const configPath = path.join(dataDir, 'config.json')
if (fs.existsSync(configPath)) { if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (config.web_port) { const p = Number(config.web_port)
return config.web_port if (Number.isInteger(p) && p > 0 && p < 65536) {
return p
} }
} }
} catch { } catch {
// ignore // ignore — fall through to auto-selection
} }
return 9899 return null
}
/**
* Resolve a usable port without hard-depending on any specific one:
* 1. A user-pinned web_port is honored as-is (their explicit choice).
* 2. Otherwise try PREFERRED_PORT; if free, use it.
* 3. If taken, let the OS hand us a guaranteed-free ephemeral port.
* This never throws on a busy port — it just moves on to a free one.
*/
private async resolvePort(dataDir: string): Promise<number> {
const pinned = this.readConfiguredPort(dataDir)
if (pinned !== null) {
return pinned
}
if (await this.isPortFree(PREFERRED_PORT)) {
return PREFERRED_PORT
}
return this.findFreePort()
}
/** True if we can bind 127.0.0.1:port right now (i.e. it's free). */
private isPortFree(port: number): Promise<boolean> {
return new Promise((resolve) => {
const tester = net
.createServer()
.once('error', () => resolve(false))
.once('listening', () => {
tester.close(() => resolve(true))
})
.listen(port, '127.0.0.1')
})
}
/** Ask the OS for a free ephemeral port (listen on 0, read the assigned one). */
private findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.once('error', reject)
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
const port = typeof addr === 'object' && addr ? addr.port : PREFERRED_PORT
srv.close(() => resolve(port))
})
})
} }
async start(): Promise<void> { async start(): Promise<void> {
@@ -97,14 +150,17 @@ export class PythonBackend extends EventEmitter {
const bundled = this.findBundledBackend() const bundled = this.findBundledBackend()
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo. // Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
const dataDir = bundled ? COW_DATA_DIR : this.backendPath const dataDir = bundled ? COW_DATA_DIR : this.backendPath
this.port = this.readPort(dataDir)
const alreadyRunning = await this.probeHealth() // Always launch our OWN backend (re-entrancy is guarded above by the
if (alreadyRunning) { // status check, so we never double-spawn for this instance). We don't reuse
this.status = 'ready' // whatever happens to be on the port: that's how the app previously
this.emit('log', `Backend already running on port ${this.port}`) // attached to a source-run web console and read the wrong config. Pick a
this.emit('ready', this.port) // usable port instead — honoring a user-pinned web_port, else PREFERRED_PORT
return // when free, else an OS-assigned free port. This never depends on a probe.
try {
this.port = await this.resolvePort(dataDir)
} catch {
this.port = PREFERRED_PORT
} }
let command: string let command: string
@@ -150,6 +206,9 @@ export class PythonBackend extends EventEmitter {
...process.env, ...process.env,
PYTHONUNBUFFERED: '1', PYTHONUNBUFFERED: '1',
COW_DESKTOP: '1', COW_DESKTOP: '1',
// The shell owns the port: tell the backend to bind exactly here so the
// two sides can never disagree (and we avoid the 9899 web-console clash).
COW_WEB_PORT: String(this.port),
...(bundled ? { COW_DATA_DIR } : {}), ...(bundled ? { COW_DATA_DIR } : {}),
}, },
stdio: ['pipe', 'pipe', 'pipe'], stdio: ['pipe', 'pipe', 'pipe'],
@@ -185,16 +244,6 @@ export class PythonBackend extends EventEmitter {
await this.waitForReady() await this.waitForReady()
} }
private probeHealth(): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
resolve(res.statusCode === 200)
})
req.on('error', () => resolve(false))
req.setTimeout(2000, () => { req.destroy(); resolve(false) })
})
}
private waitForReady(): Promise<void> { private waitForReady(): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
// Wall-clock deadline rather than an attempt counter: if the machine // Wall-clock deadline rather than an attempt counter: if the machine

View File

@@ -25,7 +25,7 @@ interface ApiResult {
} }
class ApiClient { class ApiClient {
private baseUrl = 'http://127.0.0.1:9899' private baseUrl = 'http://127.0.0.1:9876'
setBaseUrl(url: string) { setBaseUrl(url: string) {
this.baseUrl = url this.baseUrl = url

View File

@@ -9,7 +9,7 @@ interface BackendState {
export function useBackend() { export function useBackend() {
const [state, setState] = useState<BackendState>({ const [state, setState] = useState<BackendState>({
status: 'connecting', status: 'connecting',
port: 9899, port: 9876,
}) })
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null) const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -31,7 +31,7 @@ export function useBackend() {
const readyRef = useRef(false) const readyRef = useRef(false)
// Holds the latest resolved port so the visibility handler (registered once) // Holds the latest resolved port so the visibility handler (registered once)
// always probes the correct port without re-running the effect. // always probes the correct port without re-running the effect.
const portRef = useRef(9899) const portRef = useRef(9876)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@@ -75,7 +75,7 @@ export function useBackend() {
if (api) { if (api) {
api.getBackendPort().then((port) => { api.getBackendPort().then((port) => {
const p = port || 9899 const p = port || 9876
portRef.current = p portRef.current = p
setState((prev) => ({ ...prev, port: p })) setState((prev) => ({ ...prev, port: p }))
startPolling(p) startPolling(p)
@@ -98,7 +98,7 @@ export function useBackend() {
} }
}) })
} else { } else {
startPolling(9899) startPolling(9876)
} }
// When the window comes back to the foreground, re-probe immediately so a // When the window comes back to the foreground, re-probe immediately so a