diff --git a/channel/web/web_channel.py b/channel/web/web_channel.py index 52f873e3..517e2d93 100644 --- a/channel/web/web_channel.py +++ b/channel/web/web_channel.py @@ -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() diff --git a/desktop/src/main/python-manager.ts b/desktop/src/main/python-manager.ts index 8edf5766..a7487a03 100644 --- a/desktop/src/main/python-manager.ts +++ b/desktop/src/main/python-manager.ts @@ -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 { + 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 { + 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 { + 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 { @@ -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 { - 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 { return new Promise((resolve) => { // Wall-clock deadline rather than an attempt counter: if the machine diff --git a/desktop/src/renderer/src/api/client.ts b/desktop/src/renderer/src/api/client.ts index 05feeee4..847d634d 100644 --- a/desktop/src/renderer/src/api/client.ts +++ b/desktop/src/renderer/src/api/client.ts @@ -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 diff --git a/desktop/src/renderer/src/hooks/useBackend.ts b/desktop/src/renderer/src/hooks/useBackend.ts index 146ee071..2a62e69f 100644 --- a/desktop/src/renderer/src/hooks/useBackend.ts +++ b/desktop/src/renderer/src/hooks/useBackend.ts @@ -9,7 +9,7 @@ interface BackendState { export function useBackend() { const [state, setState] = useState({ status: 'connecting', - port: 9899, + port: 9876, }) const pollingRef = useRef | 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