Skip to content

Commit 6180d70

Browse files
chrisbbreuerclaude
andcommitted
feat: harden dev wrapper to also clear stale broadcast port
The wrapper now identifies stale `stx dev` runs by command line (not just by port 3456), so it also cleans up the broadcasting port 6001 that silently fails when held by an orphaned previous run. Falls back to a port sweep as a safety net. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7a8f271 commit 6180d70

1 file changed

Lines changed: 97 additions & 30 deletions

File tree

scripts/dev.ts

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,125 @@
11
#!/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.
33
//
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.
1010

11+
import * as net from 'node:net'
1112
import { spawn, spawnSync } from 'node:child_process'
1213
import * as fs from 'node:fs'
14+
import * as path from 'node:path'
1315

1416
const PORT = 3456
17+
const BROADCAST_PORT = 6001
18+
const CWD = process.cwd()
19+
const STX_MARKER = path.join(CWD, 'pantry/@stacksjs/stx')
1520

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' })
1843
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[] = []
2145
let pid = 0
22-
for (const line of lines) {
46+
for (const line of r.stdout.split('\n').filter(Boolean)) {
2347
if (line.startsWith('p')) pid = Number.parseInt(line.slice(1), 10) || 0
2448
else if (line.startsWith('c') && pid) out.push({ pid, command: line.slice(1) })
2549
}
2650
return out
2751
}
2852

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 {
3254
return /^(stx|bun|node)$/i.test(cmd)
3355
}
3456

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))
3984
if (theirs.length > 0) {
40-
console.error(`[dev] port ${PORT} is in use by:`)
85+
console.error(`[dev] port ${port} is in use by:`)
4186
for (const t of theirs) console.error(` pid=${t.pid} cmd=${t.command}`)
4287
console.error(`[dev] free it before running \`bun run dev\``)
4388
process.exit(1)
4489
}
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`)
5091
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)
56123
}
57124
}
58125

0 commit comments

Comments
 (0)