feat(coding-agent/eval): added local python-runner subprocess execution
- Replaced Python execution with a local `python -u runner.py` subprocess and NDJSON stdin/stdout framing. - Removed shared-gateway architecture, including coordinator lifecycle APIs, `useSharedGateway` wiring, and `jupyter` CLI/actions. - Simplified setup checks to a plain Python 3 availability probe and removed automatic dependency-install fallbacks. - Updated kernel cancellation and display processing to use status frames, SIGINT/SIGTERM escalation, and normalized output coercion. - Added `python-runner` integration and display tests while deleting legacy websocket and kernel lifecycle test suites.
This commit is contained in:
@@ -1,28 +0,0 @@
|
||||
export function getAbortReason(signal: AbortSignal | undefined, fallbackReason: string): Error {
|
||||
if (signal?.reason instanceof Error) return signal.reason;
|
||||
if (typeof signal?.reason === "string" && signal.reason.length > 0) {
|
||||
return new Error(signal.reason);
|
||||
}
|
||||
|
||||
return new Error(fallbackReason);
|
||||
}
|
||||
|
||||
export function createCancellationError(name: "AbortError" | "TimeoutError", message: string): Error {
|
||||
const error = new Error(message);
|
||||
error.name = name;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function getExecutionCancellationError(
|
||||
result: { timedOut?: boolean },
|
||||
signal: AbortSignal | undefined,
|
||||
fallbackReason: string,
|
||||
): Error {
|
||||
if (signal?.aborted) {
|
||||
return getAbortReason(signal, fallbackReason);
|
||||
}
|
||||
if (result.timedOut) {
|
||||
return createCancellationError("TimeoutError", fallbackReason);
|
||||
}
|
||||
return createCancellationError("AbortError", fallbackReason);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Display bundle rendering shared between the Python runner output and the
|
||||
* legacy Jupyter MIME conventions. Pure function, no kernel coupling.
|
||||
*/
|
||||
import { htmlToBasicMarkdown } from "../../web/scrapers/types";
|
||||
|
||||
/** Status event emitted by prelude helpers for TUI rendering. */
|
||||
export interface PythonStatusEvent {
|
||||
/** Operation name (e.g., "find", "read", "write") */
|
||||
op: string;
|
||||
/** Additional data fields (count, path, pattern, etc.) */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type KernelDisplayOutput =
|
||||
| { type: "json"; data: unknown }
|
||||
| { type: "image"; data: string; mimeType: string }
|
||||
| { type: "markdown" }
|
||||
| { type: "status"; event: PythonStatusEvent };
|
||||
|
||||
function normalizeDisplayText(text: string): string {
|
||||
return text.endsWith("\n") ? text : `${text}\n`;
|
||||
}
|
||||
|
||||
/** Render a MIME bundle into text + structured outputs. */
|
||||
export async function renderKernelDisplay(content: Record<string, unknown>): Promise<{
|
||||
text: string;
|
||||
outputs: KernelDisplayOutput[];
|
||||
}> {
|
||||
// Accept both raw bundles ({"text/plain": ...}) and Jupyter-style
|
||||
// content envelopes ({ data: {...} }) so callers don't need to unwrap.
|
||||
const data =
|
||||
(content.data as Record<string, unknown> | undefined) ?? (content as Record<string, unknown> | undefined);
|
||||
if (!data) return { text: "", outputs: [] };
|
||||
|
||||
const outputs: KernelDisplayOutput[] = [];
|
||||
|
||||
// Status events bypass the text path entirely — they exist only for TUI hooks.
|
||||
if (data["application/x-omp-status"] !== undefined) {
|
||||
const statusData = data["application/x-omp-status"];
|
||||
if (statusData && typeof statusData === "object" && "op" in statusData) {
|
||||
outputs.push({ type: "status", event: statusData as PythonStatusEvent });
|
||||
}
|
||||
return { text: "", outputs };
|
||||
}
|
||||
|
||||
if (typeof data["image/png"] === "string") {
|
||||
outputs.push({ type: "image", data: data["image/png"] as string, mimeType: "image/png" });
|
||||
}
|
||||
if (typeof data["image/jpeg"] === "string") {
|
||||
outputs.push({ type: "image", data: data["image/jpeg"] as string, mimeType: "image/jpeg" });
|
||||
}
|
||||
if (data["application/json"] !== undefined) {
|
||||
outputs.push({ type: "json", data: data["application/json"] });
|
||||
}
|
||||
|
||||
// text/markdown takes precedence over text/plain (Markdown objects expose both
|
||||
// where text/plain is just the repr).
|
||||
if (typeof data["text/markdown"] === "string") {
|
||||
outputs.push({ type: "markdown" });
|
||||
return { text: normalizeDisplayText(String(data["text/markdown"])), outputs };
|
||||
}
|
||||
if (typeof data["text/plain"] === "string") {
|
||||
return { text: normalizeDisplayText(String(data["text/plain"])), outputs };
|
||||
}
|
||||
if (data["text/html"] !== undefined) {
|
||||
const markdown = (await htmlToBasicMarkdown(String(data["text/html"]))) || "";
|
||||
return { text: markdown ? normalizeDisplayText(markdown) : "", outputs };
|
||||
}
|
||||
return { text: "", outputs };
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
||||
import { OutputSink } from "../../session/streaming-output";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import type { JsStatusEvent } from "../js/shared/types";
|
||||
import { shutdownSharedGateway } from "./gateway-coordinator";
|
||||
import type { KernelDisplayOutput } from "./display";
|
||||
import {
|
||||
checkPythonKernelAvailability,
|
||||
type KernelDisplayOutput,
|
||||
type KernelExecuteOptions,
|
||||
type KernelExecuteResult,
|
||||
PythonKernel,
|
||||
@@ -38,8 +37,6 @@ export interface PythonExecutorOptions {
|
||||
kernelMode?: PythonKernelMode;
|
||||
/** Restart the kernel before executing */
|
||||
reset?: boolean;
|
||||
/** Use shared gateway across pi instances (default: true) */
|
||||
useSharedGateway?: boolean;
|
||||
/** Session file path for accessing task outputs */
|
||||
sessionFile?: string;
|
||||
/**
|
||||
@@ -102,7 +99,6 @@ interface KernelSession {
|
||||
restartCount: number;
|
||||
dead: boolean;
|
||||
needsRestart: boolean;
|
||||
kernelInvalidatedByRecovery: boolean;
|
||||
disposing: boolean;
|
||||
disposeCapacityPromise?: Promise<void>;
|
||||
resolveDisposeCapacity?: () => void;
|
||||
@@ -122,7 +118,6 @@ const disposingKernelSessions = new Set<KernelSession>();
|
||||
let cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
interface KernelSessionExecutionOptions {
|
||||
useSharedGateway?: boolean;
|
||||
sessionFile?: string;
|
||||
artifactsDir?: string;
|
||||
signal?: AbortSignal;
|
||||
@@ -295,7 +290,6 @@ function buildKernelStartOptions(
|
||||
return {
|
||||
cwd,
|
||||
env,
|
||||
useSharedGateway: options.useSharedGateway,
|
||||
signal: options.signal,
|
||||
deadlineMs: options.deadlineMs,
|
||||
};
|
||||
@@ -379,7 +373,6 @@ function finishDisposingKernelSession(session: KernelSession): void {
|
||||
session.disposeResultPromise = undefined;
|
||||
session.disposeResultTimeoutMs = undefined;
|
||||
session.nextDisposalRetryAt = undefined;
|
||||
session.kernelInvalidatedByRecovery = false;
|
||||
syncCleanupTimer();
|
||||
}
|
||||
|
||||
@@ -503,58 +496,6 @@ async function ensureKernelAvailable(
|
||||
}
|
||||
}
|
||||
|
||||
function isResourceExhaustionError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return (
|
||||
message.includes("Too many open files") ||
|
||||
message.includes("EMFILE") ||
|
||||
message.includes("ENFILE") ||
|
||||
message.includes("resource temporarily unavailable")
|
||||
);
|
||||
}
|
||||
|
||||
function clearSharedGatewayDisposingKernelSessionTracking(): void {
|
||||
for (const session of Array.from(disposingKernelSessions.values())) {
|
||||
if (!session.kernel.isSharedGateway) continue;
|
||||
if (session.heartbeatTimer) {
|
||||
clearInterval(session.heartbeatTimer);
|
||||
session.heartbeatTimer = undefined;
|
||||
}
|
||||
disposingKernelSessions.delete(session);
|
||||
session.resolveDisposeCapacity?.();
|
||||
session.resolveDisposeCapacity = undefined;
|
||||
session.disposeCapacityPromise = undefined;
|
||||
session.resolveDisposeAttempt?.();
|
||||
session.resolveDisposeAttempt = undefined;
|
||||
session.disposeAttemptPromise = undefined;
|
||||
session.disposeResultPromise = undefined;
|
||||
session.disposeResultTimeoutMs = undefined;
|
||||
session.nextDisposalRetryAt = undefined;
|
||||
session.kernelInvalidatedByRecovery = false;
|
||||
}
|
||||
}
|
||||
|
||||
function markLiveKernelSessionsForRecovery(): void {
|
||||
for (const session of kernelSessions.values()) {
|
||||
if (session.heartbeatTimer) {
|
||||
clearInterval(session.heartbeatTimer);
|
||||
session.heartbeatTimer = undefined;
|
||||
}
|
||||
session.needsRestart = true;
|
||||
session.kernelInvalidatedByRecovery = session.kernel.isSharedGateway;
|
||||
session.restartCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverFromResourceExhaustion(): Promise<void> {
|
||||
logger.warn("Resource exhaustion detected, recovering by restarting shared gateway");
|
||||
stopCleanupTimer();
|
||||
markLiveKernelSessionsForRecovery();
|
||||
clearSharedGatewayDisposingKernelSessionTracking();
|
||||
await shutdownSharedGateway();
|
||||
syncCleanupTimer();
|
||||
}
|
||||
|
||||
function ensureKernelHeartbeat(session: KernelSession): void {
|
||||
if (session.heartbeatTimer) return;
|
||||
session.heartbeatTimer = setInterval(() => {
|
||||
@@ -570,22 +511,12 @@ async function createKernelSession(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
options: KernelSessionExecutionOptions = {},
|
||||
isRetry?: boolean,
|
||||
): Promise<KernelSession> {
|
||||
requireRemainingTimeoutMs(options.deadlineMs);
|
||||
const env = buildKernelEnv(options);
|
||||
const startOptions = buildKernelStartOptions(cwd, env, options);
|
||||
|
||||
let kernel: PythonKernel;
|
||||
try {
|
||||
kernel = await logger.time("createKernelSession:PythonKernel.start", PythonKernel.start, startOptions);
|
||||
} catch (err) {
|
||||
if (!isRetry && isResourceExhaustionError(err)) {
|
||||
await recoverFromResourceExhaustion();
|
||||
return createKernelSession(sessionId, cwd, options, true);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const kernel = await logger.time("createKernelSession:PythonKernel.start", PythonKernel.start, startOptions);
|
||||
|
||||
const hasFallbackOwner = options.kernelOwnerId === undefined;
|
||||
const initialOwnerId = options.kernelOwnerId ?? sessionId;
|
||||
@@ -596,7 +527,6 @@ async function createKernelSession(
|
||||
restartCount: 0,
|
||||
dead: false,
|
||||
needsRestart: false,
|
||||
kernelInvalidatedByRecovery: false,
|
||||
disposing: false,
|
||||
disposeResultPromise: undefined,
|
||||
nextDisposalRetryAt: undefined,
|
||||
@@ -621,18 +551,16 @@ async function restartKernelSession(
|
||||
}
|
||||
requireRemainingTimeoutMs(options.deadlineMs);
|
||||
try {
|
||||
if (!session.kernelInvalidatedByRecovery) {
|
||||
const deadKernel = session.dead || !session.kernel.isAlive();
|
||||
const shutdownTimeoutMs = requireRemainingTimeoutMs(options.deadlineMs);
|
||||
const shutdownResult = await session.kernel.shutdown({ signal: options.signal, timeoutMs: shutdownTimeoutMs });
|
||||
if (!shutdownResult.confirmed && !deadKernel) {
|
||||
throw new Error("Failed to confirm crashed kernel shutdown before restart");
|
||||
}
|
||||
if (!shutdownResult.confirmed) {
|
||||
logger.warn("Proceeding with retained kernel restart after unconfirmed dead-kernel shutdown", {
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
const deadKernel = session.dead || !session.kernel.isAlive();
|
||||
const shutdownTimeoutMs = requireRemainingTimeoutMs(options.deadlineMs);
|
||||
const shutdownResult = await session.kernel.shutdown({ signal: options.signal, timeoutMs: shutdownTimeoutMs });
|
||||
if (!shutdownResult.confirmed && !deadKernel) {
|
||||
throw new Error("Failed to confirm crashed kernel shutdown before restart");
|
||||
}
|
||||
if (!shutdownResult.confirmed) {
|
||||
logger.warn("Proceeding with retained kernel restart after unconfirmed dead-kernel shutdown", {
|
||||
sessionId: session.id,
|
||||
});
|
||||
}
|
||||
const env = buildKernelEnv(options);
|
||||
const startOptions = buildKernelStartOptions(cwd, env, options);
|
||||
@@ -640,7 +568,6 @@ async function restartKernelSession(
|
||||
session.kernel = kernel;
|
||||
session.dead = false;
|
||||
session.needsRestart = false;
|
||||
session.kernelInvalidatedByRecovery = false;
|
||||
session.lastUsedAt = Date.now();
|
||||
ensureKernelHeartbeat(session);
|
||||
} catch (err) {
|
||||
@@ -654,9 +581,6 @@ type KernelDisposalResult = { status: "confirmed" } | { status: "unconfirmed" }
|
||||
type KernelDisposalWaitResult = KernelDisposalResult | { status: "timedOut" };
|
||||
|
||||
function createKernelDisposalResultPromise(session: KernelSession, timeoutMs?: number): Promise<KernelDisposalResult> {
|
||||
if (session.kernelInvalidatedByRecovery) {
|
||||
return Promise.resolve({ status: "confirmed" as const });
|
||||
}
|
||||
return Promise.resolve()
|
||||
.then(() => session.kernel.shutdown(timeoutMs === undefined ? undefined : { timeoutMs }))
|
||||
.then(
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
import * as fs from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import * as path from "node:path";
|
||||
import { Process } from "@oh-my-pi/pi-natives";
|
||||
import { getPythonGatewayDir, isEnoent, logger, procmgr } from "@oh-my-pi/pi-utils";
|
||||
import type { Subprocess } from "bun";
|
||||
import { Settings } from "../../config/settings";
|
||||
import { getOrCreateSnapshot } from "../../utils/shell-snapshot";
|
||||
import { filterEnv, resolvePythonRuntime } from "./runtime";
|
||||
|
||||
const GATEWAY_INFO_FILE = "gateway.json";
|
||||
const GATEWAY_LOCK_FILE = "gateway.lock";
|
||||
const GATEWAY_STARTUP_TIMEOUT_MS = 30000;
|
||||
const GATEWAY_LOCK_TIMEOUT_MS = GATEWAY_STARTUP_TIMEOUT_MS + 5000;
|
||||
const GATEWAY_LOCK_RETRY_MS = 50;
|
||||
const GATEWAY_LOCK_STALE_MS = GATEWAY_STARTUP_TIMEOUT_MS * 2;
|
||||
const GATEWAY_LOCK_HEARTBEAT_MS = 5000;
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 3000;
|
||||
|
||||
export interface GatewayInfo {
|
||||
url: string;
|
||||
pid: number;
|
||||
startedAt: number;
|
||||
pythonPath?: string;
|
||||
venvPath?: string | null;
|
||||
}
|
||||
|
||||
interface GatewayLockInfo {
|
||||
pid: number;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
interface AcquireResult {
|
||||
url: string;
|
||||
isShared: boolean;
|
||||
}
|
||||
|
||||
let localGatewayProcess: Subprocess | null = null;
|
||||
let localGatewayUrl: string | null = null;
|
||||
let isCoordinatorInitialized = false;
|
||||
|
||||
async function allocatePort(): Promise<number> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<number>();
|
||||
const server = createServer();
|
||||
server.unref();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (address && typeof address === "object") {
|
||||
const port = address.port;
|
||||
server.close((err: Error | null | undefined) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
server.close();
|
||||
reject(new Error("Failed to allocate port"));
|
||||
}
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
function getGatewayDir(): string {
|
||||
return getPythonGatewayDir();
|
||||
}
|
||||
|
||||
function getGatewayInfoPath(): string {
|
||||
return path.join(getGatewayDir(), GATEWAY_INFO_FILE);
|
||||
}
|
||||
|
||||
function getGatewayLockPath(): string {
|
||||
return path.join(getGatewayDir(), GATEWAY_LOCK_FILE);
|
||||
}
|
||||
|
||||
async function writeLockInfo(lockPath: string): Promise<void> {
|
||||
const payload: GatewayLockInfo = { pid: process.pid, startedAt: Date.now() };
|
||||
try {
|
||||
await Bun.write(lockPath, JSON.stringify(payload));
|
||||
} catch {
|
||||
// Ignore lock write failures
|
||||
}
|
||||
}
|
||||
|
||||
async function readLockInfo(lockPath: string): Promise<GatewayLockInfo | null> {
|
||||
try {
|
||||
const raw = await Bun.file(lockPath).text();
|
||||
const parsed = JSON.parse(raw) as Partial<GatewayLockInfo>;
|
||||
if (typeof parsed.pid === "number" && Number.isFinite(parsed.pid)) {
|
||||
return { pid: parsed.pid, startedAt: typeof parsed.startedAt === "number" ? parsed.startedAt : 0 };
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureGatewayDir(): Promise<void> {
|
||||
const dir = getGatewayDir();
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
}
|
||||
|
||||
async function withGatewayLock<T>(handler: () => Promise<T>): Promise<T> {
|
||||
await ensureGatewayDir();
|
||||
const lockPath = getGatewayLockPath();
|
||||
const start = Date.now();
|
||||
while (true) {
|
||||
let fd: fs.promises.FileHandle | undefined;
|
||||
try {
|
||||
fd = await fs.promises.open(lockPath, "wx");
|
||||
let heartbeatRunning = true;
|
||||
const heartbeat = (async () => {
|
||||
while (heartbeatRunning) {
|
||||
await Bun.sleep(GATEWAY_LOCK_HEARTBEAT_MS);
|
||||
if (!heartbeatRunning) break;
|
||||
try {
|
||||
const now = new Date();
|
||||
await fs.promises.utimes(lockPath, now, now);
|
||||
} catch {
|
||||
// Ignore heartbeat errors
|
||||
}
|
||||
}
|
||||
})();
|
||||
try {
|
||||
await writeLockInfo(lockPath);
|
||||
return await handler();
|
||||
} finally {
|
||||
heartbeatRunning = false;
|
||||
void heartbeat.catch(() => {}); // Don't await - let it die naturally
|
||||
try {
|
||||
await fd.close();
|
||||
await fs.promises.unlink(lockPath);
|
||||
} catch {
|
||||
// Ignore lock cleanup errors
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === "EEXIST") {
|
||||
let removedStale = false;
|
||||
try {
|
||||
const lockStat = await fs.promises.stat(lockPath);
|
||||
const lockInfo = await readLockInfo(lockPath);
|
||||
const lockPid = lockInfo?.pid;
|
||||
const lockAgeMs = lockInfo?.startedAt ? Date.now() - lockInfo.startedAt : Date.now() - lockStat.mtimeMs;
|
||||
const staleByTime = lockAgeMs > GATEWAY_LOCK_STALE_MS;
|
||||
const staleByPid = lockPid !== undefined && !procmgr.isPidRunning(lockPid);
|
||||
const staleByMissingPid = lockPid === undefined && staleByTime;
|
||||
if (staleByPid || staleByMissingPid) {
|
||||
await fs.promises.unlink(lockPath);
|
||||
removedStale = true;
|
||||
logger.warn("Removed stale shared gateway lock", { path: lockPath, pid: lockPid });
|
||||
}
|
||||
} catch {
|
||||
// Ignore stat errors; keep waiting
|
||||
}
|
||||
if (!removedStale) {
|
||||
if (Date.now() - start > GATEWAY_LOCK_TIMEOUT_MS) {
|
||||
throw new Error("Timed out waiting for shared gateway lock");
|
||||
}
|
||||
await Bun.sleep(GATEWAY_LOCK_RETRY_MS);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readGatewayInfo(): Promise<GatewayInfo | null> {
|
||||
const infoPath = getGatewayInfoPath();
|
||||
try {
|
||||
const content = await Bun.file(infoPath).text();
|
||||
const parsed = JSON.parse(content) as Partial<GatewayInfo>;
|
||||
|
||||
if (typeof parsed.url !== "string" || typeof parsed.pid !== "number" || typeof parsed.startedAt !== "number") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
url: parsed.url,
|
||||
pid: parsed.pid,
|
||||
startedAt: parsed.startedAt,
|
||||
pythonPath: typeof parsed.pythonPath === "string" ? parsed.pythonPath : undefined,
|
||||
venvPath: typeof parsed.venvPath === "string" || parsed.venvPath === null ? parsed.venvPath : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeGatewayInfo(info: GatewayInfo): Promise<void> {
|
||||
const infoPath = getGatewayInfoPath();
|
||||
const tempPath = `${infoPath}.tmp`;
|
||||
await Bun.write(tempPath, JSON.stringify(info, null, 2));
|
||||
await fs.promises.rename(tempPath, infoPath);
|
||||
}
|
||||
|
||||
async function clearGatewayInfo(): Promise<void> {
|
||||
const infoPath = getGatewayInfoPath();
|
||||
try {
|
||||
await fs.promises.unlink(infoPath);
|
||||
} catch {
|
||||
// Ignore errors on cleanup (file may not exist)
|
||||
}
|
||||
}
|
||||
|
||||
async function isGatewayHealthy(url: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${url}/api/kernelspecs`, {
|
||||
signal: AbortSignal.timeout(HEALTH_CHECK_TIMEOUT_MS),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function isGatewayAlive(info: GatewayInfo): Promise<boolean> {
|
||||
if (!procmgr.isPidRunning(info.pid)) return false;
|
||||
return await isGatewayHealthy(info.url);
|
||||
}
|
||||
|
||||
async function startGatewayProcess(
|
||||
cwd: string,
|
||||
): Promise<{ url: string; pid: number; pythonPath: string; venvPath: string | null }> {
|
||||
const settings = await Settings.init();
|
||||
const { shell, env } = settings.getShellConfig();
|
||||
const filteredEnv = filterEnv(env);
|
||||
const runtime = resolvePythonRuntime(cwd, filteredEnv);
|
||||
const snapshotPath = await getOrCreateSnapshot(shell, env).catch((err: unknown) => {
|
||||
logger.warn("Failed to resolve shell snapshot for shared Python gateway", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
});
|
||||
|
||||
const kernelEnv: Record<string, string | undefined> = {
|
||||
...runtime.env,
|
||||
PYTHONUNBUFFERED: "1",
|
||||
PI_SHELL_SNAPSHOT: snapshotPath ?? undefined,
|
||||
};
|
||||
|
||||
const gatewayPort = await allocatePort();
|
||||
const gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
|
||||
|
||||
const gatewayProcess = Bun.spawn(
|
||||
[
|
||||
runtime.pythonPath,
|
||||
"-m",
|
||||
"kernel_gateway",
|
||||
"--KernelGatewayApp.ip=127.0.0.1",
|
||||
`--KernelGatewayApp.port=${gatewayPort}`,
|
||||
"--KernelGatewayApp.port_retries=0",
|
||||
"--KernelGatewayApp.allow_origin=*",
|
||||
"--JupyterApp.answer_yes=true",
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
windowsHide: true,
|
||||
detached: true,
|
||||
env: kernelEnv,
|
||||
},
|
||||
);
|
||||
|
||||
let exited = false;
|
||||
gatewayProcess.exited
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
exited = true;
|
||||
});
|
||||
|
||||
const startTime = Date.now();
|
||||
while (Date.now() - startTime < GATEWAY_STARTUP_TIMEOUT_MS) {
|
||||
if (exited) {
|
||||
throw new Error("Gateway process exited during startup");
|
||||
}
|
||||
if (await isGatewayHealthy(gatewayUrl)) {
|
||||
localGatewayProcess = gatewayProcess;
|
||||
localGatewayUrl = gatewayUrl;
|
||||
return {
|
||||
url: gatewayUrl,
|
||||
pid: gatewayProcess.pid,
|
||||
pythonPath: runtime.pythonPath,
|
||||
venvPath: runtime.venvPath ?? null,
|
||||
};
|
||||
}
|
||||
await Bun.sleep(100);
|
||||
}
|
||||
|
||||
gatewayProcess.kill();
|
||||
throw new Error("Gateway startup timeout");
|
||||
}
|
||||
|
||||
async function killGateway(pid: number, context: string): Promise<void> {
|
||||
try {
|
||||
await Process.fromPid(pid)?.terminate();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to kill shared gateway process", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
pid,
|
||||
context,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireSharedGateway(cwd: string): Promise<AcquireResult | null> {
|
||||
try {
|
||||
return await withGatewayLock(async () => {
|
||||
const existingInfo = await logger.time("acquireSharedGateway:readInfo", readGatewayInfo);
|
||||
if (existingInfo) {
|
||||
if (await logger.time("acquireSharedGateway:isAlive", isGatewayAlive, existingInfo)) {
|
||||
localGatewayUrl = existingInfo.url;
|
||||
isCoordinatorInitialized = true;
|
||||
logger.debug("Reusing global Python gateway", { url: existingInfo.url });
|
||||
return { url: existingInfo.url, isShared: true };
|
||||
}
|
||||
|
||||
logger.debug("Cleaning up stale gateway info", { pid: existingInfo.pid });
|
||||
if (procmgr.isPidRunning(existingInfo.pid)) {
|
||||
await killGateway(existingInfo.pid, "stale");
|
||||
}
|
||||
await clearGatewayInfo();
|
||||
}
|
||||
|
||||
const { url, pid, pythonPath, venvPath } = await logger.time(
|
||||
"acquireSharedGateway:startGateway",
|
||||
startGatewayProcess,
|
||||
cwd,
|
||||
);
|
||||
const info: GatewayInfo = {
|
||||
url,
|
||||
pid,
|
||||
startedAt: Date.now(),
|
||||
pythonPath,
|
||||
venvPath,
|
||||
};
|
||||
await writeGatewayInfo(info);
|
||||
isCoordinatorInitialized = true;
|
||||
logger.debug("Started global Python gateway", { url, pid });
|
||||
return { url, isShared: true };
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to acquire shared gateway, falling back to local", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function releaseSharedGateway(): Promise<void> {
|
||||
if (!isCoordinatorInitialized) return;
|
||||
}
|
||||
|
||||
export async function getSharedGatewayUrl(): Promise<string | null> {
|
||||
if (localGatewayUrl) return localGatewayUrl;
|
||||
return (await readGatewayInfo())?.url ?? null;
|
||||
}
|
||||
|
||||
export async function isSharedGatewayActive(): Promise<boolean> {
|
||||
return (await getGatewayStatus()).active;
|
||||
}
|
||||
|
||||
export interface GatewayStatus {
|
||||
active: boolean;
|
||||
url: string | null;
|
||||
pid: number | null;
|
||||
uptime: number | null;
|
||||
pythonPath: string | null;
|
||||
venvPath: string | null;
|
||||
}
|
||||
|
||||
export async function getGatewayStatus(): Promise<GatewayStatus> {
|
||||
const info = await readGatewayInfo();
|
||||
if (!info) {
|
||||
return {
|
||||
active: false,
|
||||
url: null,
|
||||
pid: null,
|
||||
uptime: null,
|
||||
pythonPath: null,
|
||||
venvPath: null,
|
||||
};
|
||||
}
|
||||
const active = procmgr.isPidRunning(info.pid);
|
||||
return {
|
||||
active,
|
||||
url: info.url,
|
||||
pid: info.pid,
|
||||
uptime: active ? Date.now() - info.startedAt : null,
|
||||
pythonPath: info.pythonPath ?? null,
|
||||
venvPath: info.venvPath ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function shutdownSharedGateway(): Promise<void> {
|
||||
try {
|
||||
await withGatewayLock(async () => {
|
||||
const info = await readGatewayInfo();
|
||||
if (!info) return;
|
||||
if (procmgr.isPidRunning(info.pid)) {
|
||||
await killGateway(info.pid, "shutdown");
|
||||
}
|
||||
await clearGatewayInfo();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to shutdown shared gateway", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
if (localGatewayProcess) {
|
||||
await killGateway(localGatewayProcess.pid, "shutdown-local");
|
||||
}
|
||||
localGatewayProcess = null;
|
||||
localGatewayUrl = null;
|
||||
isCoordinatorInitialized = false;
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,6 @@ export default {
|
||||
},
|
||||
|
||||
async execute(code: string, opts: ExecutorBackendExecOptions): Promise<ExecutorBackendResult> {
|
||||
const useSharedGateway = readSetting<boolean>(opts.session, "python.sharedGateway");
|
||||
const kernelMode = readSetting<PythonExecutorOptions["kernelMode"]>(opts.session, "python.kernelMode");
|
||||
const executorOptions: PythonExecutorOptions = {
|
||||
cwd: opts.cwd,
|
||||
@@ -33,7 +32,6 @@ export default {
|
||||
signal: opts.signal,
|
||||
sessionId: namespaceSessionId(opts.sessionId),
|
||||
kernelMode,
|
||||
useSharedGateway,
|
||||
sessionFile: opts.sessionFile,
|
||||
artifactsDir: opts.session.getArtifactsDir?.() ?? undefined,
|
||||
kernelOwnerId: opts.kernelOwnerId,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
# OMP IPython prelude helpers
|
||||
# OMP prelude helpers (loaded once into the runner namespace)
|
||||
if "__omp_prelude_loaded__" not in globals():
|
||||
__omp_prelude_loaded__ = True
|
||||
from pathlib import Path
|
||||
import os, json
|
||||
from IPython.display import display as _ipy_display, JSON
|
||||
|
||||
# __omp_display is injected by runner.py before the prelude executes; it
|
||||
# mirrors IPython's display() semantics with the same MIME bundle output.
|
||||
_omp_display = __omp_display # type: ignore[name-defined]
|
||||
|
||||
_PRESENTABLE_REPRS = (
|
||||
"_repr_mimebundle_",
|
||||
@@ -18,21 +21,22 @@ if "__omp_prelude_loaded__" not in globals():
|
||||
)
|
||||
|
||||
def display(value):
|
||||
"""Render a value. Wraps plain dict/list values as interactive JSON."""
|
||||
"""Render a value. Falls back to a JSON+text/plain bundle for plain dict/list/tuple."""
|
||||
if any(hasattr(value, attr) for attr in _PRESENTABLE_REPRS):
|
||||
_ipy_display(value)
|
||||
_omp_display(value)
|
||||
return
|
||||
if isinstance(value, (dict, list, tuple)):
|
||||
try:
|
||||
_ipy_display(JSON(value))
|
||||
bundle = {"application/json": value, "text/plain": repr(value)}
|
||||
_omp_display(bundle, raw=True)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
_ipy_display(value)
|
||||
_omp_display(value)
|
||||
|
||||
def _emit_status(op: str, **data):
|
||||
"""Emit structured status event for TUI rendering."""
|
||||
_ipy_display({"application/x-omp-status": {"op": op, **data}}, raw=True)
|
||||
_omp_display({"application/x-omp-status": {"op": op, **data}}, raw=True)
|
||||
|
||||
|
||||
def env(key: str | None = None, value: str | None = None):
|
||||
|
||||
@@ -0,0 +1,879 @@
|
||||
"""OMP Python runner — subprocess wrapper used by the coding-agent host.
|
||||
|
||||
NDJSON protocol over stdin/stdout. Host writes one JSON object per line;
|
||||
wrapper writes typed frames back.
|
||||
|
||||
Host -> wrapper:
|
||||
{"id": str, "code": str, "silent": bool?, "storeHistory": bool?}
|
||||
{"type": "exit"} # graceful shutdown
|
||||
|
||||
Wrapper -> host:
|
||||
{"type": "started", "id": ...}
|
||||
{"type": "stdout", "id": ..., "data": str}
|
||||
{"type": "stderr", "id": ..., "data": str}
|
||||
{"type": "display", "id": ..., "bundle": {<mime>: <value>}}
|
||||
{"type": "result", "id": ..., "bundle": {<mime>: <value>}}
|
||||
{"type": "error", "id": ..., "ename": str, "evalue": str, "traceback": [str]}
|
||||
{"type": "done", "id": ..., "status": "ok"|"error",
|
||||
"executionCount": int, "cancelled": bool}
|
||||
|
||||
The runner is intentionally self-contained: no third-party imports, no IPython.
|
||||
Magics are translated by a small line-scanner before AST parsing; rich display
|
||||
falls back through `_repr_*_` methods so pandas/PIL/plotly etc. still render
|
||||
when installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import base64
|
||||
import builtins
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import runpy
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Frame writer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RAW_STDOUT = sys.__stdout__
|
||||
_RAW_STDERR = sys.__stderr__
|
||||
_OUT_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _json_default(o: Any) -> Any:
|
||||
try:
|
||||
return repr(o)
|
||||
except Exception:
|
||||
return f"<unrepr {type(o).__name__}>"
|
||||
|
||||
|
||||
def _emit(frame: dict) -> None:
|
||||
"""Serialize a frame and write it to the host as a single NDJSON line."""
|
||||
line = json.dumps(frame, ensure_ascii=False, default=_json_default)
|
||||
with _OUT_LOCK:
|
||||
_RAW_STDOUT.write(line)
|
||||
_RAW_STDOUT.write("\n")
|
||||
_RAW_STDOUT.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User stdout/stderr proxies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StreamProxy(io.TextIOBase):
|
||||
"""Emit each ``write()`` as a typed frame tied to the current request."""
|
||||
|
||||
def __init__(self, kind: str) -> None:
|
||||
super().__init__()
|
||||
self._kind = kind
|
||||
|
||||
def writable(self) -> bool: # noqa: D401 - protocol method
|
||||
return True
|
||||
|
||||
def isatty(self) -> bool: # noqa: D401 - protocol method
|
||||
return False
|
||||
|
||||
def write(self, data: Any) -> int: # type: ignore[override]
|
||||
if not isinstance(data, str):
|
||||
data = str(data)
|
||||
if not data:
|
||||
return 0
|
||||
rid = _STATE.current_id
|
||||
if rid is None:
|
||||
_RAW_STDERR.write(data)
|
||||
_RAW_STDERR.flush()
|
||||
return len(data)
|
||||
_emit({"type": self._kind, "id": rid, "data": data})
|
||||
return len(data)
|
||||
|
||||
def flush(self) -> None: # noqa: D401 - protocol method
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runner state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RunnerState:
|
||||
def __init__(self) -> None:
|
||||
self.current_id: str | None = None
|
||||
self.execution_count: int = 0
|
||||
self.cancel_requested: bool = False
|
||||
# User globals — kept across requests when running in session mode.
|
||||
self.user_ns: dict[str, Any] = {
|
||||
"__name__": "__main__",
|
||||
"__doc__": None,
|
||||
"__builtins__": builtins,
|
||||
}
|
||||
self.last_install_marker: int = 0
|
||||
|
||||
|
||||
_STATE = _RunnerState()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Magic source transformer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_MAGIC_LINE_RE = re.compile(r"^(?P<indent>[ \t]*)(?P<name>[A-Za-z_][A-Za-z_0-9]*)(?:[ \t]+(?P<args>.*))?$")
|
||||
_ASSIGN_LINE_RE = re.compile(
|
||||
r"^(?P<indent>[ \t]*)(?P<lhs>[A-Za-z_][A-Za-z_0-9.\[\], ]*?)\s*=\s*(?P<rhs>.+)$"
|
||||
)
|
||||
|
||||
|
||||
def _fold_continuations(lines: list[str], start: int) -> tuple[str, int]:
|
||||
"""Fold trailing backslash continuations starting at ``start``. Returns
|
||||
``(folded_text, lines_consumed)``."""
|
||||
parts: list[str] = []
|
||||
i = start
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line.endswith("\\"):
|
||||
parts.append(line[:-1])
|
||||
i += 1
|
||||
continue
|
||||
parts.append(line)
|
||||
i += 1
|
||||
break
|
||||
return ("".join(parts), i - start)
|
||||
|
||||
|
||||
def _quote_arg(text: str) -> str:
|
||||
"""Return a Python string literal that round-trips ``text`` exactly."""
|
||||
return json.dumps(text, ensure_ascii=False)
|
||||
|
||||
|
||||
def transform_cell(source: str) -> str:
|
||||
"""Translate IPython-style magics + shell escapes into plain Python.
|
||||
|
||||
Rules
|
||||
-----
|
||||
* ``%name args`` -> ``__omp_magic("name", "args")``
|
||||
* ``var = %name args`` -> ``var = __omp_magic("name", "args")``
|
||||
* ``!cmd`` -> ``__omp_shell("cmd")``
|
||||
* ``var = !cmd`` -> ``var = __omp_shell("cmd")``
|
||||
* ``%%name args\\n<body>`` -> ``__omp_magic_cell("name", "args", "<body>")``
|
||||
(cell magic must be the first non-whitespace token of a top-level line and
|
||||
consumes the remainder of the cell)
|
||||
|
||||
Lines inside strings or comments are left alone — we operate on the raw
|
||||
text before parsing, but the scanner only fires on the first token of each
|
||||
physical line and never touches the body of triple-quoted strings because
|
||||
those bodies are never first tokens themselves.
|
||||
"""
|
||||
|
||||
if "%" not in source and "!" not in source:
|
||||
return source
|
||||
|
||||
lines = source.splitlines()
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.lstrip()
|
||||
indent = line[: len(line) - len(stripped)]
|
||||
|
||||
# Cell magic — consumes from here to EOF.
|
||||
if stripped.startswith("%%"):
|
||||
head, _ = _split_magic_head(stripped[2:])
|
||||
name, args = head
|
||||
body_lines = lines[i + 1 :]
|
||||
body = "\n".join(body_lines)
|
||||
out.append(
|
||||
f"{indent}__omp_magic_cell({_quote_arg(name)}, {_quote_arg(args)}, {_quote_arg(body)})"
|
||||
)
|
||||
return "\n".join(out)
|
||||
|
||||
# Line magic / shell at start of line.
|
||||
if stripped.startswith("%") and not stripped.startswith("%%"):
|
||||
folded, consumed = _fold_continuations(lines, i)
|
||||
stripped_folded = folded.lstrip()
|
||||
indent = folded[: len(folded) - len(stripped_folded)]
|
||||
head, _ = _split_magic_head(stripped_folded[1:])
|
||||
name, args = head
|
||||
out.append(f"{indent}__omp_magic({_quote_arg(name)}, {_quote_arg(args)})")
|
||||
i += consumed
|
||||
continue
|
||||
|
||||
if stripped.startswith("!"):
|
||||
folded, consumed = _fold_continuations(lines, i)
|
||||
stripped_folded = folded.lstrip()
|
||||
indent = folded[: len(folded) - len(stripped_folded)]
|
||||
cmd = stripped_folded[1:].strip()
|
||||
out.append(f"{indent}__omp_shell({_quote_arg(cmd)})")
|
||||
i += consumed
|
||||
continue
|
||||
|
||||
# Assignment forms: var = %magic / var = !cmd
|
||||
m = _ASSIGN_LINE_RE.match(line)
|
||||
if m:
|
||||
rhs = m.group("rhs").strip()
|
||||
if rhs.startswith("!"):
|
||||
cmd = rhs[1:].strip()
|
||||
out.append(f"{m.group('indent')}{m.group('lhs').rstrip()} = __omp_shell({_quote_arg(cmd)})")
|
||||
i += 1
|
||||
continue
|
||||
if rhs.startswith("%") and not rhs.startswith("%%"):
|
||||
head, _ = _split_magic_head(rhs[1:])
|
||||
name, args = head
|
||||
out.append(
|
||||
f"{m.group('indent')}{m.group('lhs').rstrip()} = __omp_magic({_quote_arg(name)}, {_quote_arg(args)})"
|
||||
)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
out.append(line)
|
||||
i += 1
|
||||
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _split_magic_head(text: str) -> tuple[tuple[str, str], str]:
|
||||
"""Split ``"name rest"`` into ``("name", "rest")``."""
|
||||
text = text.lstrip()
|
||||
if not text:
|
||||
return ("", ""), ""
|
||||
m = re.match(r"([A-Za-z_][A-Za-z_0-9]*)(?:\s+(.*))?$", text)
|
||||
if not m:
|
||||
return ("", text), ""
|
||||
return (m.group(1), (m.group(2) or "").rstrip()), ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Magic registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_LINE_MAGICS: dict[str, Callable[[str], Any]] = {}
|
||||
_CELL_MAGICS: dict[str, Callable[[str, str], Any]] = {}
|
||||
|
||||
|
||||
def line_magic(name: str) -> Callable[[Callable[[str], Any]], Callable[[str], Any]]:
|
||||
def decorator(fn: Callable[[str], Any]) -> Callable[[str], Any]:
|
||||
_LINE_MAGICS[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def cell_magic(name: str) -> Callable[[Callable[[str, str], Any]], Callable[[str, str], Any]]:
|
||||
def decorator(fn: Callable[[str, str], Any]) -> Callable[[str, str], Any]:
|
||||
_CELL_MAGICS[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def _emit_status(op: str, **data: Any) -> None:
|
||||
bundle = {"application/x-omp-status": {"op": op, **data}}
|
||||
rid = _STATE.current_id
|
||||
if rid is None:
|
||||
return
|
||||
_emit({"type": "display", "id": rid, "bundle": bundle})
|
||||
|
||||
|
||||
@line_magic("pip")
|
||||
def _magic_pip(args: str) -> None:
|
||||
argv = shlex.split(args) if args else ["--help"]
|
||||
cmd = [sys.executable, "-m", "pip", *argv]
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
installed_packages: list[str] = []
|
||||
assert proc.stdout is not None
|
||||
for raw_line in proc.stdout:
|
||||
sys.stdout.write(raw_line)
|
||||
m = re.search(r"Successfully installed\s+(.+)$", raw_line)
|
||||
if m:
|
||||
for token in m.group(1).split():
|
||||
# Token is name-version; drop the version suffix.
|
||||
pkg = token.rsplit("-", 1)[0]
|
||||
installed_packages.append(pkg.replace("_", "-"))
|
||||
proc.wait()
|
||||
if installed_packages:
|
||||
import importlib
|
||||
|
||||
importlib.invalidate_caches()
|
||||
prefixes = {pkg.lower().replace("-", "_") for pkg in installed_packages}
|
||||
for mod_name in list(sys.modules):
|
||||
head = mod_name.split(".", 1)[0].lower()
|
||||
if head in prefixes:
|
||||
sys.modules.pop(mod_name, None)
|
||||
_emit_status("pip", args=args, installed=installed_packages, exit_code=proc.returncode)
|
||||
|
||||
|
||||
@line_magic("cd")
|
||||
def _magic_cd(args: str) -> str:
|
||||
path = os.path.expanduser(args.strip()) or os.path.expanduser("~")
|
||||
os.chdir(path)
|
||||
cwd = os.getcwd()
|
||||
_emit_status("cd", path=cwd)
|
||||
return cwd
|
||||
|
||||
|
||||
@line_magic("pwd")
|
||||
def _magic_pwd(_args: str) -> str:
|
||||
cwd = os.getcwd()
|
||||
_emit_status("pwd", path=cwd)
|
||||
return cwd
|
||||
|
||||
|
||||
@line_magic("ls")
|
||||
def _magic_ls(args: str) -> list[str]:
|
||||
target = os.path.expanduser(args.strip()) or "."
|
||||
entries = sorted(os.listdir(target))
|
||||
_emit_status("ls", path=os.path.abspath(target), count=len(entries))
|
||||
return entries
|
||||
|
||||
|
||||
@line_magic("env")
|
||||
def _magic_env(args: str) -> Any:
|
||||
args = args.strip()
|
||||
if not args:
|
||||
return dict(sorted(os.environ.items()))
|
||||
if "=" in args:
|
||||
key, value = args.split("=", 1)
|
||||
os.environ[key.strip()] = value.strip()
|
||||
return value.strip()
|
||||
return os.environ.get(args)
|
||||
|
||||
|
||||
@line_magic("set_env")
|
||||
def _magic_set_env(args: str) -> str:
|
||||
parts = args.split(None, 1)
|
||||
if len(parts) != 2:
|
||||
raise ValueError("Usage: %set_env KEY VALUE")
|
||||
key, value = parts
|
||||
os.environ[key] = value
|
||||
return value
|
||||
|
||||
|
||||
@line_magic("time")
|
||||
def _magic_time(args: str) -> Any:
|
||||
start = time.perf_counter()
|
||||
result = eval(args, _STATE.user_ns)
|
||||
elapsed = time.perf_counter() - start
|
||||
sys.stdout.write(f"Wall time: {elapsed * 1000:.2f} ms\n")
|
||||
_emit_status("time", elapsed_ms=round(elapsed * 1000, 3))
|
||||
return result
|
||||
|
||||
|
||||
@line_magic("timeit")
|
||||
def _magic_timeit(args: str) -> None:
|
||||
import timeit as _timeit
|
||||
|
||||
timer = _timeit.Timer(stmt=args, globals=_STATE.user_ns)
|
||||
iters, total = timer.autorange()
|
||||
per = total / iters
|
||||
sys.stdout.write(f"{iters} loops, best of 1: {per * 1e6:.2f} us per loop\n")
|
||||
_emit_status("timeit", loops=iters, total_ms=round(total * 1000, 3))
|
||||
|
||||
|
||||
@line_magic("who")
|
||||
def _magic_who(_args: str) -> list[str]:
|
||||
names = sorted(
|
||||
name
|
||||
for name, value in _STATE.user_ns.items()
|
||||
if not name.startswith("_") and not callable(value) or hasattr(value, "__class__")
|
||||
)
|
||||
return [n for n in names if not n.startswith("__")]
|
||||
|
||||
|
||||
@line_magic("whos")
|
||||
def _magic_whos(_args: str) -> list[tuple[str, str]]:
|
||||
rows = []
|
||||
for name in sorted(_STATE.user_ns):
|
||||
if name.startswith("__"):
|
||||
continue
|
||||
value = _STATE.user_ns[name]
|
||||
rows.append((name, type(value).__name__))
|
||||
return rows
|
||||
|
||||
|
||||
@line_magic("reset")
|
||||
def _magic_reset(_args: str) -> None:
|
||||
_STATE.user_ns.clear()
|
||||
_STATE.user_ns.update({"__name__": "__main__", "__doc__": None, "__builtins__": builtins})
|
||||
_install_builtins(_STATE.user_ns)
|
||||
_emit_status("reset")
|
||||
|
||||
|
||||
@line_magic("load")
|
||||
def _magic_load(args: str) -> None:
|
||||
path = Path(os.path.expanduser(args.strip()))
|
||||
source = path.read_text(encoding="utf-8")
|
||||
_emit({"type": "display", "id": _STATE.current_id, "bundle": {"text/plain": source}})
|
||||
_exec_source(source, _STATE.user_ns)
|
||||
|
||||
|
||||
@line_magic("run")
|
||||
def _magic_run(args: str) -> None:
|
||||
parts = shlex.split(args) if args else []
|
||||
if not parts:
|
||||
raise ValueError("Usage: %run <path>")
|
||||
target = os.path.expanduser(parts[0])
|
||||
saved_argv = sys.argv
|
||||
try:
|
||||
sys.argv = [target, *parts[1:]]
|
||||
result_ns = runpy.run_path(target, run_name="__main__")
|
||||
finally:
|
||||
sys.argv = saved_argv
|
||||
for name, value in result_ns.items():
|
||||
if name.startswith("__"):
|
||||
continue
|
||||
_STATE.user_ns[name] = value
|
||||
|
||||
|
||||
@cell_magic("bash")
|
||||
def _magic_cell_bash(args: str, body: str) -> int:
|
||||
return _run_shell_body(body, shell_arg="/bin/bash")
|
||||
|
||||
|
||||
@cell_magic("sh")
|
||||
def _magic_cell_sh(args: str, body: str) -> int:
|
||||
return _run_shell_body(body, shell_arg="/bin/sh")
|
||||
|
||||
|
||||
@cell_magic("capture")
|
||||
def _magic_cell_capture(args: str, body: str) -> str:
|
||||
"""Capture stdout/stderr of body; bind to ``args`` (a name) if provided."""
|
||||
captured = io.StringIO()
|
||||
saved_stdout, saved_stderr = sys.stdout, sys.stderr
|
||||
sys.stdout = sys.stderr = captured
|
||||
try:
|
||||
_exec_source(body, _STATE.user_ns)
|
||||
finally:
|
||||
sys.stdout, sys.stderr = saved_stdout, saved_stderr
|
||||
text = captured.getvalue()
|
||||
name = args.strip()
|
||||
if name:
|
||||
_STATE.user_ns[name] = text
|
||||
return text
|
||||
|
||||
|
||||
@cell_magic("timeit")
|
||||
def _magic_cell_timeit(args: str, body: str) -> None:
|
||||
import timeit as _timeit
|
||||
|
||||
timer = _timeit.Timer(stmt=body, globals=_STATE.user_ns)
|
||||
iters, total = timer.autorange()
|
||||
per = total / iters
|
||||
sys.stdout.write(f"{iters} loops, best of 1: {per * 1e6:.2f} us per loop\n")
|
||||
_emit_status("timeit", loops=iters, total_ms=round(total * 1000, 3))
|
||||
|
||||
|
||||
@cell_magic("writefile")
|
||||
def _magic_cell_writefile(args: str, body: str) -> str:
|
||||
path = Path(os.path.expanduser(args.strip()))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(body, encoding="utf-8")
|
||||
_emit_status("writefile", path=str(path), bytes=len(body))
|
||||
return str(path)
|
||||
|
||||
|
||||
def _run_shell_body(body: str, *, shell_arg: str) -> int:
|
||||
proc = subprocess.Popen(
|
||||
[shell_arg, "-c", body],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for raw_line in proc.stdout:
|
||||
sys.stdout.write(raw_line)
|
||||
proc.wait()
|
||||
return proc.returncode
|
||||
|
||||
|
||||
def __omp_magic(name: str, args: str) -> Any:
|
||||
fn = _LINE_MAGICS.get(name)
|
||||
if fn is None:
|
||||
raise NameError(f"UsageError: Line magic function '%{name}' not found.")
|
||||
return fn(args)
|
||||
|
||||
|
||||
def __omp_magic_cell(name: str, args: str, body: str) -> Any:
|
||||
fn = _CELL_MAGICS.get(name)
|
||||
if fn is None:
|
||||
raise NameError(f"UsageError: Cell magic function '%%{name}' not found.")
|
||||
return fn(args, body)
|
||||
|
||||
|
||||
class _ShellResult(list):
|
||||
"""Result of ``!cmd`` — list of stripped output lines."""
|
||||
|
||||
def __init__(self, lines: list[str], returncode: int) -> None:
|
||||
super().__init__(lines)
|
||||
self.returncode = returncode
|
||||
|
||||
@property
|
||||
def n(self) -> str: # IPython compat
|
||||
return "\n".join(self)
|
||||
|
||||
@property
|
||||
def s(self) -> str: # IPython compat
|
||||
return " ".join(self)
|
||||
|
||||
|
||||
def __omp_shell(cmd: str) -> _ShellResult:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
shell=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
)
|
||||
if proc.stdout:
|
||||
sys.stdout.write(proc.stdout)
|
||||
lines = [line for line in (proc.stdout or "").splitlines()]
|
||||
return _ShellResult(lines, proc.returncode)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Display dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_REPR_MIMES = [
|
||||
("_repr_html_", "text/html"),
|
||||
("_repr_markdown_", "text/markdown"),
|
||||
("_repr_svg_", "image/svg+xml"),
|
||||
("_repr_png_", "image/png"),
|
||||
("_repr_jpeg_", "image/jpeg"),
|
||||
("_repr_json_", "application/json"),
|
||||
("_repr_latex_", "text/latex"),
|
||||
]
|
||||
|
||||
|
||||
def _coerce_image_bytes(value: Any) -> str:
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
return base64.b64encode(bytes(value)).decode("ascii")
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return base64.b64encode(repr(value).encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def _mime_bundle(value: Any) -> dict:
|
||||
"""Build a Jupyter-style MIME bundle for ``value``.
|
||||
|
||||
Honors ``_repr_mimebundle_`` first, falls back to individual ``_repr_*_``
|
||||
accessors, and always provides ``text/plain``.
|
||||
"""
|
||||
bundle: dict[str, Any] = {}
|
||||
|
||||
mimebundle = getattr(value, "_repr_mimebundle_", None)
|
||||
if callable(mimebundle):
|
||||
try:
|
||||
data = mimebundle()
|
||||
except Exception:
|
||||
data = None
|
||||
if isinstance(data, tuple):
|
||||
data = data[0]
|
||||
if isinstance(data, dict):
|
||||
bundle.update({str(k): v for k, v in data.items()})
|
||||
|
||||
for attr, mime in _REPR_MIMES:
|
||||
if mime in bundle:
|
||||
continue
|
||||
repr_fn = getattr(value, attr, None)
|
||||
if not callable(repr_fn):
|
||||
continue
|
||||
try:
|
||||
data = repr_fn()
|
||||
except Exception:
|
||||
continue
|
||||
if data is None:
|
||||
continue
|
||||
if mime in ("image/png", "image/jpeg"):
|
||||
bundle[mime] = _coerce_image_bytes(data)
|
||||
else:
|
||||
bundle[mime] = data
|
||||
|
||||
if "text/plain" not in bundle:
|
||||
try:
|
||||
bundle["text/plain"] = repr(value)
|
||||
except Exception:
|
||||
bundle["text/plain"] = f"<unrepr {type(value).__name__}>"
|
||||
|
||||
return bundle
|
||||
|
||||
|
||||
def _emit_display(bundle: dict, *, kind: str = "display") -> None:
|
||||
rid = _STATE.current_id
|
||||
if rid is None:
|
||||
return
|
||||
_emit({"type": kind, "id": rid, "bundle": bundle})
|
||||
|
||||
|
||||
def __omp_display(value: Any, *, raw: bool = False, kind: str = "display") -> None:
|
||||
if raw:
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError("display(..., raw=True) requires a MIME bundle dict")
|
||||
bundle = {str(k): v for k, v in value.items()}
|
||||
if "text/plain" not in bundle:
|
||||
bundle["text/plain"] = ""
|
||||
_emit_display(bundle, kind=kind)
|
||||
return
|
||||
_emit_display(_mime_bundle(value), kind=kind)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matplotlib post-cell flush
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _flush_matplotlib_figures() -> None:
|
||||
plt = sys.modules.get("matplotlib.pyplot")
|
||||
if plt is None:
|
||||
return
|
||||
try:
|
||||
fignums = list(plt.get_fignums())
|
||||
except Exception:
|
||||
return
|
||||
for num in fignums:
|
||||
try:
|
||||
fig = plt.figure(num)
|
||||
buf = io.BytesIO()
|
||||
fig.savefig(buf, format="png", bbox_inches="tight")
|
||||
data = base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
_emit_display({"image/png": data, "text/plain": f"<Figure {num}>"})
|
||||
plt.close(fig)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
|
||||
# Force a non-interactive backend before user code imports matplotlib. Set as
|
||||
# environ default so the user can still override it explicitly.
|
||||
os.environ.setdefault("MPLBACKEND", "Agg")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Builtin injection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_builtins(ns: dict) -> None:
|
||||
ns["display"] = __omp_display
|
||||
ns["__omp_display"] = __omp_display
|
||||
ns["__omp_magic"] = __omp_magic
|
||||
ns["__omp_magic_cell"] = __omp_magic_cell
|
||||
ns["__omp_shell"] = __omp_shell
|
||||
|
||||
|
||||
_install_builtins(_STATE.user_ns)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source execution (split last expression for rich display)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _exec_source(source: str, ns: dict) -> None:
|
||||
"""Compile + execute ``source``; if the last node is an expression, route
|
||||
its value through ``__omp_display`` so dataframes/figures render rich."""
|
||||
try:
|
||||
module = ast.parse(source, mode="exec")
|
||||
except SyntaxError:
|
||||
raise
|
||||
|
||||
if not module.body:
|
||||
return
|
||||
|
||||
last = module.body[-1]
|
||||
if isinstance(last, ast.Expr):
|
||||
body_module = ast.Module(body=module.body[:-1], type_ignores=[])
|
||||
expr_module = ast.Expression(body=last.value)
|
||||
ast.copy_location(expr_module, last)
|
||||
body_code = compile(body_module, "<cell>", "exec")
|
||||
expr_code = compile(expr_module, "<cell>", "eval")
|
||||
exec(body_code, ns)
|
||||
value = eval(expr_code, ns)
|
||||
if value is not None:
|
||||
__omp_display(value, kind="result")
|
||||
return
|
||||
|
||||
code = compile(module, "<cell>", "exec")
|
||||
exec(code, ns)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_idle_sigint() -> None:
|
||||
try:
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
except (OSError, ValueError):
|
||||
# Some platforms (Windows in non-console mode) reject this; fine.
|
||||
pass
|
||||
|
||||
|
||||
def _install_exec_sigint() -> None:
|
||||
try:
|
||||
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _start_parent_watchdog() -> None:
|
||||
"""Self-terminate when the host process dies.
|
||||
|
||||
The main loop only exits when stdin EOFs, which only happens once user
|
||||
code finishes and the next ``readline`` call returns. If the host gets
|
||||
SIGKILL mid-execution (or any way that skips graceful shutdown) the
|
||||
runner would otherwise outlive its parent and keep holding kernel
|
||||
state. Poll ``os.getppid()`` instead and ``os._exit`` the moment we get
|
||||
reparented \u2014 covers POSIX hosts. Windows has no reliable ppid
|
||||
equivalent; there we still bail out on the next stdin read.
|
||||
"""
|
||||
if os.name != "posix":
|
||||
return
|
||||
original_ppid = os.getppid()
|
||||
if original_ppid <= 1:
|
||||
return
|
||||
|
||||
def watch() -> None:
|
||||
while True:
|
||||
try:
|
||||
if os.getppid() != original_ppid:
|
||||
os._exit(0)
|
||||
except Exception:
|
||||
return
|
||||
time.sleep(10)
|
||||
|
||||
thread = threading.Thread(target=watch, name="omp-parent-watchdog", daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _handle_request(req: dict) -> None:
|
||||
if req.get("type") == "exit":
|
||||
sys.exit(0)
|
||||
|
||||
rid = str(req.get("id"))
|
||||
code = req.get("code", "")
|
||||
_STATE.current_id = rid
|
||||
_STATE.cancel_requested = False
|
||||
_STATE.execution_count += 1
|
||||
_emit({"type": "started", "id": rid})
|
||||
|
||||
status: str = "ok"
|
||||
cancelled = False
|
||||
|
||||
try:
|
||||
transformed = transform_cell(code)
|
||||
except SyntaxError as exc:
|
||||
_emit_error(rid, exc)
|
||||
_emit({
|
||||
"type": "done",
|
||||
"id": rid,
|
||||
"status": "error",
|
||||
"executionCount": _STATE.execution_count,
|
||||
"cancelled": False,
|
||||
})
|
||||
_STATE.current_id = None
|
||||
return
|
||||
|
||||
_install_exec_sigint()
|
||||
try:
|
||||
_exec_source(transformed, _STATE.user_ns)
|
||||
except KeyboardInterrupt:
|
||||
cancelled = True
|
||||
status = "error"
|
||||
_emit_error(rid, KeyboardInterrupt("Execution interrupted"))
|
||||
except SystemExit:
|
||||
raise
|
||||
except BaseException as exc: # noqa: BLE001 - we want to surface every user error
|
||||
status = "error"
|
||||
_emit_error(rid, exc)
|
||||
finally:
|
||||
_install_idle_sigint()
|
||||
try:
|
||||
_flush_matplotlib_figures()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_emit({
|
||||
"type": "done",
|
||||
"id": rid,
|
||||
"status": status,
|
||||
"executionCount": _STATE.execution_count,
|
||||
"cancelled": cancelled,
|
||||
})
|
||||
_STATE.current_id = None
|
||||
|
||||
|
||||
def _emit_error(rid: str, exc: BaseException) -> None:
|
||||
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
|
||||
_emit({
|
||||
"type": "error",
|
||||
"id": rid,
|
||||
"ename": type(exc).__name__,
|
||||
"evalue": str(exc),
|
||||
"traceback": [line.rstrip("\n") for line in tb_lines],
|
||||
})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main loop
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sys.stdout = _StreamProxy("stdout")
|
||||
sys.stderr = _StreamProxy("stderr")
|
||||
_install_idle_sigint()
|
||||
_start_parent_watchdog()
|
||||
|
||||
stdin = sys.__stdin__
|
||||
if stdin is None:
|
||||
return
|
||||
|
||||
for raw_line in stdin:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
_emit({
|
||||
"type": "error",
|
||||
"id": "",
|
||||
"ename": "ProtocolError",
|
||||
"evalue": f"Invalid JSON request: {exc}",
|
||||
"traceback": [],
|
||||
})
|
||||
continue
|
||||
try:
|
||||
_handle_request(req)
|
||||
except SystemExit:
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -160,19 +160,6 @@ export function resolveVenvPath(cwd: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the windowless Python executable (pythonw.exe) on Windows.
|
||||
* Falls back to the regular Python path if pythonw.exe is not available.
|
||||
*/
|
||||
function resolveWindowlessPython(pythonPath: string): string {
|
||||
if (process.platform !== "win32") return pythonPath;
|
||||
const pythonwPath = pythonPath.replace(/python\.exe$/i, "pythonw.exe");
|
||||
if (pythonwPath !== pythonPath && fs.existsSync(pythonwPath)) {
|
||||
return pythonwPath;
|
||||
}
|
||||
return pythonPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Python runtime including executable path, environment, and venv detection.
|
||||
*/
|
||||
@@ -189,7 +176,7 @@ export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
|
||||
const currentPath = env[pathKey];
|
||||
env[pathKey] = currentPath ? `${binDir}${path.delimiter}${currentPath}` : binDir;
|
||||
return {
|
||||
pythonPath: resolveWindowlessPython(pythonCandidate),
|
||||
pythonPath: pythonCandidate,
|
||||
env,
|
||||
venvPath,
|
||||
};
|
||||
@@ -205,7 +192,7 @@ export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
|
||||
process.platform === "win32" ? path.join(managed.venvPath, "Scripts") : path.join(managed.venvPath, "bin");
|
||||
env[pathKey] = currentPath ? `${managedBin}${path.delimiter}${currentPath}` : managedBin;
|
||||
return {
|
||||
pythonPath: resolveWindowlessPython(managed.pythonPath),
|
||||
pythonPath: managed.pythonPath,
|
||||
env,
|
||||
venvPath: managed.venvPath,
|
||||
};
|
||||
@@ -216,7 +203,7 @@ export function resolvePythonRuntime(cwd: string, baseEnv: Record<string, string
|
||||
throw new Error("Python executable not found on PATH");
|
||||
}
|
||||
return {
|
||||
pythonPath: resolveWindowlessPython(pythonPath),
|
||||
pythonPath,
|
||||
env,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user