Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions dev/local/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,28 @@ async function findPortConflicts(
return { conflicts, reusedHostServices };
}

// Next.js rejects the X11 range. Keep automatic worktree offsets away from it
// for every resolved listener, including worker inspector ports.
const RESERVED_PORT_RANGES: readonly [number, number][] = [[6000, 6063]];

function reservedPort(port: number): boolean {
return RESERVED_PORT_RANGES.some(([start, end]) => port >= start && port <= end);
}

function findReservedPorts(serviceNames: string[]): string[] {
const reserved: string[] = [];
for (const name of serviceNames) {
const service = getService(name);
if (service.port > 0 && reservedPort(service.port)) {
reserved.push(`${name}:${service.port}`);
}
if (service.type === 'worker' && reservedPort(service.port + 10_000)) {
reserved.push(`${name}-inspector:${service.port + 10_000}`);
}
}
return reserved;
}

function processIdentity(pid: number): string | undefined {
try {
return (
Expand Down Expand Up @@ -135,6 +157,7 @@ async function acquirePortOffsetLease(

for (const candidate of candidates) {
applyPortOffset(candidate);
if (!explicit && findReservedPorts(serviceNames).length > 0) continue;
const claimPath = path.join(leasesRoot, `${candidate}.json`);
let release: () => Promise<void>;
try {
Expand Down
47 changes: 47 additions & 0 deletions dev/local/port-offset-lease.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,53 @@ test('automatic port selection skips conflicts and restores the offset on exhaus
}
});

test('automatic port selection skips the X11 range rejected by Next.js', async () => {
const leases = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-port-lease-'));
const initialOffset = portOffset;
let scans = 0;
try {
applyPortOffset(3000);
await acquirePortOffsetLease(
['nextjs'],
false,
'/worktree/one',
'missing-session-one',
leases,
async () => {
scans++;
return { conflicts: [], reusedHostServices: new Set<string>() };
}
);
assert.equal(portOffset, 3100);
assert.equal(scans, 1);
await releasePortOffsetClaims('/worktree/one', 'missing-session-one', leases);
} finally {
applyPortOffset(initialOffset);
fs.rmSync(leases, { recursive: true, force: true });
}
});

test('explicit port offsets remain unchanged even when a resolved port is reserved', async () => {
const leases = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-port-lease-'));
const initialOffset = portOffset;
try {
applyPortOffset(3000);
await acquirePortOffsetLease(
['nextjs'],
true,
'/worktree/one',
'missing-session-one',
leases,
async () => ({ conflicts: [], reusedHostServices: new Set<string>() })
);
assert.equal(portOffset, 3000);
await releasePortOffsetClaims('/worktree/one', 'missing-session-one', leases);
} finally {
applyPortOffset(initialOffset);
fs.rmSync(leases, { recursive: true, force: true });
}
});

test('keeps a port offset reserved until its stack stops', async () => {
const leases = fs.mkdtempSync(path.join(os.tmpdir(), 'kilo-port-lease-'));
try {
Expand Down
97 changes: 90 additions & 7 deletions dev/local/tmux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
buildInteractiveShellCommand,
captureServicePane,
listWindows,
createSession,
createWindow,
killSession,
pipeServicePane,
setPaneServiceIdentity,
} from './tmux';
Expand All @@ -20,6 +23,15 @@ import {
restartServiceInTmux,
} from './runner';

const hasTmux = (() => {
try {
execFileSync('tmux', ['-V'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();

test('buildInteractiveShellCommand wraps quoted startup commands in parseable shell syntax', () => {
const startupCommand =
"PATH='/tmp/with spaces:/bin' PNPM_HOME='/tmp/pnpm home' node '/tmp/runner with spaces.js' --flag";
Expand All @@ -30,16 +42,87 @@ test('buildInteractiveShellCommand wraps quoted startup commands in parseable sh
assert.match(wrapped, /exec/);
assert.match(wrapped, /PATH/);
execFileSync('/bin/sh', ['-n', '-c', wrapped]);

const rooted = buildInteractiveShellCommand(startupCommand, '/bin/sh', '/tmp/worktree root');
assert.match(rooted, /^'\/bin\/sh' -c /);
assert.match(rooted, /cd .*tmp\/worktree root/);
execFileSync('/bin/sh', ['-n', '-c', rooted]);
});

const hasTmux = (() => {
try {
execFileSync('tmux', ['-V'], { stdio: 'ignore' });
return true;
} catch {
return false;
test(
'new tmux sessions and service windows ignore stale global worktree environment',
{ skip: !hasTmux },
async () => {
const sessionName = `kilo-tmux-test-${process.pid}-${Date.now()}`;
const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
encoding: 'utf8',
}).trim();
const marker = path.join(os.tmpdir(), `${sessionName}-pwd`);
const tmux = (...args: string[]) => execFileSync('tmux', args, { stdio: 'ignore' });
const tmuxOutput = (...args: string[]) =>
execFileSync('tmux', args, { encoding: 'utf8' }).trim();
const savedGlobalEnvironment = new Map<string, string | undefined>();
for (const key of ['PWD', 'OLDPWD']) {
try {
savedGlobalEnvironment.set(
key,
tmuxOutput('show-environment', '-g', key).slice(key.length + 1)
);
} catch {
savedGlobalEnvironment.set(key, undefined);
}
}

try {
const staleWorktree = path.join(os.tmpdir(), 'deleted-sibling-worktree');
tmux('set-environment', '-g', 'PWD', staleWorktree);
tmux('set-environment', '-g', 'OLDPWD', staleWorktree);
createSession(sessionName);

assert.equal(
tmuxOutput('display-message', '-p', '-t', `${sessionName}:0.0`, '#{pane_current_path}'),
repoRoot
);
const windowIndex = createWindow(
sessionName,
'service',
undefined,
`pwd > ${JSON.stringify(marker)}`
);
for (let i = 0; i < 20 && !fs.existsSync(marker); i++) await sleep(50);
assert.equal(fs.readFileSync(marker, 'utf8').trim(), repoRoot);
assert.doesNotMatch(
tmuxOutput('capture-pane', '-p', '-J', '-t', `${sessionName}:${windowIndex}.0`),
/shell-init: error retrieving current directory/
);
assert.equal(
tmuxOutput(
'display-message',
'-p',
'-t',
`${sessionName}:${windowIndex}.0`,
'#{pane_current_path}'
),
repoRoot
);
} finally {
fs.rmSync(marker, { force: true });
try {
killSession(sessionName);
} catch {
// Session may already be gone if setup fails.
}
for (const [key, value] of savedGlobalEnvironment) {
try {
if (value === undefined) tmux('set-environment', '-gu', key);
else tmux('set-environment', '-g', key, value);
} catch {
// The variable may not exist in this tmux version's environment.
}
}
}
}
})();
);

function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
Expand Down
50 changes: 35 additions & 15 deletions dev/local/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ function getWorktreeRoot(): string {
return cachedWorktreeRoot;
}

function worktreeEnvironment(
repoRoot: string,
env?: Record<string, string>
): Record<string, string> {
return { ...env, PWD: repoRoot };
}

// ---------------------------------------------------------------------------
// Session management
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -65,16 +72,22 @@ function createSession(sessionName: string, env?: Record<string, string>): void
// environment — NOT our current process.env. Pass critical vars with -e and
// set them on the session so later windows see values like KILO_PORT_OFFSET
// and CI-provided PATH updates.
const envArgs = env
? Object.entries(env)
.map(([k, v]) => `-e ${escapeForShell(`${k}=${v}`)}`)
.join(' ')
: '';
const envPrefix = envArgs ? `${envArgs} ` : '';
execSync(`tmux new-session -d ${envPrefix}-s ${sessionName} -n dashboard -c ${repoRoot}`, {
stdio: 'ignore',
});
for (const [key, value] of Object.entries(env ?? {})) {
const sessionEnv = worktreeEnvironment(repoRoot, env);
const newSessionEnv = { ...sessionEnv };
delete newSessionEnv.OLDPWD;
const envArgs = Object.entries(newSessionEnv)
.map(([k, v]) => `-e ${escapeForShell(`${k}=${v}`)}`)
.join(' ');
const envPrefix = `${envArgs} `;
execSync(
`tmux new-session -d ${envPrefix}-s ${sessionName} -n dashboard -c ${repoRoot} ${buildInteractiveShellCommand(
`cd ${escapeForShell(repoRoot)}`,
undefined,
repoRoot
)}`,
{ stdio: 'ignore' }
);
for (const [key, value] of Object.entries({ ...sessionEnv, OLDPWD: repoRoot })) {
execSync(
`tmux set-environment -t ${sessionName} ${escapeForShell(key)} ${escapeForShell(value)}`,
{ stdio: 'ignore' }
Expand Down Expand Up @@ -140,7 +153,10 @@ function createWindow(
const args = [
'new-window',
'-d',
...Object.entries(env ?? {}).flatMap(([key, value]) => ['-e', `${key}=${value}`]),
...Object.entries(worktreeEnvironment(getWorktreeRoot(), env)).flatMap(([key, value]) => [
'-e',
`${key}=${value}`,
]),
'-t',
sessionName,
'-n',
Expand All @@ -152,7 +168,7 @@ function createWindow(
'#{window_index}',
];
if (startupCommand) {
args.push(buildInteractiveShellCommand(startupCommand));
args.push(buildInteractiveShellCommand(startupCommand, undefined, getWorktreeRoot()));
}

const output = execFileSync('tmux', args, { encoding: 'utf-8' }).trim();
Expand Down Expand Up @@ -181,10 +197,14 @@ function setPaneServiceIdentity(

function buildInteractiveShellCommand(
startupCommand: string,
shell = process.env.SHELL || '/bin/sh'
shell = process.env.SHELL || '/bin/sh',
cwd?: string
): string {
return `${escapeForShell(shell)} -lc ${escapeForShell(
`${startupCommand}; exec ${escapeForShell(shell)} -l`
const interactiveCommand = `${startupCommand}; exec ${escapeForShell(shell)} -l`;
const command = `${escapeForShell(shell)} -lc ${escapeForShell(interactiveCommand)}`;
if (cwd === undefined) return command;
return `${escapeForShell('/bin/sh')} -c ${escapeForShell(
`cd ${escapeForShell(cwd)} && exec ${command}`
)}`;
}

Expand Down