feedBack-desktop/src/main/python.ts
2026-07-17 18:42:43 +02:00

950 lines
45 KiB
TypeScript

// Python subprocess management for the embedded Slopsmith server.
// Starts the FastAPI server as a child process, monitors health,
// and forwards logs.
import { ChildProcess, spawn, spawnSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import { app } from 'electron';
import * as http from 'http';
import * as net from 'net';
import * as os from 'os';
import { getActiveSoundfontPath, getDesktopConfig } from './soundfont-manager';
import { isDebugEnabled } from './debug-log';
import {
applyLibraryPathToPythonEnvironment,
normalizeExistingLibraryDirectory,
prepareLibraryPathForPython,
} from './library-path-config';
let pythonProcess: ChildProcess | null = null;
// A backend that is being *gracefully* stopped (SIGTERM sent, async SIGKILL
// timer pending) but isn't confirmed dead yet. restartPython() nulls
// pythonProcess and waits ~1s before spawning the replacement, so for that
// window (and until the old child's SIGTERM lands, up to the 5s timer) the
// dying backend is reachable only here — an app quit in that window must be
// able to force-kill it synchronously, or it orphans (its own async timer
// can't fire once the Electron main process exits). Cleared on the child's
// `close` (or when the graceful timer force-kills it).
let stoppingPythonProcess: ChildProcess | null = null;
// Use 18000+ to avoid conflicting with Docker Slopsmith on 8000. The renderer
// loads from http://127.0.0.1:${serverPort}, so this is also the localStorage
// origin — keeping it stable across launches is what stops origin-keyed UI /
// plugin settings (left-handed mode, volume, …) from resetting (#491).
const PREFERRED_PORT = 18000;
let serverPort = PREFERRED_PORT;
let serverReady = false;
// Set by startPython when the backend cannot even be spawned (e.g. server.py
// missing). waitForPython checks this so a config error fails fast with a
// specific message instead of running out the full readiness timeout.
let startupError: string | null = null;
// True once waitForPython has confirmed the server with a successful HTTP
// probe. Until then, any child exit is a startup failure — serverReady
// alone is not enough, since the child can log "startup complete" and then
// exit before the probe succeeds.
let startupComplete = false;
export function getPythonPort(): number {
return serverPort;
}
// Interface the backend binds. Default loopback (127.0.0.1); 0.0.0.0 (all
// interfaces, LAN-reachable) only when the user opts in via the desktop
// config's `lanAccess` flag. 0.0.0.0 still includes loopback, so the renderer
// (which loads http://127.0.0.1:${port}) is unaffected either way. See #441.
function getBindHost(): string {
return getDesktopConfig().lanAccess ? '0.0.0.0' : '127.0.0.1';
}
// LAN URLs the backend is reachable at when LAN access is enabled — the
// machine's non-internal IPv4 addresses on the current server port. Used by
// the desktop UI to tell the user where to point their phone/other device.
// Returns [] when no external IPv4 interface is up.
export function getLanUrls(): string[] {
const urls: string[] = [];
const ifaces = os.networkInterfaces();
for (const name of Object.keys(ifaces)) {
for (const ni of ifaces[name] || []) {
if (ni.family === 'IPv4' && !ni.internal) {
urls.push(`http://${ni.address}:${serverPort}`);
}
}
}
return urls;
}
export interface StartupStatus {
running: boolean;
phase: string;
message: string;
/** Name of the plugin currently being loaded (raw string value from the backend `current_plugin` field). */
currentPlugin: string;
loaded: number;
total: number;
error?: string | null;
}
// Find an available port starting from 8000
async function findPort(startPort: number): Promise<number> {
// Probe on the SAME interface the backend will bind (getBindHost): with
// LAN access on we bind 0.0.0.0, where a port can be free on loopback yet
// taken on another interface — a 127.0.0.1-only probe would call it free
// and uvicorn would then fail with EADDRINUSE. Found in Codex review.
const bindHost = getBindHost();
return new Promise((resolve) => {
const server = net.createServer();
server.listen(startPort, bindHost, () => {
server.close(() => resolve(startPort));
});
server.on('error', () => {
resolve(findPort(startPort + 1));
});
});
}
function findPythonExecutable(): string {
// In packaged app, look for bundled Python
if (app.isPackaged) {
const resourcesPath = process.resourcesPath;
const candidates = [
// Linux/macOS: venv with --copies or full runtime
path.join(resourcesPath, 'python', 'runtime', 'bin', 'python3'),
path.join(resourcesPath, 'python', 'runtime', 'bin', 'python'),
// Windows: embedded Python
path.join(resourcesPath, 'python', 'python.exe'),
// Fallback paths
path.join(resourcesPath, 'python', 'bin', 'python3'),
path.join(resourcesPath, 'python', 'python3'),
path.join(resourcesPath, 'python', 'python'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
}
// Development: check for local venv first
const venvPython = path.join(__dirname, '..', '..', '.venv', 'bin', 'python3');
if (fs.existsSync(venvPython)) return venvPython;
return 'python3';
}
function reapOrphanedPythonBackends(pythonPath: string): void {
if (process.platform === 'win32') return;
const result = spawnSync('ps', ['-ww', '-axo', 'pid=,ppid=,command='], PROBE_SPAWN);
if (result.error || result.status !== 0 || typeof result.stdout !== 'string') return;
const expectedPrefixes = new Set<string>();
const uvicornArgs = ' -m uvicorn server:app';
if (path.isAbsolute(pythonPath)) {
expectedPrefixes.add(`${path.resolve(pythonPath)}${uvicornArgs}`);
} else {
expectedPrefixes.add(`${pythonPath}${uvicornArgs}`);
expectedPrefixes.add(`${path.basename(pythonPath)}${uvicornArgs}`);
}
const expectedPrefixList = [...expectedPrefixes];
const stalePids: number[] = [];
for (const line of result.stdout.split('\n')) {
const match = line.trim().match(/^(\d+)\s+(\d+)\s+(.+)$/);
if (!match) continue;
const pid = Number(match[1]);
const ppid = Number(match[2]);
const command = match[3];
// Only reap fully-orphaned backends (reparented to init). A matching
// uvicorn process with a live parent could be another app instance's
// managed backend or a developer's own `uvicorn server:app` on a
// different port — killing those would be a cross-process regression.
// The actual port-18000 holder is handled precisely by reclaimPort().
if (!Number.isInteger(pid) || ppid !== 1) continue;
if (!expectedPrefixList.some(prefix => command.startsWith(prefix))) continue;
stalePids.push(pid);
}
for (const pid of stalePids) {
try {
process.kill(pid, 'SIGTERM');
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ESRCH') console.warn(`[python] failed to reap orphaned backend ${pid} (${code})`);
}
}
if (stalePids.length) {
console.log(`[python] Reaped orphaned backend PIDs: ${stalePids.join(', ')}`);
}
}
// True if the TCP port can be bound right now on the interface the backend
// will use (getBindHost) — loopback by default, 0.0.0.0 when LAN access is on,
// so the free/busy answer matches what uvicorn will actually attempt.
function isPortFree(port: number): Promise<boolean> {
const bindHost = getBindHost();
return new Promise((resolve) => {
const probe = net.createServer();
probe.once('error', () => resolve(false));
probe.listen(port, bindHost, () => {
probe.close(() => resolve(true));
});
});
}
// Poll until `port` is bindable or `timeoutMs` elapses; returns whether it
// became free. Killing a stale backend is asynchronous (SIGTERM/SIGKILL) and
// the OS can hold the listener briefly after the process dies, so binding
// immediately would drift to 18001 and change the renderer origin anyway.
// In the common case the first probe succeeds, so this adds no startup delay.
async function waitForPortFree(port: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (await isPortFree(port)) return true;
if (Date.now() >= deadline) return false;
await new Promise((r) => setTimeout(r, 150));
}
}
// Shared spawnSync options for the startup port-reclaim probes: a short
// timeout so a hung query (netstat / lsof / ps / powershell / taskkill) can
// never freeze app startup, and windowsHide so none of them flash a console
// window on Windows. On timeout, spawnSync sets `.error` (ETIMEDOUT), which
// every caller already treats as "couldn't query".
const PROBE_SPAWN: { encoding: BufferEncoding; timeout: number; windowsHide: boolean } = {
encoding: 'utf8', timeout: 5000, windowsHide: true,
};
// Our backend is launched as
// python -m uvicorn server:app --host <127.0.0.1|0.0.0.0> --port <p> …
// The host depends on the LAN-access setting (getBindHost), so accept either
// of our two signatures. Match on the full signature (not just the python
// image) so we never kill an unrelated orphaned uvicorn `server:app` that
// happens to hold the port.
function isOurBackendCmd(cmd: string): boolean {
return cmd.includes('uvicorn')
&& cmd.includes('server:app')
&& (cmd.includes('--host 127.0.0.1') || cmd.includes('--host 0.0.0.0'));
}
// PIDs LISTENING at <port> on a local bind we might use — loopback
// (127.0.0.1 / ::1) OR all-interfaces (0.0.0.0 / ::), since the backend binds
// 0.0.0.0 when LAN access is enabled (see getBindHost). Cross-platform;
// returns [] when the query tool is unavailable, hangs, or nothing is
// listening. The caller (reclaimPort) only ever kills a PID that
// `backendProcInfo` confirms is *our* uvicorn backend, so matching a
// non-loopback listener here is safe — a stranger on the port is left alone.
function findPidsOnPort(port: number): number[] {
const pids = new Set<number>();
const isLocalBind = (addr: string): boolean =>
addr === '127.0.0.1' || addr === '[::1]' || addr === '::1'
|| addr === '0.0.0.0' || addr === '[::]' || addr === '::' || addr === '*';
if (process.platform === 'win32') {
const out = spawnSync('netstat', ['-ano', '-p', 'tcp'], PROBE_SPAWN);
if (out.error || typeof out.stdout !== 'string') return [];
for (const line of out.stdout.split('\n')) {
// Proto Local Address Foreign Address State PID
// TCP 0.0.0.0:18000 0.0.0.0:0 LISTENING 1234
const m = line.trim().match(/^TCP\s+(\S+):(\d+)\s+\S+\s+LISTENING\s+(\d+)/i);
if (!m || Number(m[2]) !== port || !isLocalBind(m[1])) continue;
const pid = Number(m[3]);
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
}
} else {
// lsof ships with macOS and most Linux installs. Query by port only
// (no @host) so it matches whether the holder bound loopback or
// 0.0.0.0 — the kill decision is gated on isOurBackendCmd downstream.
const out = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], PROBE_SPAWN);
if (!out.error && typeof out.stdout === 'string') {
for (const tok of out.stdout.split(/\s+/)) {
const pid = Number(tok);
if (Number.isInteger(pid) && pid > 0) pids.add(pid);
}
}
}
return [...pids];
}
// Is `pid` currently a live process? POSIX: signal 0 probes without killing.
// Windows: a PowerShell CIM query that prints a deterministic ALIVE/DEAD token.
//
// Fails SAFE: if we cannot positively query the process (PowerShell errored /
// non-zero / unparseable, or signal probe inconclusive), assume the process is
// alive. The only caller uses this to decide whether a port holder is orphaned,
// and a query failure must never let us conclude "parent is dead" and kill a
// live backend.
function isProcessAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false;
if (process.platform === 'win32') {
// PowerShell CIM gives a deterministic ALIVE/DEAD answer — unlike
// `tasklist /FI`, whose exit status on a no-match (i.e. the process is
// genuinely gone) varies by Windows version and could otherwise be
// misread as "can't query".
const out = spawnSync('powershell', ['-NoProfile', '-Command',
`if (Get-CimInstance Win32_Process -Filter "ProcessId=${pid}") {'ALIVE'} else {'DEAD'}`],
PROBE_SPAWN);
if (out.error || out.status !== 0 || typeof out.stdout !== 'string') return true; // can't query → assume alive
return out.stdout.includes('ALIVE');
}
try {
process.kill(pid, 0);
return true;
} catch (e: unknown) {
// EPERM means it exists but we can't signal it (still "alive").
return (e as NodeJS.ErrnoException)?.code === 'EPERM';
}
}
// Process identity for the port holder: is it one of our Python/uvicorn
// backends, and what is its parent pid? Returns null when the query tool is
// unavailable, the process is gone, it isn't our uvicorn backend, or the
// parent pid doesn't parse — callers then leave it alone (never kill on a
// guess).
function backendProcInfo(pid: number): { ppid: number } | null {
if (process.platform === 'win32') {
// wmic is deprecated/removed on newer Windows; PowerShell CIM is stable.
// Verify the full command line, not just the image name, so we don't
// kill an unrelated orphaned python that happens to hold the port.
const out = spawnSync('powershell', ['-NoProfile', '-Command',
`$p=Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; `
+ `if($p){"$($p.Name)|$($p.ParentProcessId)|$($p.CommandLine)"}`], PROBE_SPAWN);
if (out.error || typeof out.stdout !== 'string') return null;
const [name, ppidStr, ...cmdParts] = out.stdout.trim().split('|');
const cmd = cmdParts.join('|');
if (!/^python(w)?\.exe$/i.test((name || '').trim())) return null;
if (!isOurBackendCmd(cmd)) return null;
const ppid = Number(ppidStr);
if (!Number.isInteger(ppid) || ppid <= 0) return null;
return { ppid };
}
// -ww disables column truncation so the full command line is visible
// (consistent with reapOrphanedPythonBackends).
const out = spawnSync('ps', ['-ww', '-p', String(pid), '-o', 'ppid=,command='], PROBE_SPAWN);
if (out.error || typeof out.stdout !== 'string') return null;
const m = out.stdout.trim().match(/^(\d+)\s+(.+)$/);
if (!m || !isOurBackendCmd(m[2])) return null;
const ppid = Number(m[1]);
if (!Number.isInteger(ppid) || ppid <= 0) return null;
return { ppid };
}
// Kill the backend holding `port` — but ONLY if it's orphaned (its parent is
// gone). This covers the real bug (a backend left over from the previous run
// still holding 18000) on every platform, including Windows where the
// command-line reaper is a no-op, while never touching a *live* sibling
// instance's managed backend. If we can't positively confirm both "our
// backend" and "orphaned", we leave it and let waitForPortFree / findPort
// degrade gracefully to the current drift behavior.
function reclaimPort(port: number): void {
for (const pid of findPidsOnPort(port)) {
const info = backendProcInfo(pid);
if (!info) {
console.warn(`[python] port ${port} held by pid ${pid} (not a recognizable backend); leaving it alone`);
continue;
}
// Orphan signal is platform-specific: POSIX reparents a dead parent's
// child to init (ppid===1); Windows keeps the original ppid, so we
// check whether that parent is still alive. A live non-init parent
// means a running sibling instance owns it — never kill those.
const orphaned = process.platform === 'win32'
? !isProcessAlive(info.ppid)
: info.ppid === 1;
if (!orphaned) {
console.warn(`[python] port ${port} held by pid ${pid} with live parent ${info.ppid} (another running instance?); leaving it alone`);
continue;
}
try {
if (process.platform === 'win32') {
const r = spawnSync('taskkill', ['/PID', String(pid), '/F', '/T'], PROBE_SPAWN);
if (r.error || r.status !== 0) {
console.warn(`[python] taskkill failed for port-${port} holder pid ${pid} `
+ `(status=${r.status}${r.error ? `, ${r.error.message}` : ''}): ${(r.stderr || '').trim()}`);
continue;
}
} else {
process.kill(pid, 'SIGKILL');
}
console.log(`[python] reclaimed port ${port} from orphaned backend pid ${pid}`);
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
if (code !== 'ESRCH') console.warn(`[python] failed to reclaim port ${port} from pid ${pid} (${code})`);
}
}
}
function findSlopsmithDir(): string {
// In packaged app, Slopsmith is bundled in resources
if (app.isPackaged) {
return path.join(process.resourcesPath, 'slopsmith');
}
// Development — same resolution order as scripts/setup-dev.sh:
// 1. $SLOPSMITH_DIR
// 2. ../slopsmith (sibling to slopsmith-desktop)
// 3. ~/Repositories/slopsmith (legacy)
// An explicit $SLOPSMITH_DIR is honoured verbatim — never fall through
// to a sibling/legacy checkout, so a typo or partial checkout surfaces
// as a clear "server.py not found" error in startPython instead of
// silently starting a different Slopsmith. Matches the build scripts
// (bundle-python.sh, build-macos.sh). For the unset case a fallback
// candidate only counts if it actually contains server.py, so a partial
// or unrelated ../slopsmith directory cannot mask a valid legacy
// checkout.
const isSlopsmithRepo = (dir: string): boolean =>
fs.existsSync(path.join(dir, 'server.py'));
// $SLOPSMITH_DIR must be a native path. On Windows, pass a native path
// (C:\\src\\slopsmith), not an MSYS/Git-Bash path (/c/src/slopsmith) —
// Node resolves the latter against the current drive root. See README.
const explicit = process.env.SLOPSMITH_DIR;
if (explicit) return path.resolve(explicit);
const siblingPath = path.join(__dirname, '..', '..', '..', 'slopsmith');
if (isSlopsmithRepo(siblingPath)) return siblingPath;
// app.getPath('home') is the platform-native home (USERPROFILE on
// Windows, HOME on POSIX). process.env.HOME is an MSYS-style path such
// as /c/Users/name when Electron is launched from Git Bash, which Node
// misresolves — see the cacheBase comment in startPython.
const legacyPath = path.join(app.getPath('home'), 'Repositories', 'slopsmith');
if (isSlopsmithRepo(legacyPath)) return legacyPath;
return siblingPath;
}
function getConfigDir(): string {
// Share config with the Docker Slopsmith instance so DLC dir, favorites etc. are shared
const sharedConfig = path.join(process.env.HOME || '', '.local', 'share', 'slopsmith');
if (fs.existsSync(sharedConfig)) return sharedConfig;
// Fallback to app-specific config
const configDir = path.join(app.getPath('userData'), 'slopsmith-config');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
return configDir;
}
function getPluginsDir(): string {
const pluginsDir = path.join(app.getPath('userData'), 'plugins');
if (!fs.existsSync(pluginsDir)) {
fs.mkdirSync(pluginsDir, { recursive: true });
}
return pluginsDir;
}
function getDLCDir(explicitDlcDir = normalizeExistingLibraryDirectory(process.env.DLC_DIR)): string {
if (explicitDlcDir) return explicitDlcDir;
// Read from shared config
const configFile = path.join(getConfigDir(), 'config.json');
if (fs.existsSync(configFile)) {
try {
const cfg = JSON.parse(fs.readFileSync(configFile, 'utf-8'));
if (cfg.dlc_dir && fs.existsSync(cfg.dlc_dir)) return cfg.dlc_dir;
} catch { /* ignore */ }
}
// Default library location. `feedback` is the current brand; the legacy
// `slopsmith` paths stay as fallbacks so existing installs that relied on
// the default keep pointing at their already-populated library.
// Resolve home via Electron (USERPROFILE on Windows, $HOME on Unix) rather
// than raw env — an empty/ MSYS-style env would otherwise make the mkdir
// below create the library relative to cwd or under the wrong home.
const home = app.getPath('home');
const preferred = path.join(home, '.local', 'share', 'feedback', 'library');
const candidates = [
preferred,
path.join(home, '.local', 'share', 'slopsmith', 'library'), // legacy
path.join(home, 'Music', 'Slopsmith'), // legacy
];
for (const dir of candidates) {
if (fs.existsSync(dir)) return dir;
}
return preferred; // fresh install — nothing exists yet
}
export async function startPython(): Promise<void> {
startupError = null;
startupComplete = false;
const slopsmithDir = findSlopsmithDir();
const serverScript = path.join(slopsmithDir, 'server.py');
if (!fs.existsSync(serverScript)) {
// findSlopsmithDir returns a bundled path when packaged and ignores
// SLOPSMITH_DIR there — so the remediation differs by mode.
startupError = `Slopsmith server.py not found at ${serverScript}. `
+ (app.isPackaged
? 'The application bundle is incomplete or corrupt — reinstall Slopsmith Desktop.'
: 'Set SLOPSMITH_DIR to your Slopsmith checkout, clone it to ../slopsmith, '
+ 'or use ~/Repositories/slopsmith/.');
console.error(`[python] ${startupError}`);
return;
}
// Reset the readiness flag — this matters on restarts (`restartPython`),
// otherwise waitForPython would short-circuit on a flag set by the
// previous spawn and probe a server that hasn't actually started yet.
serverReady = false;
const pythonPath = findPythonExecutable();
// Keep the renderer origin stable across launches (#491): the renderer
// loads from http://127.0.0.1:${serverPort}, so if a leftover backend
// still holds the preferred port, findPort drifts to 18001+ and wipes
// origin-keyed localStorage settings. Reap any orphaned POSIX backend
// (cheap, single `ps`), then — ONLY if the preferred port is actually
// busy — reclaim its (orphaned) holder and wait for the OS to release it.
// The common case (port already free) does no netstat/lsof/ps probing.
reapOrphanedPythonBackends(pythonPath);
if (!(await isPortFree(PREFERRED_PORT))) {
reclaimPort(PREFERRED_PORT);
if (!(await waitForPortFree(PREFERRED_PORT, 3000))) {
console.warn(`[python] preferred port ${PREFERRED_PORT} still busy after reclaim; `
+ 'renderer origin may drift and reset localStorage-backed settings');
}
}
serverPort = await findPort(PREFERRED_PORT);
const configDir = getConfigDir();
const explicitDlcDir = normalizeExistingLibraryDirectory(process.env.DLC_DIR);
const dlcDir = getDLCDir(explicitDlcDir);
// Ensure the resolved library folder exists before the server starts. The
// Python side only seeds starter content (and scans) when DLC_DIR.is_dir()
// is true, and it can't bootstrap the folder itself (the seed's mkdir runs
// only after the dir already resolves). Creating it here makes a fresh
// install seed the bundled starter songs on its first scan instead of
// reporting "DLC folder not configured".
try {
fs.mkdirSync(dlcDir, { recursive: true });
} catch (err) {
console.warn(`[python] could not create DLC dir ${dlcDir}:`, err);
}
// Preserve a caller-supplied DLC_DIR as an explicit administrator override.
// Normal desktop launches bootstrap the initial/default path into config.json
// instead. The backend re-reads that file for every scan, so a path saved in
// Settings takes effect immediately rather than being shadowed by the
// startup path until the Python process restarts.
const libraryPath = prepareLibraryPathForPython(configDir, dlcDir, explicitDlcDir);
if (libraryPath.error) {
console.warn(`[python] could not prepare dynamic library path (${libraryPath.status}): ${libraryPath.error}`);
}
const pluginsDir = getPluginsDir();
const slopsmithPlugins = path.join(slopsmithDir, 'plugins');
console.log(`[python] Starting ${pythonPath} ${serverScript} on port ${serverPort}`);
console.log(`[python] Config dir: ${configDir}`);
console.log(`[python] DLC dir: ${dlcDir}`);
console.log(`[python] Slopsmith plugins: ${slopsmithPlugins}`);
console.log(`[python] User plugins: ${pluginsDir}`);
// Build PYTHONPATH to include slopsmith's lib directory
const pythonPathParts = [
slopsmithDir,
path.join(slopsmithDir, 'lib'),
];
// On Windows, include embedded Python's Lib/site-packages
if (app.isPackaged && process.platform === 'win32') {
const pythonDir = path.join(process.resourcesPath, 'python');
pythonPathParts.push(path.join(pythonDir, 'Lib', 'site-packages'));
}
const pythonPathEnv = pythonPathParts.join(path.delimiter);
// Pin ML model caches to a persistent root (XDG_CACHE_HOME if set,
// otherwise the user's home/.cache) so demucs / torch / huggingface
// weights survive across launches and stay shareable with any other
// torch app on the machine. The libraries already pick those paths
// by default, but spelling them out here keeps the cache anchored
// even if a future Electron sandbox / AppImage relocates HOME.
// sloppak_convert.py uses env.setdefault on TORCH_HOME and
// XDG_CACHE_HOME, so values set here win over its CONFIG_DIR
// fallback — the right behaviour for Desktop; Docker still falls
// back to /config/torch_cache via the same setdefault.
//
// Home is resolved via app.getPath('home') rather than
// process.env.HOME: HOME is unset on Windows (and some sandboxed
// contexts), which would otherwise produce a relative `.cache` path
// and pass HOME='' into the subprocess. app.getPath consults the
// platform-correct source (HOME on POSIX, USERPROFILE on Windows).
//
// cacheBase derives from XDG_CACHE_HOME first so a user who pins
// their cache to a non-default disk (e.g. XDG_CACHE_HOME=/mnt/big)
// gets all three caches (XDG/TORCH/HF) under the same root rather
// than splitting torch/HF off to ~/.cache.
const homeDir = app.getPath('home');
const cacheBase = process.env.XDG_CACHE_HOME || path.join(homeDir, '.cache');
// Build environment for Python process
const pythonEnv: Record<string, string> = {
...process.env as Record<string, string>,
PYTHONPATH: pythonPathEnv,
CONFIG_DIR: configDir,
SLOPSMITH_PLUGINS_DIR: pluginsDir,
HOME: homeDir,
XDG_CACHE_HOME: cacheBase,
TORCH_HOME: process.env.TORCH_HOME || path.join(cacheBase, 'torch'),
HF_HOME: process.env.HF_HOME || path.join(cacheBase, 'huggingface'),
RESOURCESPATH: app.isPackaged
? process.resourcesPath
: path.join(__dirname, '..', '..', 'resources'),
PATH: (app.isPackaged
? path.join(process.resourcesPath, 'bin') + path.delimiter
: path.join(__dirname, '..', '..', 'resources', 'bin') + path.delimiter
) + (process.env.PATH || ''),
};
// `...process.env` may carry an empty/invalid value. Do not let it shadow
// config.json in the normal dynamic-settings path.
applyLibraryPathToPythonEnvironment(pythonEnv, libraryPath);
// Debug mode: raise the Slopsmith server's log level and tee its
// structured logs to a file. lib/logging_setup.py reads LOG_LEVEL and
// LOG_FILE natively, so no Slopsmith-side change is needed. The Python
// logs go to their own file; the subprocess's stdout/stderr are still
// forwarded as [python] lines into the main debug log.
if (isDebugEnabled()) {
pythonEnv.LOG_LEVEL = 'DEBUG';
pythonEnv.LOG_FILE = path.join(app.getPath('logs'), 'slopsmith-python.log');
}
// Cap the library-scan worker pool to prevent memory exhaustion on
// low-RAM machines. The scan ProcessPoolExecutor (server.py) defaults to
// one worker per CPU core for CPU-bound metadata parsing; each spawned
// worker independently loads the lib stack and
// can transiently hold ~2 GiB while parsing large song files. On a stock
// 8 GB M2 MacBook Air an uncapped 8-worker pool
// consumed ~7.7 GB, drove the macOS compressor to 5.7 GB, starved
// WindowServer, and triggered a userspace-watchdog kernel panic
// (WindowServer unresponsive for 208 s — panic-full-2026-06-08-134816).
//
// Read by server.py via SLOPSMITH_MAX_SCAN_WORKERS (slopsmith#761), which
// takes priority over the legacy SCAN_MAX_WORKERS Docker override.
// Formula: reserve 2 GiB for OS/kernel, allow ~2 GiB per worker, and cap
// at the physical CPU count and a hard ceiling of 4.
// 8 GB → max(1, min(4, floor((8-2)/2)=3, cpus)) = 3
// 16 GB → max(1, min(4, floor((16-2)/2)=7, cpus)) = 4
// 4 GB → max(1, min(4, floor((4-2)/2)=1, cpus)) = 1
const totalGiB = os.totalmem() / (1024 ** 3);
const workersByMemory = Math.floor((totalGiB - 2) / 2.0);
const maxScanWorkers = Math.max(1, Math.min(4, workersByMemory, os.cpus().length));
pythonEnv.SLOPSMITH_MAX_SCAN_WORKERS = String(maxScanWorkers);
// Honour the "Audio Quality" preference: if the user has opted into the
// high-quality FluidR3 soundfont and the file exists, point Python at it.
// Otherwise fall through to the bundled GeneralUser GS via RESOURCESPATH.
const activeSoundfont = getActiveSoundfontPath();
if (activeSoundfont) {
pythonEnv.SLOPSMITH_SOUNDFONT = activeSoundfont;
}
// Set PYTHONHOME for bundled Python on all platforms
if (app.isPackaged) {
if (process.platform === 'win32') {
pythonEnv.PYTHONHOME = path.join(process.resourcesPath, 'python');
} else {
const runtimeDir = path.join(process.resourcesPath, 'python', 'runtime');
pythonEnv.PYTHONHOME = runtimeDir;
if (process.platform === 'linux') {
const pythonLibDir = path.join(runtimeDir, 'lib');
pythonEnv.LD_LIBRARY_PATH = pythonLibDir + path.delimiter + (process.env.LD_LIBRARY_PATH || '');
}
}
// Point stdlib SSL at the bundled certifi CA bundle. `requests`
// uses certifi automatically, but Slopsmith's own update checker
// (and community plugins like slopsmith-update-manager) call
// `urllib.request.urlopen`, which falls back to the platform's
// system CA bundle and fails inside the AppImage with
// `[SSL: CERTIFICATE_VERIFY_FAILED] unable to get local issuer
// certificate` when /etc/ssl/certs isn't reachable from the
// mount. SSL_CERT_FILE makes stdlib SSL pick certifi too.
const certifiPath = process.platform === 'win32'
? path.join(process.resourcesPath, 'python', 'Lib', 'site-packages', 'certifi', 'cacert.pem')
: (() => {
const libDir = path.join(process.resourcesPath, 'python', 'runtime', 'lib');
if (fs.existsSync(libDir)) {
for (const entry of fs.readdirSync(libDir)) {
if (entry.startsWith('python')) {
const p = path.join(libDir, entry, 'site-packages', 'certifi', 'cacert.pem');
if (fs.existsSync(p)) return p;
}
}
}
return '';
})();
if (certifiPath && fs.existsSync(certifiPath)) {
pythonEnv.SSL_CERT_FILE = certifiPath;
pythonEnv.REQUESTS_CA_BUNDLE = certifiPath;
} else {
console.warn('[python] certifi cacert.pem not found in bundle — HTTPS verification may fail');
}
}
const bindHost = getBindHost();
if (bindHost !== '127.0.0.1') {
console.log(`[python] LAN access enabled — binding ${bindHost} (reachable from other devices on the network)`);
}
const child = spawn(pythonPath, [
'-m', 'uvicorn', 'server:app',
'--host', bindHost,
'--port', String(serverPort),
'--no-access-log',
], {
cwd: slopsmithDir,
env: pythonEnv,
stdio: ['pipe', 'pipe', 'pipe'],
// POSIX: give the child its own process group so stopPython() can kill
// the whole tree (uvicorn can spawn worker/reloader children). Without
// this, a plain kill leaves orphans holding the server port — the next
// launch then drifts to 18001+, changing the renderer origin and
// wiping origin-keyed localStorage (plugin settings reset every start).
// The handle is deliberately NOT unref()'d — the main process keeps
// tracking the child so shutdown() / stopPython() can terminate it
// (detached only controls the process group, not the child's lifetime).
detached: process.platform !== 'win32',
});
pythonProcess = child;
// Flip `serverReady` when uvicorn emits a startup signal on either
// stdout or stderr. structlog routes these messages to stdout in dev
// mode; plain uvicorn uses stderr — so we watch both.
function checkReadiness(msg: string): void {
if (!serverReady && (msg.includes('Uvicorn running on') || msg.includes('Application startup complete'))) {
serverReady = true;
}
}
child.stdout?.on('data', (data: Buffer) => {
try {
const msg = data.toString().trim();
if (msg) {
console.log(`[python:stdout] ${msg}`);
checkReadiness(msg);
}
} catch { /* EPIPE or similar when the process dies mid-write */ }
});
// Swallow stream errors — EPIPE fires when the Python child dies while
// we're still draining its pipes; that's expected, not a bug.
child.stdout?.on('error', () => { /* ignore */ });
child.stderr?.on('data', (data: Buffer) => {
try {
const msg = data.toString().trim();
if (msg) {
console.log(`[python] ${msg}`);
checkReadiness(msg);
}
} catch { /* EPIPE or similar when the process dies mid-write */ }
});
child.stderr?.on('error', () => { /* ignore */ });
child.on('close', (code: number | null, signal: NodeJS.Signals | null) => {
// Ignore events from a child that restartPython has already
// replaced — a stale 'close' must not null out the new process
// or fail its startup.
if (pythonProcess !== child) return;
// On a signal exit Node reports code === null; surface the signal
// so the cause (SIGKILL/OOM, SIGTERM, ...) is not lost.
const exitDesc = code !== null ? `code ${code}` : `signal ${signal ?? 'unknown'}`;
console.log(`[python] Process exited with ${exitDesc}`);
// If the child exits before waitForPython confirmed the server
// (HTTP probe), record a startupError so waitForPython fails fast
// instead of polling out the full timeout. serverReady alone is not
// enough — the child can log "startup complete" then exit before
// the probe succeeds. startupComplete === true means startup already
// succeeded (a later crash is not a startup failure).
if (!startupComplete && !startupError) {
startupError = `Python process exited before startup completed (${exitDesc}).`;
}
pythonProcess = null;
serverReady = false;
});
child.on('error', (err: Error) => {
// Ignore errors from a child restartPython has already replaced.
if (pythonProcess !== child) return;
console.error(`[python] Failed to start: ${err.message}`);
// spawn itself failed (e.g. interpreter not found) — surface it to
// waitForPython rather than waiting out the readiness timeout.
if (!startupError) {
startupError = `Failed to start Python process: ${err.message}`;
}
pythonProcess = null;
});
}
export async function waitForPython(): Promise<number> {
// Wait for the python child process we just spawned to be actually
// ready to serve. Two signals are required, in order:
//
// 1. The stdout-or-stderr handler (via checkReadiness) flips
// `serverReady` to true when it sees uvicorn print
// "Application startup complete" or "Uvicorn running on".
// structlog routes these to stdout in dev mode; plain uvicorn
// uses stderr — so we watch both. This is the authoritative
// readiness signal — it comes from the child process we just
// spawned, so it can't be fooled by a zombie python from a
// prior crashed launch still listening on a different port.
//
// 2. Once that flag flips, a single HTTP probe to /api/plugins
// confirms the listener is reachable on the port we picked.
// Belt-and-suspenders against any race between the stderr
// message and the socket actually accepting connections.
//
// Generous total budget — with ~36 bundled plugins (whisper / NAM /
// torch get imported), first-run lifespan startup easily exceeds
// one minute on a cold cache.
const maxAttempts = 600; // 5 minutes
const intervalMs = 500;
for (let i = 0; i < maxAttempts; i++) {
// Fail fast on a config error startPython already diagnosed (e.g.
// server.py missing) rather than waiting out the whole timeout.
if (startupError) throw new Error(startupError);
if (serverReady) {
const ok = await new Promise<boolean>((resolve) => {
const req = http.get(`http://127.0.0.1:${serverPort}/api/plugins`, (res) => {
resolve((res.statusCode || 0) >= 200 && (res.statusCode || 0) < 500);
});
req.on('error', () => resolve(false));
req.setTimeout(2000, () => { req.destroy(); resolve(false); });
});
if (ok) {
startupComplete = true;
return serverPort;
}
// serverReady was set but HTTP probe failed — fall through
// and retry (lifespan-complete may briefly precede socket
// accept on slow machines).
}
// Periodic progress so a long startup doesn't look like a freeze.
// Logged every 10 s so the dev console / launcher log shows life.
if (i > 0 && (i * intervalMs) % 10000 === 0) {
console.log(`[python] waiting for server on port ${serverPort} (${(i * intervalMs) / 1000}s elapsed)`);
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Python server failed to start on port ${serverPort} within ${(maxAttempts * intervalMs) / 1000}s`);
}
// Kill the Python child *and any subprocesses it spawned* (uvicorn worker /
// reloader children). The child is spawned `detached` on POSIX so it leads its
// own process group; signalling the negated PID reaps the whole group, so no
// orphan survives holding the server port. Windows has no POSIX process
// groups here — fall back to a plain kill of the child itself.
function killPythonTree(proc: ChildProcess, signal: NodeJS.Signals): void {
if (process.platform !== 'win32' && typeof proc.pid === 'number') {
try {
process.kill(-proc.pid, signal);
return;
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
// ESRCH — the process group has already exited. Nothing to do.
if (code === 'ESRCH') return;
// EPERM / EINVAL / anything else: the group kill did not land, so
// fall through to a plain single-process kill rather than leaving
// the server (and the port) alive silently.
console.warn(`[python] process-group ${signal} failed (${code}); `
+ 'falling back to single-process kill');
}
}
try {
proc.kill(signal);
} catch (e: unknown) {
const code = (e as NodeJS.ErrnoException)?.code;
// ESRCH — the child is already gone. Anything else (EPERM/…) means
// the kill did not land; log it so a failed shutdown is diagnosable.
if (code !== 'ESRCH') {
console.warn(`[python] ${signal} of child failed (${code})`);
}
}
}
// `immediate` (app quit): kill the backend group synchronously with SIGKILL
// instead of the graceful SIGTERM + async force-kill timer. On quit the timer
// can NEVER fire — the Electron main process exits the instant the synchronous
// before-quit → shutdown() chain returns, so any pending setTimeout is dropped.
// Worse, uvicorn's SIGTERM handler does a *graceful* shutdown that blocks on
// in-flight requests (a library scan / sloppak conversion / demucs job can run
// for minutes), so a plain SIGTERM would orphan the backend — and the RAM of
// its loaded ML models — until the next launch's reaper (or forever, if the
// user doesn't relaunch). SQLite's WAL makes an abrupt stop crash-safe, and the
// user is closing the app, so abandoning in-flight work is the intended result.
export function stopPython(immediate = false): void {
// Capture the child being stopped. During restartPython the global
// pythonProcess may already point at a new child by the time the
// force-kill timer fires — SIGTERM/SIGKILL must hit the one being
// stopped, never the replacement.
const proc = pythonProcess;
if (!proc) {
// No current backend — but a prior restart may have left one still
// gracefully shutting down. On quit, force-kill it so it can't orphan
// (its async SIGKILL timer won't fire once we exit).
if (immediate && stoppingPythonProcess) {
killPythonTree(stoppingPythonProcess, 'SIGKILL');
stoppingPythonProcess = null;
}
return;
}
console.log(`[python] Stopping server...${immediate ? ' (immediate / app quit)' : ''}`);
// The stop is intentional, so detach this child from module state now:
// - pythonProcess = null makes proc's own 'close'/'error' handlers
// no-op (their `pythonProcess !== child` guard), so the expected
// shutdown exit can't be recorded as a startup failure;
// - clearing the startup flags means a restart (and any later
// waitForPython) starts from a clean slate rather than throwing a
// stale startupError left by the stopped child.
pythonProcess = null;
startupError = null;
startupComplete = false;
serverReady = false;
if (immediate) {
// Quit: also force-kill any *other* backend still gracefully stopping
// from a prior restart — its async timer can't fire once we exit.
if (stoppingPythonProcess && stoppingPythonProcess !== proc) {
killPythonTree(stoppingPythonProcess, 'SIGKILL');
}
stoppingPythonProcess = null;
killPythonTree(proc, 'SIGKILL');
return;
}
// Graceful stop: this child is dying but not yet reaped — track it so an
// app quit during the wait can still force-kill it synchronously.
stoppingPythonProcess = proc;
let exited = false;
let killTimeout: ReturnType<typeof setTimeout> | undefined;
// Register the exit handler BEFORE signalling — a child that dies between
// SIGTERM and here must still flip `exited` and cancel the force-kill, so
// the timer can't fire a SIGKILL at a since-reused PID's process group.
proc.once('close', () => {
exited = true;
if (stoppingPythonProcess === proc) stoppingPythonProcess = null;
if (killTimeout) clearTimeout(killTimeout);
});
// Try graceful shutdown first — signal the whole process group so uvicorn's
// children exit too and release the port.
killPythonTree(proc, 'SIGTERM');
// Force kill after a timeout — but skip it if the child is already gone
// (exitCode/signalCode go non-null once it exits); signalling a stale,
// possibly PID-reused process group otherwise.
killTimeout = setTimeout(() => {
if (stoppingPythonProcess === proc) stoppingPythonProcess = null;
if (exited || proc.exitCode !== null || proc.signalCode !== null) return;
console.log('[python] Force killing...');
killPythonTree(proc, 'SIGKILL');
}, 5000);
}
export function restartPython(): void {
stopPython();
// Wait a bit for port to be released, then restart
setTimeout(() => startPython(), 1000);
}
export { getPluginsDir, getConfigDir, getDLCDir };