Files
Garry Tan 5319b8a13b feat: community PRs — faster install, skill namespacing, uninstall, Codex fallback, Windows fix, Python patterns (v0.12.9.0) (#561)
* 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>
2026-03-27 00:44:37 -06:00

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]);
});
});