docs(cli): 📝 Update stop command documentation to clarify port squatter handling relationship with start command

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-06-10 20:14:04 -07:00
parent 640f6719b9
commit e5868e08a9

View file

@ -2,7 +2,7 @@ import * as os from 'os';
import * as path from 'path';
import { Command } from 'commander';
import { discoverManifests } from '@shared/manifest';
import { resolveStopScript, runCommand, expandHome } from '@shared/hosts';
import { ensurePortReleased, resolveStopScript, runCommand, expandHome } from '@shared/hosts';
import type { AppManifest, PlatformConfig, ServiceConfig } from '@shared/types';
interface StopOptions {
@ -24,12 +24,35 @@ function resolveServiceStop(
return { cwd, script: service.stop.script };
}
/**
* Verify the service's port actually came free after a stop strategy ran,
* escalating TERM KILL on any holdout. A stop that leaves the port occupied
* is a FAILURE the previous behavior (report success, let the next start
* spawn a doomed second instance) left a zombie serving stale code for hours.
*/
async function verifyPortReleased(host: string, svcName: string, port: number | string): Promise<boolean> {
const release = await ensurePortReleased(host, port);
if (release.released) {
const how =
release.via === 'free' ? 'released' : release.via === 'term' ? 'released (TERM)' : 'released (SIGKILL)';
console.log(`${svcName.padEnd(32)} port :${port} ${how}`);
return true;
}
console.error(
`${svcName.padEnd(32)} port :${port} STILL OCCUPIED by pid(s) ${release.squatters.join(' ') || 'unknown'} after TERM+KILL — service is NOT stopped`,
);
return false;
}
/**
* Stop a single service. Resolution order:
* 1. Explicit `service.stop` script
* 2. systemd unit `systemctl stop`
* 3. docker-compose `docker compose -f <file> down`
* 4. Known port kill the process listening on it
* Strategies 1, 2, 4 verify port release (with TERMKILL escalation) when the
* service declares a port; docker-compose ports belong to docker-proxy, so
* compose teardown is only checked, never SIGKILLed.
*/
async function stopSingleService(
host: string,
@ -43,6 +66,7 @@ async function stopSingleService(
const ok = result.code === 0;
console.log(` ${ok ? '✓' : '✗'} ${svcName.padEnd(32)} stop script`);
if (!ok && result.output) console.log(` ${result.output.trim()}`);
if (svc.port) return (await verifyPortReleased(host, svcName, svc.port)) && ok;
return ok;
}
@ -51,6 +75,7 @@ async function stopSingleService(
const result = await runCommand(host, `systemctl ${flag}stop ${svc.systemdUnit}`, undefined, 15000);
const ok = result.code === 0;
console.log(` ${ok ? '✓' : '✗'} ${svcName.padEnd(32)} systemctl stop`);
if (svc.port) return (await verifyPortReleased(host, svcName, svc.port)) && ok;
return ok;
}
@ -65,15 +90,7 @@ async function stopSingleService(
}
if (svc.port) {
const result = await runCommand(
host,
`PID=$(lsof -ti :${svc.port} 2>/dev/null) && [ -n "$PID" ] && kill $PID && echo "killed $PID" || echo "no process on :${svc.port}"`,
undefined,
10000,
);
const killed = result.output.includes('killed');
console.log(` ${killed ? '✓' : '·'} ${svcName.padEnd(32)} port :${svc.port} ${result.output.trim()}`);
return true;
return verifyPortReleased(host, svcName, svc.port);
}
console.log(` · ${svcName.padEnd(32)} no stop strategy`);