fix: 修复 OpenCode 运行时启动与本地打包
This commit is contained in:
@@ -8,8 +8,6 @@ import { PORTS } from '../utils/config';
|
||||
const ALLOWED_ORIGINS = new Set([
|
||||
`http://127.0.0.1:${PORTS.NIANCODE_DEV}`,
|
||||
`http://localhost:${PORTS.NIANCODE_DEV}`,
|
||||
`http://127.0.0.1:${PORTS.OPENCODE_RUNTIME}`,
|
||||
`http://localhost:${PORTS.OPENCODE_RUNTIME}`,
|
||||
]);
|
||||
|
||||
export async function parseJsonBody<T>(req: IncomingMessage): Promise<T> {
|
||||
@@ -43,11 +41,23 @@ export function requireJsonContentType(req: IncomingMessage): boolean {
|
||||
return ct.includes('application/json');
|
||||
}
|
||||
|
||||
export function setCorsHeaders(res: ServerResponse, origin?: string): void {
|
||||
export function setCorsHeaders(
|
||||
res: ServerResponse,
|
||||
origin?: string,
|
||||
runtimeUrl?: string,
|
||||
): void {
|
||||
// Only reflect the Origin header back if it is in the allow-list.
|
||||
// Omitting the header for unknown origins causes the browser to block
|
||||
// the response — this is the intended behavior for untrusted callers.
|
||||
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
||||
let runtimeOrigin: string | null = null;
|
||||
if (runtimeUrl) {
|
||||
try {
|
||||
runtimeOrigin = new URL(runtimeUrl).origin;
|
||||
} catch {
|
||||
runtimeOrigin = null;
|
||||
}
|
||||
}
|
||||
if (origin && (ALLOWED_ORIGINS.has(origin) || origin === runtimeOrigin)) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
|
||||
@@ -1770,7 +1770,7 @@ export async function handleOpencodeRoutes(
|
||||
if (url.pathname === '/api/opencode/stop' && req.method === 'POST') {
|
||||
try {
|
||||
await ctx.opencodeManager.stop();
|
||||
sendJson(res, 200, { success: true });
|
||||
sendJson(res, 200, { success: true, status: ctx.opencodeManager.getStatus() });
|
||||
} catch (error) {
|
||||
sendJson(res, 500, { success: false, error: String(error) });
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE
|
||||
// Set origin-aware CORS headers early so every response
|
||||
// (including error responses) carries them consistently.
|
||||
const origin = req.headers.origin;
|
||||
setCorsHeaders(res, origin);
|
||||
setCorsHeaders(res, origin, ctx.opencodeManager.getStatus().url);
|
||||
|
||||
// CORS preflight — respond before auth so browsers can negotiate.
|
||||
if (req.method === 'OPTIONS') {
|
||||
|
||||
@@ -30,7 +30,7 @@ import { registerZoomShortcuts } from './zoom-shortcuts';
|
||||
import { appUpdater, registerUpdateHandlers } from './updater';
|
||||
import { logger } from '../utils/logger';
|
||||
import { warmupNetworkOptimization } from '../utils/uv-env';
|
||||
import { resolvePythonRuntime } from '../utils/python-runtime';
|
||||
import { resolvePythonRuntime, resolveUvRuntime } from '../utils/python-runtime';
|
||||
import { initTelemetry } from '../utils/telemetry';
|
||||
import {
|
||||
getOrCreateAgentGatewaySessionClientId,
|
||||
@@ -578,6 +578,13 @@ if (gotTheLock) {
|
||||
arch: process.arch,
|
||||
configuredPath: process.env.NIANCODE_PYTHON_PATH,
|
||||
});
|
||||
const uvRuntime = resolveUvRuntime({
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: process.resourcesPath,
|
||||
appPath: app.getAppPath(),
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
});
|
||||
const bundledSuperpowersDir = resolveBundledSuperpowersDir({
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: process.resourcesPath,
|
||||
@@ -594,13 +601,15 @@ if (gotTheLock) {
|
||||
appPath: app.getAppPath(),
|
||||
});
|
||||
opencodeManager = new OpencodeManager({
|
||||
port: 4096,
|
||||
port: getPort('OPENCODE_RUNTIME'),
|
||||
binPath: opencodePaths.binPath,
|
||||
preflightPreferredPort: true,
|
||||
userDataDir: app.getPath('userData'),
|
||||
bundledSuperpowersDir,
|
||||
bundledCourseSkillsDir,
|
||||
bundledAgentBrowserPluginPath,
|
||||
pythonRuntime,
|
||||
uvRuntime,
|
||||
runtimeConfigProvider: async () => {
|
||||
const runtime = await buildOpencodeRuntimeConfigFromNianCodeProviders({
|
||||
mcpServers: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { createServer } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
execFile,
|
||||
@@ -15,8 +16,9 @@ import {
|
||||
} from './superpowers';
|
||||
import { logger } from '../utils/logger';
|
||||
import {
|
||||
prependPythonToPath,
|
||||
prependManagedRuntimesToPath,
|
||||
type PythonRuntime,
|
||||
type UvRuntime,
|
||||
} from '../utils/python-runtime';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
@@ -166,10 +168,12 @@ export interface OpencodeManagerOptions {
|
||||
bundledCourseSkillsDir?: string;
|
||||
bundledAgentBrowserPluginPath?: string;
|
||||
pythonRuntime?: PythonRuntime;
|
||||
uvRuntime?: UvRuntime;
|
||||
spawn?: SpawnFn;
|
||||
configProvider?: ConfigProvider;
|
||||
runtimeConfigProvider?: RuntimeConfigProvider;
|
||||
startupTimeoutMs?: number;
|
||||
preflightPreferredPort?: boolean;
|
||||
findPortOwner?: FindPortOwner;
|
||||
killProcess?: KillProcess;
|
||||
}
|
||||
@@ -178,12 +182,108 @@ const execFileAsync = promisify(execFile);
|
||||
const ATTACHED_SERVER_STOP_TIMEOUT_MS = 2_000;
|
||||
const ATTACHED_SERVER_STOP_POLL_MS = 100;
|
||||
|
||||
class OpencodePortReleaseError extends Error {
|
||||
constructor(readonly port: number) {
|
||||
super(`Timed out waiting for opencode port ${port} to be released`);
|
||||
this.name = 'OpencodePortReleaseError';
|
||||
}
|
||||
}
|
||||
|
||||
class OpencodeStartCancelledError extends Error {
|
||||
constructor() {
|
||||
super('opencode startup was stopped');
|
||||
this.name = 'OpencodeStartCancelledError';
|
||||
}
|
||||
}
|
||||
|
||||
type RuntimeStartAttempt = {
|
||||
generation: number;
|
||||
requestSequence: number;
|
||||
controller: AbortController;
|
||||
};
|
||||
|
||||
function waitForAbortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
if (signal.aborted) return Promise.reject(new OpencodeStartCancelledError());
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const onAbort = () => reject(new OpencodeStartCancelledError());
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
void promise.then(
|
||||
(value) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(value);
|
||||
},
|
||||
(error) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function isLoopbackPortAvailable(port: number): Promise<boolean> {
|
||||
if (!Number.isInteger(port) || port <= 0) return Promise.resolve(true);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer();
|
||||
let settled = false;
|
||||
const finish = (available: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (server.listening) {
|
||||
server.close(() => resolve(available));
|
||||
} else {
|
||||
resolve(available);
|
||||
}
|
||||
};
|
||||
server.once('error', () => finish(false));
|
||||
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => finish(true));
|
||||
});
|
||||
}
|
||||
|
||||
function parseRuntimePort(url: string): number | null {
|
||||
try {
|
||||
const port = Number(new URL(url).port);
|
||||
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProcessEnvironment(
|
||||
overrides: Record<string, string>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Record<string, string> {
|
||||
const environment: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (typeof value === 'string') environment[key] = value;
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const inheritedPath = Object.entries(environment).find(([key]) => key.toLowerCase() === 'path');
|
||||
for (const key of Object.keys(environment)) {
|
||||
if (key.toLowerCase() === 'path') delete environment[key];
|
||||
}
|
||||
if (inheritedPath) environment[inheritedPath[0]] = inheritedPath[1];
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
if (platform === 'win32' && key.toLowerCase() === 'path') {
|
||||
for (const existingKey of Object.keys(environment)) {
|
||||
if (existingKey.toLowerCase() === 'path') delete environment[existingKey];
|
||||
}
|
||||
}
|
||||
environment[key] = value;
|
||||
}
|
||||
return environment;
|
||||
}
|
||||
|
||||
function normalizeComparablePath(value: string | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -297,7 +397,16 @@ export class OpencodeManager extends EventEmitter {
|
||||
private readonly killProcess: KillProcess;
|
||||
private readonly startupTimeoutMs: number;
|
||||
private proc: ChildProcess | null = null;
|
||||
private startPromise: Promise<OpencodeStatus> | null = null;
|
||||
private lifecycleSequence = 0;
|
||||
private cancelStartsBeforeSequence = 0;
|
||||
private runtimeGeneration = 0;
|
||||
private activeRuntimeGeneration: number | null = null;
|
||||
private activeStartAttempt: RuntimeStartAttempt | null = null;
|
||||
private readonly trackedUnreleasedPorts = new Set<number>();
|
||||
private lifecycleBusy = false;
|
||||
private readonly lifecycleQueue: Array<() => void> = [];
|
||||
private exitCleanupPromise: Promise<void> | null = null;
|
||||
private readonly ignoredExitProcesses = new WeakSet<ChildProcess>();
|
||||
private status: OpencodeStatus;
|
||||
|
||||
constructor(private readonly options: OpencodeManagerOptions) {
|
||||
@@ -320,40 +429,126 @@ export class OpencodeManager extends EventEmitter {
|
||||
return userDataDir ? getManagedOpencodeConfigDir(userDataDir) : null;
|
||||
}
|
||||
|
||||
async start(): Promise<OpencodeStatus> {
|
||||
if (this.status.state === 'running') return this.getStatus();
|
||||
if (this.startPromise) return await this.startPromise;
|
||||
private enqueueLifecycle<T>(operation: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const run = () => {
|
||||
this.lifecycleBusy = true;
|
||||
const finish = () => {
|
||||
this.lifecycleBusy = false;
|
||||
this.lifecycleQueue.shift()?.();
|
||||
};
|
||||
let result: Promise<T>;
|
||||
try {
|
||||
result = operation();
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
void result.then(
|
||||
(value) => {
|
||||
resolve(value);
|
||||
finish();
|
||||
},
|
||||
(error) => {
|
||||
reject(error);
|
||||
finish();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
this.startPromise = this.startRuntime();
|
||||
try {
|
||||
return await this.startPromise;
|
||||
} finally {
|
||||
this.startPromise = null;
|
||||
}
|
||||
if (this.lifecycleBusy) {
|
||||
this.lifecycleQueue.push(run);
|
||||
} else {
|
||||
run();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<OpencodeStatus> {
|
||||
const requestSequence = ++this.lifecycleSequence;
|
||||
return await this.enqueueLifecycle(async () => {
|
||||
this.assertStartAllowed(requestSequence);
|
||||
if (this.status.state === 'running') return this.getStatus();
|
||||
if (this.proc) await this.stopRuntime();
|
||||
return await this.startRuntime(requestSequence);
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const requestSequence = ++this.lifecycleSequence;
|
||||
this.cancelEarlierStarts(requestSequence);
|
||||
await this.enqueueLifecycle(async () => {
|
||||
await this.stopRuntime();
|
||||
});
|
||||
}
|
||||
|
||||
private async stopRuntime(): Promise<void> {
|
||||
const proc = this.proc;
|
||||
if (proc) {
|
||||
if (!proc.kill()) {
|
||||
throw new Error(`Failed to stop opencode process ${proc.pid ?? 'unknown'}`);
|
||||
}
|
||||
if (typeof proc.pid === 'number') {
|
||||
const released = await this.waitForPortOwnerToRelease(proc.pid);
|
||||
if (!released) {
|
||||
throw new Error(`Timed out waiting for opencode port ${this.options.port} to be released`);
|
||||
const generation = this.activeRuntimeGeneration;
|
||||
const port = this.status.port > 0 ? this.status.port : this.options.port;
|
||||
try {
|
||||
if (proc) {
|
||||
const exitPromise = this.waitForProcessExit(proc);
|
||||
this.ignoredExitProcesses.add(proc);
|
||||
if (!proc.kill()) {
|
||||
this.ignoredExitProcesses.delete(proc);
|
||||
throw new Error(`Failed to stop opencode process ${proc.pid ?? 'unknown'}`);
|
||||
}
|
||||
if (typeof proc.pid === 'number') {
|
||||
const [exited, released] = await Promise.all([
|
||||
exitPromise,
|
||||
this.waitForPortToBeReleased(port),
|
||||
]);
|
||||
this.recordPortReleaseOutcome(port, exited, released);
|
||||
if (!exited) {
|
||||
throw new Error(`Timed out waiting for opencode process ${proc.pid} to exit`);
|
||||
}
|
||||
if (!released) {
|
||||
throw new OpencodePortReleaseError(port);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this.stopAttachedServerIfManaged(port);
|
||||
}
|
||||
} else {
|
||||
await this.stopAttachedServerIfManaged();
|
||||
|
||||
await this.verifyTrackedPortsReleased();
|
||||
|
||||
if (this.proc === proc) {
|
||||
this.proc = null;
|
||||
}
|
||||
if (this.activeRuntimeGeneration === generation) {
|
||||
this.activeRuntimeGeneration = null;
|
||||
}
|
||||
this.setStatus({ state: 'stopped', port: this.options.port });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const errorPort = error instanceof OpencodePortReleaseError ? error.port : port;
|
||||
this.setStatus({
|
||||
state: 'error',
|
||||
port: errorPort,
|
||||
...(typeof proc?.pid === 'number' ? { pid: proc.pid } : {}),
|
||||
error: message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
this.proc = null;
|
||||
this.setStatus({ state: 'stopped', port: this.options.port });
|
||||
}
|
||||
|
||||
async restart(): Promise<OpencodeStatus> {
|
||||
await this.stop();
|
||||
return await this.start();
|
||||
const requestSequence = ++this.lifecycleSequence;
|
||||
this.cancelEarlierStarts(requestSequence);
|
||||
return await this.enqueueLifecycle(async () => {
|
||||
try {
|
||||
await this.stopRuntime();
|
||||
} catch (error) {
|
||||
if (!(error instanceof OpencodePortReleaseError)) throw error;
|
||||
logger.warn('[opencode-runtime] Restarting on another loopback port after release timeout', {
|
||||
port: error.port,
|
||||
});
|
||||
}
|
||||
this.assertStartAllowed(requestSequence);
|
||||
return await this.startRuntime(requestSequence);
|
||||
});
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<{ ok: boolean; status: OpencodeStatus }> {
|
||||
@@ -363,24 +558,45 @@ export class OpencodeManager extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
private async startRuntime(): Promise<OpencodeStatus> {
|
||||
this.setStatus({ state: 'starting', port: this.options.port });
|
||||
private async startRuntime(
|
||||
requestSequence: number,
|
||||
requestedPort?: number,
|
||||
allowPortFallback = true,
|
||||
): Promise<OpencodeStatus> {
|
||||
const attempt = this.beginStartAttempt(requestSequence);
|
||||
try {
|
||||
let runtimePort = requestedPort ?? this.options.port;
|
||||
if (
|
||||
requestedPort === undefined
|
||||
&& (this.options.preflightPreferredPort || this.options.findPortOwner)
|
||||
) {
|
||||
const preferredPort = await waitForAbortable(
|
||||
this.inspectPreferredPort(),
|
||||
attempt.controller.signal,
|
||||
);
|
||||
if (preferredPort.occupied && !preferredPort.existingServer) {
|
||||
runtimePort = 0;
|
||||
this.logPortFallback();
|
||||
}
|
||||
}
|
||||
this.setStatus({ state: 'starting', port: runtimePort });
|
||||
|
||||
const providedRuntimeConfig = this.resolveRuntimeConfig();
|
||||
const runtimeConfig = providedRuntimeConfig && typeof (providedRuntimeConfig as Promise<unknown>).then === 'function'
|
||||
? await providedRuntimeConfig
|
||||
? await waitForAbortable(providedRuntimeConfig, attempt.controller.signal)
|
||||
: providedRuntimeConfig;
|
||||
const runtimeArgs = ['serve', '--hostname=127.0.0.1', `--port=${this.options.port}`];
|
||||
this.assertStartAllowed(requestSequence);
|
||||
if (attempt.controller.signal.aborted) throw new OpencodeStartCancelledError();
|
||||
const runtimeArgs = ['serve', '--hostname=127.0.0.1', `--port=${runtimePort}`];
|
||||
const runtimeEnv = this.resolveRuntimeEnv(runtimeConfig.env);
|
||||
const spawnCommand = this.resolveSpawnCommand(runtimeArgs, runtimeEnv);
|
||||
logger.info('[opencode-runtime] Starting runtime with provider config', {
|
||||
port: this.options.port,
|
||||
port: runtimePort,
|
||||
command: spawnCommand.command,
|
||||
args: spawnCommand.args,
|
||||
...summarizeRuntimeConfigForDiagnostics(runtimeConfig.config, runtimeConfig.env),
|
||||
});
|
||||
const childEnv = {
|
||||
...process.env,
|
||||
...spawnCommand.env,
|
||||
OPENCODE_CONFIG_CONTENT: JSON.stringify(runtimeConfig.config),
|
||||
};
|
||||
@@ -399,6 +615,8 @@ export class OpencodeManager extends EventEmitter {
|
||||
return await new Promise<OpencodeStatus>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let stderr = '';
|
||||
let stdout = '';
|
||||
let abortStartup = () => undefined;
|
||||
const appendStderr = (message: string) => {
|
||||
stderr = `${stderr}${message}`;
|
||||
if (stderr.length > 8_000) {
|
||||
@@ -409,31 +627,68 @@ export class OpencodeManager extends EventEmitter {
|
||||
const detail = stderr.trim();
|
||||
return detail ? `${message}\n${detail}` : message;
|
||||
};
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
const claimSettlement = (): boolean => {
|
||||
if (settled) return false;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
attempt.controller.signal.removeEventListener('abort', abortStartup);
|
||||
return true;
|
||||
};
|
||||
const finish = (callback: () => void) => {
|
||||
if (!claimSettlement()) return;
|
||||
callback();
|
||||
};
|
||||
const terminateStartup = async () => {
|
||||
this.ignoredExitProcesses.add(proc);
|
||||
const exitPromise = this.waitForProcessExit(proc);
|
||||
proc.kill();
|
||||
const [exited, released] = await Promise.all([
|
||||
typeof proc.pid === 'number' ? exitPromise : Promise.resolve(true),
|
||||
this.waitForPortToBeReleased(runtimePort),
|
||||
]);
|
||||
if (exited && this.proc === proc) this.proc = null;
|
||||
if (this.isActiveGeneration(attempt.generation)) {
|
||||
this.recordPortReleaseOutcome(runtimePort, exited, released);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(() => {
|
||||
const error = formatStartupError(`Timed out waiting for opencode server after ${this.startupTimeoutMs}ms`);
|
||||
this.setStatus({ state: 'error', port: this.options.port, error });
|
||||
proc.kill();
|
||||
reject(new Error(error));
|
||||
});
|
||||
if (!claimSettlement()) return;
|
||||
const error = formatStartupError(`Timed out waiting for opencode server after ${this.startupTimeoutMs}ms`);
|
||||
if (this.isActiveGeneration(attempt.generation)) {
|
||||
this.setStatus({ state: 'error', port: runtimePort, error });
|
||||
}
|
||||
void terminateStartup().then(
|
||||
() => reject(new Error(error)),
|
||||
reject,
|
||||
);
|
||||
}, this.startupTimeoutMs);
|
||||
|
||||
abortStartup = () => {
|
||||
if (!claimSettlement()) return;
|
||||
void terminateStartup().then(
|
||||
() => reject(new OpencodeStartCancelledError()),
|
||||
reject,
|
||||
);
|
||||
};
|
||||
attempt.controller.signal.addEventListener('abort', abortStartup, { once: true });
|
||||
if (attempt.controller.signal.aborted) abortStartup();
|
||||
|
||||
proc.stdout?.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
const match = text.match(/opencode server listening\s+on\s+(https?:\/\/[^\s]+)/);
|
||||
if (!this.isActiveGeneration(attempt.generation) || this.proc !== proc) return;
|
||||
stdout = `${stdout}${chunk.toString()}`;
|
||||
if (stdout.length > 8_000) {
|
||||
stdout = stdout.slice(-8_000);
|
||||
}
|
||||
const match = stdout.match(/opencode server listening\s+on\s+(https?:\/\/[^\s]+)/);
|
||||
if (!match) return;
|
||||
const listeningPort = parseRuntimePort(match[1]);
|
||||
if (!listeningPort) return;
|
||||
|
||||
finish(() => {
|
||||
this.setStatus({
|
||||
state: 'running',
|
||||
port: this.options.port,
|
||||
port: listeningPort,
|
||||
url: match[1],
|
||||
pid: proc.pid,
|
||||
startedAt: Date.now(),
|
||||
@@ -443,6 +698,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
});
|
||||
|
||||
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||
if (!this.isActiveGeneration(attempt.generation)) return;
|
||||
const message = chunk.toString();
|
||||
appendStderr(message);
|
||||
this.emit('stderr', message);
|
||||
@@ -450,23 +706,36 @@ export class OpencodeManager extends EventEmitter {
|
||||
|
||||
proc.on('error', (error) => {
|
||||
finish(() => {
|
||||
this.proc = null;
|
||||
this.setStatus({ state: 'error', port: this.options.port, error: error.message });
|
||||
if (this.proc === proc) this.proc = null;
|
||||
this.ignoredExitProcesses.add(proc);
|
||||
if (this.isActiveGeneration(attempt.generation)) {
|
||||
this.setStatus({ state: 'error', port: runtimePort, error: error.message });
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
proc.on('exit', (code) => {
|
||||
this.proc = null;
|
||||
if (this.proc === proc) this.proc = null;
|
||||
if (!settled) {
|
||||
clearTimeout(timer);
|
||||
void this.findExistingServer().then((existingServer) => {
|
||||
if (this.ignoredExitProcesses.has(proc)) {
|
||||
finish(() => reject(new Error('opencode startup was stopped')));
|
||||
return;
|
||||
}
|
||||
|
||||
void this.inspectRuntimePort(runtimePort, true).then((portState) => {
|
||||
if (!this.isActiveGeneration(attempt.generation)) {
|
||||
finish(() => reject(new OpencodeStartCancelledError()));
|
||||
return;
|
||||
}
|
||||
const existingServer = portState.existingServer;
|
||||
if (existingServer) {
|
||||
finish(() => {
|
||||
logger.warn(
|
||||
'[opencode-runtime] Attached to an existing server; newly generated provider config may not be active',
|
||||
{
|
||||
port: this.options.port,
|
||||
port: runtimePort,
|
||||
url: existingServer.url,
|
||||
},
|
||||
);
|
||||
@@ -476,30 +745,135 @@ export class OpencodeManager extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
allowPortFallback
|
||||
&& runtimePort === this.options.port
|
||||
&& portState.occupied
|
||||
) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
this.logPortFallback();
|
||||
void this.startRuntime(requestSequence, 0, false).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
|
||||
finish(() => {
|
||||
const error = formatStartupError(code == null ? 'opencode process exited' : `Exited with code ${code}`);
|
||||
this.setStatus({ state: 'error', port: this.options.port, error });
|
||||
if (this.isActiveGeneration(attempt.generation)) {
|
||||
this.setStatus({ state: 'error', port: runtimePort, error });
|
||||
}
|
||||
reject(new Error(error));
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.status.state !== 'stopped') {
|
||||
this.setStatus({
|
||||
state: 'stopped',
|
||||
port: this.options.port,
|
||||
error: code == null ? undefined : `Exited with code ${code}`,
|
||||
});
|
||||
}
|
||||
if (this.ignoredExitProcesses.has(proc)) return;
|
||||
this.beginUnexpectedExitCleanup(
|
||||
proc,
|
||||
code,
|
||||
this.isActiveGeneration(attempt.generation) ? this.status.port : runtimePort,
|
||||
attempt.generation,
|
||||
);
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof OpencodeStartCancelledError)
|
||||
&& this.isActiveGeneration(attempt.generation)
|
||||
&& this.status.state === 'starting'
|
||||
) {
|
||||
this.setStatus({
|
||||
state: 'error',
|
||||
port: this.status.port,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (this.activeStartAttempt === attempt) {
|
||||
this.activeStartAttempt = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async findExistingServer(): Promise<OpencodeStatus | null> {
|
||||
private beginStartAttempt(requestSequence: number): RuntimeStartAttempt {
|
||||
this.assertStartAllowed(requestSequence);
|
||||
const attempt = {
|
||||
generation: ++this.runtimeGeneration,
|
||||
requestSequence,
|
||||
controller: new AbortController(),
|
||||
};
|
||||
this.activeRuntimeGeneration = attempt.generation;
|
||||
this.activeStartAttempt = attempt;
|
||||
return attempt;
|
||||
}
|
||||
|
||||
private cancelEarlierStarts(requestSequence: number): void {
|
||||
this.cancelStartsBeforeSequence = Math.max(
|
||||
this.cancelStartsBeforeSequence,
|
||||
requestSequence,
|
||||
);
|
||||
if (
|
||||
this.activeStartAttempt
|
||||
&& this.activeStartAttempt.requestSequence < requestSequence
|
||||
) {
|
||||
this.activeStartAttempt.controller.abort();
|
||||
}
|
||||
}
|
||||
|
||||
private assertStartAllowed(requestSequence: number): void {
|
||||
if (requestSequence < this.cancelStartsBeforeSequence) {
|
||||
throw new OpencodeStartCancelledError();
|
||||
}
|
||||
}
|
||||
|
||||
private isActiveGeneration(generation: number): boolean {
|
||||
return this.activeRuntimeGeneration === generation;
|
||||
}
|
||||
|
||||
private async inspectPreferredPort(): Promise<{
|
||||
occupied: boolean;
|
||||
existingServer: OpencodeStatus | null;
|
||||
}> {
|
||||
return await this.inspectRuntimePort(this.options.port);
|
||||
}
|
||||
|
||||
private async inspectRuntimePort(port: number, checkHealthFirst = false): Promise<{
|
||||
occupied: boolean;
|
||||
existingServer: OpencodeStatus | null;
|
||||
}> {
|
||||
if (port <= 0) return { occupied: false, existingServer: null };
|
||||
|
||||
if (checkHealthFirst) {
|
||||
const existingServer = await this.findExistingServer(port);
|
||||
if (existingServer) return { occupied: true, existingServer };
|
||||
}
|
||||
|
||||
if (!this.options.findPortOwner) {
|
||||
const available = await isLoopbackPortAvailable(port);
|
||||
if (available) return { occupied: false, existingServer: null };
|
||||
return {
|
||||
occupied: true,
|
||||
existingServer: await this.findExistingServer(port),
|
||||
};
|
||||
}
|
||||
|
||||
if (!checkHealthFirst) {
|
||||
const existingServer = await this.findExistingServer(port);
|
||||
if (existingServer) return { occupied: true, existingServer };
|
||||
}
|
||||
return {
|
||||
occupied: Boolean(await this.findPortOwner(port)),
|
||||
existingServer: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async findExistingServer(port: number): Promise<OpencodeStatus | null> {
|
||||
if (typeof globalThis.fetch !== 'function') return null;
|
||||
|
||||
const url = `http://127.0.0.1:${this.options.port}`;
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), Math.min(this.startupTimeoutMs, 1_000));
|
||||
|
||||
@@ -515,7 +889,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
|
||||
return {
|
||||
state: 'running',
|
||||
port: this.options.port,
|
||||
port,
|
||||
url,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
@@ -526,42 +900,143 @@ export class OpencodeManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private async stopAttachedServerIfManaged(): Promise<void> {
|
||||
const owner = await this.findPortOwner(this.options.port);
|
||||
private async stopAttachedServerIfManaged(port: number): Promise<void> {
|
||||
const owner = await this.findPortOwner(port);
|
||||
if (!owner || !isManagedOpencodePortOwner(owner, this.options.binPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.killProcess(owner.pid)) {
|
||||
this.trackedUnreleasedPorts.add(port);
|
||||
throw new Error(`Failed to stop attached opencode process ${owner.pid}`);
|
||||
}
|
||||
|
||||
logger.warn('[opencode-runtime] Stopped attached bundled opencode server on managed port', {
|
||||
port: this.options.port,
|
||||
port,
|
||||
pid: owner.pid,
|
||||
});
|
||||
const released = await this.waitForPortOwnerToRelease(owner.pid);
|
||||
const released = await this.waitForPortToBeReleased(port);
|
||||
if (!released) {
|
||||
throw new Error(`Timed out waiting for opencode port ${this.options.port} to be released`);
|
||||
this.trackedUnreleasedPorts.add(port);
|
||||
throw new OpencodePortReleaseError(port);
|
||||
}
|
||||
this.trackedUnreleasedPorts.delete(port);
|
||||
}
|
||||
|
||||
private async waitForPortOwnerToRelease(pid: number): Promise<boolean> {
|
||||
private async waitForPortToBeReleased(port: number): Promise<boolean> {
|
||||
if (port <= 0) return true;
|
||||
const deadline = Date.now() + ATTACHED_SERVER_STOP_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const owner = await this.findPortOwner(this.options.port);
|
||||
if (!owner || owner.pid !== pid) return true;
|
||||
const released = await this.isPortReleased(port);
|
||||
if (released) return true;
|
||||
await sleep(ATTACHED_SERVER_STOP_POLL_MS);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async isPortReleased(port: number): Promise<boolean> {
|
||||
if (port <= 0) return true;
|
||||
return this.options.findPortOwner
|
||||
? !await this.findPortOwner(port)
|
||||
: await isLoopbackPortAvailable(port);
|
||||
}
|
||||
|
||||
private async verifyTrackedPortsReleased(): Promise<void> {
|
||||
for (const port of this.trackedUnreleasedPorts) {
|
||||
if (await this.isPortReleased(port)) {
|
||||
this.trackedUnreleasedPorts.delete(port);
|
||||
continue;
|
||||
}
|
||||
throw new OpencodePortReleaseError(port);
|
||||
}
|
||||
}
|
||||
|
||||
private recordPortReleaseOutcome(
|
||||
port: number,
|
||||
processExited: boolean,
|
||||
portReleased: boolean,
|
||||
): void {
|
||||
if (port <= 0) return;
|
||||
if (processExited && portReleased) {
|
||||
this.trackedUnreleasedPorts.delete(port);
|
||||
return;
|
||||
}
|
||||
this.trackedUnreleasedPorts.add(port);
|
||||
}
|
||||
|
||||
private waitForProcessExit(proc: ChildProcess): Promise<boolean> {
|
||||
if (typeof proc.exitCode === 'number' || proc.signalCode != null) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const onExit = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
proc.off('exit', onExit);
|
||||
resolve(false);
|
||||
}, ATTACHED_SERVER_STOP_TIMEOUT_MS);
|
||||
proc.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
private beginUnexpectedExitCleanup(
|
||||
proc: ChildProcess,
|
||||
code: number | null,
|
||||
port: number,
|
||||
generation: number,
|
||||
): void {
|
||||
const cleanupPromise = this.enqueueLifecycle(async () => {
|
||||
const released = typeof proc.pid !== 'number'
|
||||
|| await this.waitForPortToBeReleased(port);
|
||||
if (!this.isActiveGeneration(generation)) {
|
||||
if (!released && port > 0) this.trackedUnreleasedPorts.add(port);
|
||||
return;
|
||||
}
|
||||
this.recordPortReleaseOutcome(port, true, released);
|
||||
if (released) {
|
||||
this.activeRuntimeGeneration = null;
|
||||
this.setStatus({
|
||||
state: 'stopped',
|
||||
port: this.options.port,
|
||||
error: code == null ? undefined : `Exited with code ${code}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const error = new OpencodePortReleaseError(port).message;
|
||||
this.setStatus({
|
||||
state: 'error',
|
||||
port,
|
||||
...(typeof proc.pid === 'number' ? { pid: proc.pid } : {}),
|
||||
error,
|
||||
});
|
||||
});
|
||||
this.exitCleanupPromise = cleanupPromise;
|
||||
void cleanupPromise.finally(() => {
|
||||
if (this.exitCleanupPromise === cleanupPromise) {
|
||||
this.exitCleanupPromise = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private logPortFallback(): void {
|
||||
logger.warn('[opencode-runtime] Preferred port is occupied by an unhealthy listener; using an ephemeral port', {
|
||||
preferredPort: this.options.port,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRuntimeEnv(runtimeEnv: Record<string, string>): Record<string, string> {
|
||||
const inheritedEnvironment = mergeProcessEnvironment(runtimeEnv);
|
||||
const userDataDir = this.options.userDataDir?.trim();
|
||||
if (!userDataDir) {
|
||||
return this.options.pythonRuntime
|
||||
? prependPythonToPath(runtimeEnv, this.options.pythonRuntime)
|
||||
: runtimeEnv;
|
||||
return prependManagedRuntimesToPath(
|
||||
inheritedEnvironment,
|
||||
this.options.pythonRuntime,
|
||||
this.options.uvRuntime,
|
||||
);
|
||||
}
|
||||
|
||||
const baseDir = join(userDataDir, 'opencode');
|
||||
@@ -587,7 +1062,7 @@ export class OpencodeManager extends EventEmitter {
|
||||
sourcePath: this.options.bundledAgentBrowserPluginPath,
|
||||
});
|
||||
const environment = {
|
||||
...runtimeEnv,
|
||||
...inheritedEnvironment,
|
||||
OPENCODE_CONFIG_DIR: managedConfigDir,
|
||||
// Makelore exposes its managed and project-owned Skills explicitly. Avoid
|
||||
// rescanning unrelated user-wide ~/.claude and ~/.agents Skill libraries
|
||||
@@ -598,9 +1073,11 @@ export class OpencodeManager extends EventEmitter {
|
||||
XDG_DATA_HOME: dataHome,
|
||||
XDG_CACHE_HOME: cacheHome,
|
||||
};
|
||||
return this.options.pythonRuntime
|
||||
? prependPythonToPath(environment, this.options.pythonRuntime)
|
||||
: environment;
|
||||
return prependManagedRuntimesToPath(
|
||||
environment,
|
||||
this.options.pythonRuntime,
|
||||
this.options.uvRuntime,
|
||||
);
|
||||
}
|
||||
|
||||
private setStatus(next: OpencodeStatus): void {
|
||||
|
||||
@@ -6,19 +6,14 @@ const moduleRequire = createRequire(import.meta.url);
|
||||
|
||||
export const PLAYWRIGHT_MCP_SERVER_ID = 'playwright';
|
||||
export const PLAYWRIGHT_MCP_PACKAGE = '@playwright/mcp';
|
||||
export const PLAYWRIGHT_MCP_VERSION = '0.0.78';
|
||||
|
||||
function fallbackPlaywrightMcpServer(): OpencodeMcpServerEntry {
|
||||
return {
|
||||
type: 'local',
|
||||
command: ['npx', '-y', `${PLAYWRIGHT_MCP_PACKAGE}@${PLAYWRIGHT_MCP_VERSION}`],
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
type ResolvePackageJson = () => string;
|
||||
|
||||
export function resolvePlaywrightMcpServer(): OpencodeMcpServerEntry {
|
||||
export function resolvePlaywrightMcpServer(
|
||||
resolvePackageJson: ResolvePackageJson = () => moduleRequire.resolve(`${PLAYWRIGHT_MCP_PACKAGE}/package.json`),
|
||||
): OpencodeMcpServerEntry {
|
||||
try {
|
||||
const packageJsonPath = moduleRequire.resolve(`${PLAYWRIGHT_MCP_PACKAGE}/package.json`);
|
||||
const packageJsonPath = resolvePackageJson();
|
||||
return {
|
||||
type: 'local',
|
||||
command: [process.execPath, join(dirname(packageJsonPath), 'cli.js')],
|
||||
@@ -27,7 +22,10 @@ export function resolvePlaywrightMcpServer(): OpencodeMcpServerEntry {
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return fallbackPlaywrightMcpServer();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Bundled ${PLAYWRIGHT_MCP_PACKAGE} is missing; Makelore does not download runtime dependencies with npx`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
|
||||
export type PythonRuntimeSource = 'configured' | 'bundled' | 'system';
|
||||
|
||||
@@ -9,6 +9,12 @@ export type PythonRuntime = {
|
||||
source: PythonRuntimeSource;
|
||||
};
|
||||
|
||||
export type UvRuntime = {
|
||||
executable: string;
|
||||
binDir: string;
|
||||
source: 'bundled' | 'system';
|
||||
};
|
||||
|
||||
export type PythonRuntimeOptions = {
|
||||
isPackaged: boolean;
|
||||
resourcesPath: string;
|
||||
@@ -18,6 +24,8 @@ export type PythonRuntimeOptions = {
|
||||
configuredPath?: string;
|
||||
};
|
||||
|
||||
export type UvRuntimeOptions = Omit<PythonRuntimeOptions, 'configuredPath'>;
|
||||
|
||||
function configuredRuntime(configuredPath: string | undefined, appPath: string): PythonRuntime | null {
|
||||
const trimmed = configuredPath?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -33,7 +41,9 @@ function bundledExecutable(resourcesPath: string, platform: NodeJS.Platform): st
|
||||
}
|
||||
|
||||
export function resolvePythonRuntime(options: PythonRuntimeOptions): PythonRuntime {
|
||||
const configured = configuredRuntime(options.configuredPath, options.appPath);
|
||||
const configured = options.isPackaged
|
||||
? null
|
||||
: configuredRuntime(options.configuredPath, options.appPath);
|
||||
if (configured) return configured;
|
||||
|
||||
const executable = bundledExecutable(options.resourcesPath, options.platform);
|
||||
@@ -52,17 +62,71 @@ export function resolvePythonRuntime(options: PythonRuntimeOptions): PythonRunti
|
||||
};
|
||||
}
|
||||
|
||||
function uvExecutableName(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? 'uv.exe' : 'uv';
|
||||
}
|
||||
|
||||
export function resolveUvRuntime(options: UvRuntimeOptions): UvRuntime {
|
||||
const executable = options.isPackaged
|
||||
? join(options.resourcesPath, 'bin', uvExecutableName(options.platform))
|
||||
: join(
|
||||
options.appPath,
|
||||
'resources',
|
||||
'bin',
|
||||
`${options.platform}-${options.arch}`,
|
||||
uvExecutableName(options.platform),
|
||||
);
|
||||
if (existsSync(executable)) {
|
||||
return { executable, binDir: dirname(executable), source: 'bundled' };
|
||||
}
|
||||
|
||||
if (options.isPackaged) {
|
||||
throw new Error(`Bundled uv runtime is missing: ${executable}`);
|
||||
}
|
||||
|
||||
return { executable: 'uv', binDir: '', source: 'system' };
|
||||
}
|
||||
|
||||
function prependRuntimeBin(
|
||||
environment: Record<string, string>,
|
||||
binDir: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Record<string, string> {
|
||||
if (!binDir) return environment;
|
||||
const result = { ...environment };
|
||||
const existingKey = Object.keys(result).find((key) => key.toLowerCase() === 'path');
|
||||
const pathKey = platform === 'win32' && existingKey ? existingKey : 'PATH';
|
||||
const existingPath = result[pathKey];
|
||||
const separator = platform === 'win32' ? ';' : ':';
|
||||
result[pathKey] = existingPath ? `${binDir}${separator}${existingPath}` : binDir;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function prependPythonToPath(
|
||||
environment: Record<string, string>,
|
||||
runtime: PythonRuntime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Record<string, string> {
|
||||
const result = { ...environment, NIANCODE_PYTHON_PATH: runtime.executable };
|
||||
if (!runtime.binDir) return result;
|
||||
|
||||
const existingKey = Object.keys(result).find((key) => key.toLowerCase() === 'path');
|
||||
const pathKey = platform === 'win32' && existingKey ? existingKey : 'PATH';
|
||||
const existingPath = result[pathKey];
|
||||
result[pathKey] = existingPath ? `${runtime.binDir}${delimiter}${existingPath}` : runtime.binDir;
|
||||
return result;
|
||||
return prependRuntimeBin(
|
||||
{ ...environment, NIANCODE_PYTHON_PATH: runtime.executable },
|
||||
runtime.binDir,
|
||||
platform,
|
||||
);
|
||||
}
|
||||
|
||||
export function prependManagedRuntimesToPath(
|
||||
environment: Record<string, string>,
|
||||
pythonRuntime?: PythonRuntime,
|
||||
uvRuntime?: UvRuntime,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): Record<string, string> {
|
||||
const withPython = pythonRuntime
|
||||
? prependPythonToPath(environment, pythonRuntime, platform)
|
||||
: { ...environment };
|
||||
if (!uvRuntime) return withPython;
|
||||
return prependRuntimeBin(
|
||||
{ ...withPython, NIANCODE_UV_PATH: uvRuntime.executable },
|
||||
uvRuntime.binDir,
|
||||
platform,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user