fix(api): lazy-load node-pty to prevent API crash on missing native binary #525

Merged
jason.woltje merged 2 commits from fix/api-node-pty-crash into main 2026-02-26 13:46:27 +00:00
3 changed files with 35 additions and 7 deletions
Showing only changes of commit d18cf44546 - Show all commits

View File

@@ -31,7 +31,11 @@ COPY packages/config/package.json ./packages/config/
COPY apps/api/package.json ./apps/api/
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
RUN pnpm install --frozen-lockfile
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
# scripts or fail to find prebuilt binaries for this Node.js version
RUN pnpm install --frozen-lockfile \
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
&& npx node-gyp rebuild 2>&1 || true
# ======================
# Builder stage

View File

@@ -46,7 +46,7 @@ describe("TerminalService", () => {
let service: TerminalService;
let mockSocket: Socket;
beforeEach(() => {
beforeEach(async () => {
vi.clearAllMocks();
// Reset mock implementations
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
@@ -54,6 +54,8 @@ describe("TerminalService", () => {
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
);
service = new TerminalService();
// Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock)
await service.onModuleInit();
mockSocket = createMockSocket();
});

View File

@@ -13,11 +13,16 @@
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
*/
import { Injectable, Logger } from "@nestjs/common";
import * as pty from "node-pty";
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import type { Socket } from "socket.io";
import { randomUUID } from "node:crypto";
// Lazy-loaded in onModuleInit via dynamic import() to prevent crash
// if the native binary is missing. node-pty requires a compiled .node
// binary which may not be available in all Docker environments.
type NodePtyModule = typeof import("node-pty");
let pty: NodePtyModule | null = null;
/** Maximum concurrent PTY sessions per workspace */
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
@@ -31,7 +36,7 @@ const DEFAULT_ROWS = 24;
export interface TerminalSession {
sessionId: string;
workspaceId: string;
pty: pty.IPty;
pty: import("node-pty").IPty;
name?: string;
createdAt: Date;
}
@@ -53,7 +58,7 @@ export interface SessionCreatedResult {
}
@Injectable()
export class TerminalService {
export class TerminalService implements OnModuleInit {
private readonly logger = new Logger(TerminalService.name);
/**
@@ -66,13 +71,30 @@ export class TerminalService {
*/
private readonly workspaceSessions = new Map<string, Set<string>>();
async onModuleInit(): Promise<void> {
if (!pty) {
try {
pty = await import("node-pty");
this.logger.log("node-pty loaded successfully — terminal sessions available");
} catch {
this.logger.warn(
"node-pty native module not available — terminal sessions will be disabled. " +
"Install build tools (python3, make, g++) and rebuild node-pty to enable."
);
}
}
}
/**
* Create a new PTY session for the given workspace and socket.
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
*
* @throws Error if workspace session limit is exceeded
* @throws Error if workspace session limit is exceeded or node-pty is unavailable
*/
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
if (!pty) {
throw new Error("Terminal sessions are unavailable: node-pty native module failed to load");
}
const { workspaceId, name, cwd, socketId } = options;
const cols = options.cols ?? DEFAULT_COLS;
const rows = options.rows ?? DEFAULT_ROWS;