|
1 | 1 | #!/usr/bin/env bun |
2 | | -// Dev wrapper that frees port 3456 before handing off to `stx dev`. |
| 2 | +// Dev wrapper that frees ports + kills stale `stx dev` runs before handing off. |
3 | 3 | // |
4 | | -// Why: `stx dev` silently rolls to a different port (3456 → 34560) when 3456 |
5 | | -// is busy. That breaks anything pointed at the documented URL — including the |
6 | | -// /api/* routes the broadcasting/WebSocket clients hard-code in `channels.ts`. |
7 | | -// This wrapper detects the conflict, identifies the holder, and either kills a |
8 | | -// stale `stx` process on the same port or refuses to start so the user can |
9 | | -// take action. |
| 4 | +// Why: `stx dev` silently rolls to a different port (3456 → 34560 → ...) when |
| 5 | +// 3456 is busy, and broadcasting on 6001 silently fails when its port is busy. |
| 6 | +// A previous run that didn't shut down cleanly will keep holding 6001 plus a |
| 7 | +// rolled-over port — invisible to a wrapper that only checks port 3456. So |
| 8 | +// we identify stale STX runs by *command line* (this workspace's pantry path) |
| 9 | +// and kill them first, then sweep the named ports as a final safety net. |
10 | 10 |
|
| 11 | +import * as net from 'node:net' |
11 | 12 | import { spawn, spawnSync } from 'node:child_process' |
12 | 13 | import * as fs from 'node:fs' |
| 14 | +import * as path from 'node:path' |
13 | 15 |
|
14 | 16 | const PORT = 3456 |
| 17 | +const BROADCAST_PORT = 6001 |
| 18 | +const CWD = process.cwd() |
| 19 | +const STX_MARKER = path.join(CWD, 'pantry/@stacksjs/stx') |
15 | 20 |
|
16 | | -function findHolders(): { pid: number, command: string }[] { |
17 | | - const r = spawnSync('lsof', ['-nP', `-iTCP:${PORT}`, '-sTCP:LISTEN', '-Fpc'], { encoding: 'utf8' }) |
| 21 | +interface Holder { pid: number, command: string } |
| 22 | + |
| 23 | +function findStaleDevProcesses(): Holder[] { |
| 24 | + const r = spawnSync('ps', ['-ax', '-o', 'pid=,command='], { encoding: 'utf8' }) |
| 25 | + if (r.status !== 0 || !r.stdout) return [] |
| 26 | + const out: Holder[] = [] |
| 27 | + const self = process.pid |
| 28 | + for (const line of r.stdout.split('\n')) { |
| 29 | + const m = line.match(/^\s*(\d+)\s+(.+)$/) |
| 30 | + if (!m) continue |
| 31 | + const pid = Number.parseInt(m[1], 10) |
| 32 | + const command = m[2] |
| 33 | + if (pid === self) continue |
| 34 | + if (command.includes(STX_MARKER) && /\bdev\b/.test(command)) { |
| 35 | + out.push({ pid, command }) |
| 36 | + } |
| 37 | + } |
| 38 | + return out |
| 39 | +} |
| 40 | + |
| 41 | +function findPortHolders(port: number): Holder[] { |
| 42 | + const r = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'], { encoding: 'utf8' }) |
18 | 43 | if (r.status !== 0 || !r.stdout) return [] |
19 | | - const lines = r.stdout.split('\n').filter(Boolean) |
20 | | - const out: { pid: number, command: string }[] = [] |
| 44 | + const out: Holder[] = [] |
21 | 45 | let pid = 0 |
22 | | - for (const line of lines) { |
| 46 | + for (const line of r.stdout.split('\n').filter(Boolean)) { |
23 | 47 | if (line.startsWith('p')) pid = Number.parseInt(line.slice(1), 10) || 0 |
24 | 48 | else if (line.startsWith('c') && pid) out.push({ pid, command: line.slice(1) }) |
25 | 49 | } |
26 | 50 | return out |
27 | 51 | } |
28 | 52 |
|
29 | | -function isOurStaleStx(cmd: string): boolean { |
30 | | - // Treat any prior `stx`/`bun`/`node` running our dev server as stale. Refuse |
31 | | - // to kill anything else so the user notices another tool is using the port. |
| 53 | +function isOursByCmd(cmd: string): boolean { |
32 | 54 | return /^(stx|bun|node)$/i.test(cmd) |
33 | 55 | } |
34 | 56 |
|
35 | | -const holders = findHolders() |
36 | | -if (holders.length > 0) { |
37 | | - const ours = holders.filter(h => isOurStaleStx(h.command)) |
38 | | - const theirs = holders.filter(h => !isOurStaleStx(h.command)) |
| 57 | +function killAll(holders: Holder[], label: string): void { |
| 58 | + for (const h of holders) { |
| 59 | + console.warn(`[dev] killing ${label} ${h.command} (pid ${h.pid})`) |
| 60 | + try { process.kill(h.pid, 'SIGTERM') } catch {} |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +// 1. Kill stale STX dev runs from this workspace, regardless of which port |
| 65 | +// they ended up listening on. |
| 66 | +const stale = findStaleDevProcesses() |
| 67 | +if (stale.length > 0) { |
| 68 | + killAll(stale, 'stale stx dev') |
| 69 | + await Bun.sleep(300) |
| 70 | + const survivors = findStaleDevProcesses() |
| 71 | + for (const h of survivors) { |
| 72 | + try { process.kill(h.pid, 'SIGKILL') } catch {} |
| 73 | + } |
| 74 | + if (survivors.length > 0) await Bun.sleep(200) |
| 75 | +} |
| 76 | + |
| 77 | +// 2. Sweep the named ports as a safety net (covers non-STX-pantry STX or |
| 78 | +// other tools holding our ports). |
| 79 | +for (const port of [PORT, BROADCAST_PORT]) { |
| 80 | + const holders = findPortHolders(port) |
| 81 | + if (holders.length === 0) continue |
| 82 | + const ours = holders.filter(h => isOursByCmd(h.command)) |
| 83 | + const theirs = holders.filter(h => !isOursByCmd(h.command)) |
39 | 84 | if (theirs.length > 0) { |
40 | | - console.error(`[dev] port ${PORT} is in use by:`) |
| 85 | + console.error(`[dev] port ${port} is in use by:`) |
41 | 86 | for (const t of theirs) console.error(` pid=${t.pid} cmd=${t.command}`) |
42 | 87 | console.error(`[dev] free it before running \`bun run dev\``) |
43 | 88 | process.exit(1) |
44 | 89 | } |
45 | | - for (const h of ours) { |
46 | | - console.warn(`[dev] killing stale ${h.command} (pid ${h.pid}) on port ${PORT}`) |
47 | | - try { process.kill(h.pid, 'SIGTERM') } catch {} |
48 | | - } |
49 | | - // Give the OS a beat to release the socket |
| 90 | + killAll(ours, `port-${port} holder`) |
50 | 91 | await Bun.sleep(300) |
51 | | - if (findHolders().length > 0) { |
52 | | - for (const h of findHolders()) { |
53 | | - try { process.kill(h.pid, 'SIGKILL') } catch {} |
54 | | - } |
55 | | - await Bun.sleep(200) |
| 92 | + for (const h of findPortHolders(port)) { |
| 93 | + try { process.kill(h.pid, 'SIGKILL') } catch {} |
| 94 | + } |
| 95 | +} |
| 96 | + |
| 97 | +// 3. Wait until each named port is bindable on both IPv4 and IPv6 — STX's |
| 98 | +// own check fails if either family is busy, and kernel socket release can |
| 99 | +// lag behind process exit. Fixed sleeps were flaky; poll instead. |
| 100 | +async function isBindable(port: number, host: string): Promise<boolean> { |
| 101 | + return await new Promise<boolean>(resolve => { |
| 102 | + const srv = net.createServer() |
| 103 | + srv.once('error', () => resolve(false)) |
| 104 | + srv.once('listening', () => srv.close(() => resolve(true))) |
| 105 | + srv.listen(port, host) |
| 106 | + }) |
| 107 | +} |
| 108 | + |
| 109 | +async function waitForFree(port: number, deadlineMs: number): Promise<boolean> { |
| 110 | + const end = Date.now() + deadlineMs |
| 111 | + while (Date.now() < end) { |
| 112 | + if (await isBindable(port, '0.0.0.0') && await isBindable(port, '::')) |
| 113 | + return true |
| 114 | + await Bun.sleep(100) |
| 115 | + } |
| 116 | + return false |
| 117 | +} |
| 118 | + |
| 119 | +for (const port of [PORT, BROADCAST_PORT]) { |
| 120 | + if (!await waitForFree(port, 5000)) { |
| 121 | + console.error(`[dev] port ${port} did not free up after kill — aborting`) |
| 122 | + process.exit(1) |
56 | 123 | } |
57 | 124 | } |
58 | 125 |
|
|
0 commit comments