shraga 0.1.62 → 0.1.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/assets/{index-QguraA7L.js → index-6SI-XJ2-.js} +62 -62
- package/dist/client/assets/{index-BeVHw9x2.css → index-uvYJNXVj.css} +1 -1
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/client/components/ConfigPanel.tsx +21 -9
- package/src/server/boot.ts +20 -3
- package/src/server/claude.ts +7 -8
- package/src/server/directives.ts +29 -8
- package/src/server/engine/index.ts +22 -0
- package/src/server/port-reclaim.ts +73 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** Reclaim the listen port from a STALE COPY OF OURSELVES.
|
|
4
|
+
*
|
|
5
|
+
* The EADDRINUSE handler's assumption — "the old instance is draining, it will free the port, the
|
|
6
|
+
* service manager restarts us" — holds only while something still owns that old process. It doesn't
|
|
7
|
+
* when the server's launchd/systemd parent dies first: the server reparents to pid 1, nothing will
|
|
8
|
+
* ever signal it, and it holds the port FOREVER. The replacement then dies on every respawn while
|
|
9
|
+
* the orphan keeps serving the OLD code, so `kickstart -k` looks successful and changes nothing
|
|
10
|
+
* (observed on feedox 2026-08-28: a deploy "restarted" the service four times, and the process
|
|
11
|
+
* answering :3032 was three hours and one version old).
|
|
12
|
+
*
|
|
13
|
+
* Reclaiming is deliberately narrow — an over-broad "kill whatever holds my port" is how a deploy
|
|
14
|
+
* takes down an unrelated service. Every condition must hold: the holder is orphaned (ppid 1, so no
|
|
15
|
+
* manager owns it), it runs THIS deployment's entrypoint from THIS working directory, and it isn't
|
|
16
|
+
* us. Anything else (a sibling deployment, a manager-owned process mid-drain, an unrelated server)
|
|
17
|
+
* is left alone and the caller keeps today's exit-and-let-the-manager-retry behavior. */
|
|
18
|
+
export function reclaimStalePort(port: number, opts: { entrypoint?: string; cwd?: string; termGraceMs?: number } = {}): boolean {
|
|
19
|
+
const entrypoint = opts.entrypoint ?? 'src/main.ts';
|
|
20
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
21
|
+
const grace = opts.termGraceMs ?? 5000;
|
|
22
|
+
|
|
23
|
+
const holders = listeners(port).filter((pid) => pid !== process.pid && isStaleSelf(pid, entrypoint, cwd));
|
|
24
|
+
if (!holders.length) return false;
|
|
25
|
+
|
|
26
|
+
for (const pid of holders) {
|
|
27
|
+
console.warn(`[server] port ${port} held by ORPHANED stale instance pid=${pid} (ppid 1, same cwd + entrypoint) — reclaiming`);
|
|
28
|
+
signal(pid, 'SIGTERM');
|
|
29
|
+
if (!waitGone(pid, grace)) {
|
|
30
|
+
console.warn(`[server] pid=${pid} ignored SIGTERM for ${grace}ms — SIGKILL`);
|
|
31
|
+
signal(pid, 'SIGKILL');
|
|
32
|
+
waitGone(pid, 2000);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const free = listeners(port).filter((pid) => pid !== process.pid).length === 0;
|
|
36
|
+
console.warn(`[server] port ${port} ${free ? 'reclaimed' : 'STILL held after reclaim'}`);
|
|
37
|
+
return free;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function listeners(port: number): number[] {
|
|
41
|
+
return sh('lsof', ['-ti', `tcp:${port}`, '-sTCP:LISTEN'])
|
|
42
|
+
.split('\n').map((l) => Number(l.trim())).filter((n) => Number.isInteger(n) && n > 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Orphaned (ppid 1) + our entrypoint + our cwd. All three, or it is not ours to kill. */
|
|
46
|
+
function isStaleSelf(pid: number, entrypoint: string, cwd: string): boolean {
|
|
47
|
+
if (sh('ps', ['-o', 'ppid=', '-p', String(pid)]).trim() !== '1') return false;
|
|
48
|
+
if (!sh('ps', ['-o', 'command=', '-p', String(pid)]).includes(entrypoint)) return false;
|
|
49
|
+
// `lsof -d cwd -Fn` prints the cwd on an `n`-prefixed line.
|
|
50
|
+
const holderCwd = sh('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'])
|
|
51
|
+
.split('\n').find((l) => l.startsWith('n'))?.slice(1).trim();
|
|
52
|
+
return !!holderCwd && holderCwd === cwd;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function signal(pid: number, sig: NodeJS.Signals): void {
|
|
56
|
+
try { process.kill(pid, sig); } catch (err: any) { console.warn(`[server] ${sig} pid=${pid} failed: ${err?.message ?? err}`); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function waitGone(pid: number, ms: number): boolean {
|
|
60
|
+
const deadline = Date.now() + ms;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
try { process.kill(pid, 0); } catch { return true; }
|
|
63
|
+
// Synchronous on purpose: this runs inside the listen-error path, before the event loop is
|
|
64
|
+
// serving anything, and the caller must decide bind-or-exit before returning.
|
|
65
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
66
|
+
}
|
|
67
|
+
try { process.kill(pid, 0); return false; } catch { return true; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sh(cmd: string, args: string[]): string {
|
|
71
|
+
try { return execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }); }
|
|
72
|
+
catch { return ''; } // non-zero exit = no match (lsof) or no such pid (ps) — both mean "nothing there"
|
|
73
|
+
}
|