Skip to content
20 changes: 20 additions & 0 deletions packages/sandbox-container/src/handlers/port-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Port Handler
import type {
Logger,
PortCheckRequest,
PortCloseResult,
PortExposeResult,
PortListResult
Expand All @@ -25,6 +26,8 @@ export class PortHandler extends BaseHandler<Request, Response> {

if (pathname === '/api/expose-port') {
return await this.handleExpose(request, context);
} else if (pathname === '/api/port-check') {
return await this.handlePortCheck(request, context);
} else if (pathname === '/api/exposed-ports') {
return await this.handleList(request, context);
} else if (pathname.startsWith('/api/exposed-ports/')) {
Expand All @@ -51,6 +54,23 @@ export class PortHandler extends BaseHandler<Request, Response> {
);
}

private async handlePortCheck(
request: Request,
context: RequestContext
): Promise<Response> {
const body = await this.parseRequestBody<PortCheckRequest>(request);

const result = await this.portService.checkPortReady(body);

return new Response(JSON.stringify(result), {
status: 200,
headers: {
'Content-Type': 'application/json',
...context.corsHeaders
}
});
}

private async handleExpose(
request: Request,
context: RequestContext
Expand Down
7 changes: 7 additions & 0 deletions packages/sandbox-container/src/routes/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,13 @@ export function setupRoutes(router: Router, container: Container): void {
middleware: [container.get('loggingMiddleware')]
});

router.register({
method: 'POST',
path: '/api/port-check',
handler: async (req, ctx) => container.get('portHandler').handle(req, ctx),
middleware: [container.get('loggingMiddleware')]
});

router.register({
method: 'GET',
path: '/api/exposed-ports',
Expand Down
78 changes: 77 additions & 1 deletion packages/sandbox-container/src/services/port-service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Port Management Service

import type { Logger } from '@repo/shared';
import type { Logger, PortCheckRequest, PortCheckResponse } from '@repo/shared';
import type {
InvalidPortContext,
PortAlreadyExposedContext,
Expand Down Expand Up @@ -414,6 +414,82 @@ export class PortService {
}
}

/**
* Check if a port is ready to accept connections
* Supports both TCP and HTTP modes
*/
async checkPortReady(request: PortCheckRequest): Promise<PortCheckResponse> {
const {
port,
mode,
path = '/',
statusMin = 200,
statusMax = 399
} = request;

if (mode === 'tcp') {
return this.checkTcpReady(port);
} else {
return this.checkHttpReady(port, path, statusMin, statusMax);
}
}

private async checkTcpReady(port: number): Promise<PortCheckResponse> {
try {
const socket = await Bun.connect({
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing timeout on TCP connection

Bun.connect() has no timeout. If port is in weird state (e.g., SYN sent but no SYN-ACK response), this could hang indefinitely. The HTTP check has a 5s timeout (line 473), but TCP doesn't.

Add timeout via Promise.race or Bun API if available.

hostname: 'localhost',
port,
socket: {
data() {},
open(socket) {
socket.end();
},
error() {},
close() {}
}
});
// Connection succeeded
socket.end();
return { ready: true };
} catch (error) {
return {
ready: false,
error: error instanceof Error ? error.message : 'TCP connection failed'
};
}
}

private async checkHttpReady(
port: number,
path: string,
statusMin: number,
statusMax: number
): Promise<PortCheckResponse> {
try {
const url = `http://localhost:${port}${path.startsWith('/') ? path : `/${path}`}`;
const response = await fetch(url, {
method: 'GET',
signal: AbortSignal.timeout(5000) // 5 second timeout for individual check
});

const statusCode = response.status;
const ready = statusCode >= statusMin && statusCode <= statusMax;

return {
ready,
statusCode,
error: ready
? undefined
: `HTTP status ${statusCode} not in expected range ${statusMin}-${statusMax}`
};
} catch (error) {
return {
ready: false,
error: error instanceof Error ? error.message : 'HTTP request failed'
};
}
}

private startCleanupProcess(): void {
this.cleanupInterval = setInterval(
async () => {
Expand Down
29 changes: 28 additions & 1 deletion packages/sandbox/src/clients/port-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type {
PortCheckRequest,
PortCheckResponse,
PortCloseResult,
PortExposeResult,
PortListResult
Expand All @@ -7,7 +9,12 @@ import { BaseHttpClient } from './base-client';
import type { HttpClientOptions } from './types';

// Re-export for convenience
export type { PortExposeResult, PortCloseResult, PortListResult };
export type {
PortExposeResult,
PortCloseResult,
PortListResult,
PortCheckResponse
};

/**
* Request interface for exposing ports
Expand Down Expand Up @@ -102,4 +109,24 @@ export class PortClient extends BaseHttpClient {
throw error;
}
}

/**
* Check if a port is ready to accept connections
* @param request - Port check configuration
*/
async checkPortReady(request: PortCheckRequest): Promise<PortCheckResponse> {
try {
const response = await this.post<PortCheckResponse>(
'/api/port-check',
request
);
return response;
} catch (error) {
// On error (e.g., container not ready), return not ready
return {
ready: false,
error: error instanceof Error ? error.message : 'Port check failed'
};
}
}
}
54 changes: 54 additions & 0 deletions packages/sandbox/src/errors/classes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import type {
PortErrorContext,
PortNotExposedContext,
ProcessErrorContext,
ProcessExitedBeforeReadyContext,
ProcessNotFoundContext,
ProcessReadyTimeoutContext,
ValidationFailedContext
} from '@repo/shared/errors';

Expand Down Expand Up @@ -592,3 +594,55 @@ export class ValidationFailedError extends SandboxError<ValidationFailedContext>
return this.context.validationErrors;
}
}

// ============================================================================
// Process Readiness Errors
// ============================================================================

/**
* Error thrown when a process does not become ready within the timeout period
*/
export class ProcessReadyTimeoutError extends SandboxError<ProcessReadyTimeoutContext> {
constructor(errorResponse: ErrorResponse<ProcessReadyTimeoutContext>) {
super(errorResponse);
this.name = 'ProcessReadyTimeoutError';
}

// Type-safe accessors
get processId() {
return this.context.processId;
}
get command() {
return this.context.command;
}
get condition() {
return this.context.condition;
}
get timeout() {
return this.context.timeout;
}
}

/**
* Error thrown when a process exits before becoming ready
*/
export class ProcessExitedBeforeReadyError extends SandboxError<ProcessExitedBeforeReadyContext> {
constructor(errorResponse: ErrorResponse<ProcessExitedBeforeReadyContext>) {
super(errorResponse);
this.name = 'ProcessExitedBeforeReadyError';
}

// Type-safe accessors
get processId() {
return this.context.processId;
}
get command() {
return this.context.command;
}
get condition() {
return this.context.condition;
}
get exitCode() {
return this.context.exitCode;
}
}
5 changes: 5 additions & 0 deletions packages/sandbox/src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ export type {
PortErrorContext,
PortNotExposedContext,
ProcessErrorContext,
ProcessExitedBeforeReadyContext,
ProcessNotFoundContext,
ProcessReadyTimeoutContext,
ValidationFailedContext
} from '@repo/shared/errors';
// Re-export shared types and constants
Expand Down Expand Up @@ -100,8 +102,11 @@ export {
PortInUseError,
PortNotExposedError,
ProcessError,
// Process Readiness Errors
ProcessExitedBeforeReadyError,
// Process Errors
ProcessNotFoundError,
ProcessReadyTimeoutError,
SandboxError,
ServiceNotRespondingError,
// Validation Errors
Expand Down
10 changes: 9 additions & 1 deletion packages/sandbox/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ export type {
RunCodeOptions,
SandboxOptions,
SessionOptions,
StreamOptions
StreamOptions,
// Process readiness types
WaitForLogResult,
WaitForPortOptions
} from '@repo/shared';
// Export type guards for runtime validation
export { isExecResult, isProcess, isProcessStatus } from '@repo/shared';
Expand Down Expand Up @@ -96,6 +99,11 @@ export type {
ExecutionCallbacks,
InterpreterClient
} from './clients/interpreter-client.js';
// Export process readiness errors
export {
ProcessExitedBeforeReadyError,
ProcessReadyTimeoutError
} from './errors';
// Export file streaming utilities for binary file support
export { collectFile, streamFile } from './file-stream';
// Export interpreter functionality
Expand Down
Loading
Loading