mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 11:17:50 +02:00
5319b8a13b
* fix: sync package.json version with VERSION file (0.12.7.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * perf: shallow clone for faster install (#484) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: Python/async/SSRF patterns in review checklist (#531) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: namespace skill symlinks with gstack- prefix (#503) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: add uninstall script (#323) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat: office-hours Claude subagent fallback when Codex unavailable (#464) Updates generateCodexSecondOpinion resolver to always offer second opinion and fall back to Claude subagent when Codex is unavailable or errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: findPort() race condition via net.createServer (#490) Replaces Bun.serve() port probing with net.createServer() for proper async bind/close semantics. Fixes Windows EADDRINUSE race condition. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add tests for uninstall, setup prefix, and resolver fallback - Uninstall integration tests: syntax, flags, mock install layout, upgrade path - Setup prefix tests: gstack-* prefixing, --no-prefix, cleanup migration - Resolver tests: Claude subagent fallback in generated SKILL.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.12.9.0) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
192 lines
6.2 KiB
TypeScript
192 lines
6.2 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import * as net from 'net';
|
|
import * as path from 'path';
|
|
|
|
const polyfillPath = path.resolve(import.meta.dir, '../src/bun-polyfill.cjs');
|
|
|
|
// Helper: bind a port and hold it open, returning a cleanup function
|
|
function occupyPort(port: number): Promise<() => Promise<void>> {
|
|
return new Promise((resolve, reject) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', reject);
|
|
srv.listen(port, '127.0.0.1', () => {
|
|
resolve(() => new Promise<void>((r) => srv.close(() => r())));
|
|
});
|
|
});
|
|
}
|
|
|
|
// Helper: find a known-free port by binding to 0
|
|
function getFreePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', reject);
|
|
srv.listen(0, '127.0.0.1', () => {
|
|
const port = (srv.address() as net.AddressInfo).port;
|
|
srv.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
describe('findPort / isPortAvailable', () => {
|
|
|
|
test('isPortAvailable returns true for a free port', async () => {
|
|
// Use the same isPortAvailable logic from server.ts
|
|
const port = await getFreePort();
|
|
|
|
const available = await new Promise<boolean>((resolve) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', () => resolve(false));
|
|
srv.listen(port, '127.0.0.1', () => {
|
|
srv.close(() => resolve(true));
|
|
});
|
|
});
|
|
|
|
expect(available).toBe(true);
|
|
});
|
|
|
|
test('isPortAvailable returns false for an occupied port', async () => {
|
|
const port = await getFreePort();
|
|
const release = await occupyPort(port);
|
|
|
|
try {
|
|
const available = await new Promise<boolean>((resolve) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', () => resolve(false));
|
|
srv.listen(port, '127.0.0.1', () => {
|
|
srv.close(() => resolve(true));
|
|
});
|
|
});
|
|
|
|
expect(available).toBe(false);
|
|
} finally {
|
|
await release();
|
|
}
|
|
});
|
|
|
|
test('port is actually free after isPortAvailable returns true', async () => {
|
|
// This is the core race condition test: after isPortAvailable says
|
|
// a port is free, can we IMMEDIATELY bind to it?
|
|
const port = await getFreePort();
|
|
|
|
// Simulate isPortAvailable
|
|
const isFree = await new Promise<boolean>((resolve) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', () => resolve(false));
|
|
srv.listen(port, '127.0.0.1', () => {
|
|
srv.close(() => resolve(true));
|
|
});
|
|
});
|
|
|
|
expect(isFree).toBe(true);
|
|
|
|
// Now immediately try to bind — this would fail with the old
|
|
// Bun.serve() polyfill approach because the test server's
|
|
// listen() would still be pending
|
|
const canBind = await new Promise<boolean>((resolve) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', () => resolve(false));
|
|
srv.listen(port, '127.0.0.1', () => {
|
|
srv.close(() => resolve(true));
|
|
});
|
|
});
|
|
|
|
expect(canBind).toBe(true);
|
|
});
|
|
|
|
test('polyfill Bun.serve stop() is fire-and-forget (async)', async () => {
|
|
// Verify that the polyfill's stop() does NOT wait for the socket
|
|
// to actually close — this is the root cause of the race condition.
|
|
// On macOS/Linux the OS reclaims the port fast enough that the race
|
|
// rarely manifests, but on Windows TIME_WAIT makes it 100% repro.
|
|
const result = Bun.spawnSync(['node', '-e', `
|
|
require('${polyfillPath}');
|
|
const net = require('net');
|
|
|
|
async function test() {
|
|
const port = 10000 + Math.floor(Math.random() * 50000);
|
|
|
|
const testServer = Bun.serve({
|
|
port,
|
|
hostname: '127.0.0.1',
|
|
fetch: () => new Response('ok'),
|
|
});
|
|
|
|
// stop() returns undefined — it does NOT return a Promise,
|
|
// so callers cannot await socket teardown
|
|
const retval = testServer.stop();
|
|
console.log(typeof retval === 'undefined' ? 'FIRE_AND_FORGET' : 'AWAITABLE');
|
|
}
|
|
|
|
test();
|
|
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
|
|
const output = result.stdout.toString().trim();
|
|
// Confirms the polyfill's stop() is fire-and-forget — callers
|
|
// cannot wait for the port to be released, hence the race
|
|
expect(output).toBe('FIRE_AND_FORGET');
|
|
});
|
|
|
|
test('net.createServer approach does not have the race condition', async () => {
|
|
// Prove the fix: net.createServer with proper async bind/close
|
|
// releases the port cleanly
|
|
const result = Bun.spawnSync(['node', '-e', `
|
|
const net = require('net');
|
|
|
|
async function testFix() {
|
|
const port = 10000 + Math.floor(Math.random() * 50000);
|
|
|
|
// Simulate the NEW isPortAvailable: proper async bind/close
|
|
const isFree = await new Promise((resolve) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', () => resolve(false));
|
|
srv.listen(port, '127.0.0.1', () => {
|
|
srv.close(() => resolve(true));
|
|
});
|
|
});
|
|
|
|
if (!isFree) {
|
|
console.log('PORT_BUSY');
|
|
return;
|
|
}
|
|
|
|
// Immediately try to bind — should succeed because close()
|
|
// completed before the Promise resolved
|
|
const canBind = await new Promise((resolve) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', () => resolve(false));
|
|
srv.listen(port, '127.0.0.1', () => {
|
|
srv.close(() => resolve(true));
|
|
});
|
|
});
|
|
|
|
console.log(canBind ? 'FIX_WORKS' : 'FIX_BROKEN');
|
|
}
|
|
|
|
testFix();
|
|
`], { stdout: 'pipe', stderr: 'pipe' });
|
|
|
|
const output = result.stdout.toString().trim();
|
|
expect(output).toBe('FIX_WORKS');
|
|
});
|
|
|
|
test('isPortAvailable handles rapid sequential checks', async () => {
|
|
// Stress test: check the same port multiple times in sequence
|
|
const port = await getFreePort();
|
|
const results: boolean[] = [];
|
|
|
|
for (let i = 0; i < 5; i++) {
|
|
const available = await new Promise<boolean>((resolve) => {
|
|
const srv = net.createServer();
|
|
srv.once('error', () => resolve(false));
|
|
srv.listen(port, '127.0.0.1', () => {
|
|
srv.close(() => resolve(true));
|
|
});
|
|
});
|
|
results.push(available);
|
|
}
|
|
|
|
// All 5 checks should succeed — no leaked sockets
|
|
expect(results).toEqual([true, true, true, true, true]);
|
|
});
|
|
});
|