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):
configured_host = conf().get("web_host", "")
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", "::")
self._cleanup_stale_voice_recordings()

View File

@@ -4,16 +4,23 @@ import path from 'path'
import os from 'os'
import fs from 'fs'
import http from 'http'
import net from 'net'
// 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
// the read-only app bundle. Source/dev runs keep using the repo CWD instead.
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 {
private process: ChildProcess | null = null
private backendPath: string
private port: number = 9899
private port: number = PREFERRED_PORT
private status: 'stopped' | 'starting' | 'ready' | 'error' = 'stopped'
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
* 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 {
const configPath = path.join(dataDir, 'config.json')
if (fs.existsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
if (config.web_port) {
return config.web_port
const p = Number(config.web_port)
if (Number.isInteger(p) && p > 0 && p < 65536) {
return p
}
}
} 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> {
@@ -97,14 +150,17 @@ export class PythonBackend extends EventEmitter {
const bundled = this.findBundledBackend()
// Packaged app stores writable data in ~/.cow; dev keeps it in the repo.
const dataDir = bundled ? COW_DATA_DIR : this.backendPath
this.port = this.readPort(dataDir)
const alreadyRunning = await this.probeHealth()
if (alreadyRunning) {
this.status = 'ready'
this.emit('log', `Backend already running on port ${this.port}`)
this.emit('ready', this.port)
return
// Always launch our OWN backend (re-entrancy is guarded above by the
// status check, so we never double-spawn for this instance). We don't reuse
// whatever happens to be on the port: that's how the app previously
// attached to a source-run web console and read the wrong config. Pick a
// usable port instead — honoring a user-pinned web_port, else PREFERRED_PORT
// 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
@@ -150,6 +206,9 @@ export class PythonBackend extends EventEmitter {
...process.env,
PYTHONUNBUFFERED: '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 } : {}),
},
stdio: ['pipe', 'pipe', 'pipe'],
@@ -185,16 +244,6 @@ export class PythonBackend extends EventEmitter {
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> {
return new Promise((resolve) => {
// Wall-clock deadline rather than an attempt counter: if the machine

View File

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

View File

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