Skip to content

Commit 8cf6b2f

Browse files
process readiness (#273)
* Introduced a new `/server` endpoint to start a server and return its preview URL; added `waitFor` method to processes, allowing them to wait for specific conditions (log patterns or port availability) * minor fix * claude had things to say * always wait for port * fix for the tests * use tcp instead * got rid of a few patterns, added a few new patterns * small nits on top * some directional changes * more nits and picks * add changeset The `Process` object now includes methods to detect readiness based on port and log patterns, enhancing process management. * Remove internal detail to avoid confusions --------- Co-authored-by: Naresh <[email protected]>
1 parent 1522475 commit 8cf6b2f

File tree

18 files changed

+2079
-6
lines changed

18 files changed

+2079
-6
lines changed

.changeset/mighty-squids-count.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@cloudflare/sandbox': patch
3+
---
4+
5+
Add process readiness detection with port and log pattern waiting
6+
The `Process` object returned by `startProcess()` now includes readiness methods:
7+
8+
- `process.waitForPort(port, options?)`: Wait for process to listen on a port
9+
- Supports HTTP mode (default): checks endpoint returns expected status (200-399)
10+
- Supports TCP mode: checks port accepts connections
11+
- Configurable timeout, interval, path, and expected status
12+
13+
- `process.waitForLog(pattern, options?)`: Wait for pattern in process output
14+
- Supports string or RegExp patterns
15+
- Returns matching line and capture groups

packages/sandbox-container/src/handlers/port-handler.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Port Handler
22
import type {
33
Logger,
4+
PortCheckRequest,
45
PortCloseResult,
56
PortExposeResult,
67
PortListResult
@@ -25,6 +26,8 @@ export class PortHandler extends BaseHandler<Request, Response> {
2526

2627
if (pathname === '/api/expose-port') {
2728
return await this.handleExpose(request, context);
29+
} else if (pathname === '/api/port-check') {
30+
return await this.handlePortCheck(request, context);
2831
} else if (pathname === '/api/exposed-ports') {
2932
return await this.handleList(request, context);
3033
} else if (pathname.startsWith('/api/exposed-ports/')) {
@@ -51,6 +54,23 @@ export class PortHandler extends BaseHandler<Request, Response> {
5154
);
5255
}
5356

57+
private async handlePortCheck(
58+
request: Request,
59+
context: RequestContext
60+
): Promise<Response> {
61+
const body = await this.parseRequestBody<PortCheckRequest>(request);
62+
63+
const result = await this.portService.checkPortReady(body);
64+
65+
return new Response(JSON.stringify(result), {
66+
status: 200,
67+
headers: {
68+
'Content-Type': 'application/json',
69+
...context.corsHeaders
70+
}
71+
});
72+
}
73+
5474
private async handleExpose(
5575
request: Request,
5676
context: RequestContext

packages/sandbox-container/src/routes/setup.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ export function setupRoutes(router: Router, container: Container): void {
118118
middleware: [container.get('loggingMiddleware')]
119119
});
120120

121+
router.register({
122+
method: 'POST',
123+
path: '/api/port-check',
124+
handler: async (req, ctx) => container.get('portHandler').handle(req, ctx),
125+
middleware: [container.get('loggingMiddleware')]
126+
});
127+
121128
router.register({
122129
method: 'GET',
123130
path: '/api/exposed-ports',

packages/sandbox-container/src/services/port-service.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Port Management Service
22

3-
import type { Logger } from '@repo/shared';
3+
import type { Logger, PortCheckRequest, PortCheckResponse } from '@repo/shared';
44
import type {
55
InvalidPortContext,
66
PortAlreadyExposedContext,
@@ -414,6 +414,93 @@ export class PortService {
414414
}
415415
}
416416

417+
/**
418+
* Check if a port is ready to accept connections
419+
* Supports both TCP and HTTP modes
420+
*/
421+
async checkPortReady(request: PortCheckRequest): Promise<PortCheckResponse> {
422+
const {
423+
port,
424+
mode,
425+
path = '/',
426+
statusMin = 200,
427+
statusMax = 399
428+
} = request;
429+
430+
if (mode === 'tcp') {
431+
return this.checkTcpReady(port);
432+
} else {
433+
return this.checkHttpReady(port, path, statusMin, statusMax);
434+
}
435+
}
436+
437+
private async checkTcpReady(port: number): Promise<PortCheckResponse> {
438+
const TCP_TIMEOUT_MS = 5000; // 5 second timeout matching HTTP check
439+
440+
try {
441+
const timeoutPromise = new Promise<never>((_, reject) => {
442+
setTimeout(
443+
() => reject(new Error('TCP connection timeout')),
444+
TCP_TIMEOUT_MS
445+
);
446+
});
447+
448+
const connectPromise = Bun.connect({
449+
hostname: 'localhost',
450+
port,
451+
socket: {
452+
data() {},
453+
open(socket) {
454+
socket.end();
455+
},
456+
error() {},
457+
close() {}
458+
}
459+
});
460+
461+
const socket = await Promise.race([connectPromise, timeoutPromise]);
462+
// Connection succeeded
463+
socket.end();
464+
return { ready: true };
465+
} catch (error) {
466+
return {
467+
ready: false,
468+
error: error instanceof Error ? error.message : 'TCP connection failed'
469+
};
470+
}
471+
}
472+
473+
private async checkHttpReady(
474+
port: number,
475+
path: string,
476+
statusMin: number,
477+
statusMax: number
478+
): Promise<PortCheckResponse> {
479+
try {
480+
const url = `http://localhost:${port}${path.startsWith('/') ? path : `/${path}`}`;
481+
const response = await fetch(url, {
482+
method: 'GET',
483+
signal: AbortSignal.timeout(5000) // 5 second timeout for individual check
484+
});
485+
486+
const statusCode = response.status;
487+
const ready = statusCode >= statusMin && statusCode <= statusMax;
488+
489+
return {
490+
ready,
491+
statusCode,
492+
error: ready
493+
? undefined
494+
: `HTTP status ${statusCode} not in expected range ${statusMin}-${statusMax}`
495+
};
496+
} catch (error) {
497+
return {
498+
ready: false,
499+
error: error instanceof Error ? error.message : 'HTTP request failed'
500+
};
501+
}
502+
}
503+
417504
private startCleanupProcess(): void {
418505
this.cleanupInterval = setInterval(
419506
async () => {

packages/sandbox/src/clients/port-client.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type {
2+
PortCheckRequest,
3+
PortCheckResponse,
24
PortCloseResult,
35
PortExposeResult,
46
PortListResult
@@ -7,7 +9,12 @@ import { BaseHttpClient } from './base-client';
79
import type { HttpClientOptions } from './types';
810

911
// Re-export for convenience
10-
export type { PortExposeResult, PortCloseResult, PortListResult };
12+
export type {
13+
PortExposeResult,
14+
PortCloseResult,
15+
PortListResult,
16+
PortCheckResponse
17+
};
1118

1219
/**
1320
* Request interface for exposing ports
@@ -102,4 +109,24 @@ export class PortClient extends BaseHttpClient {
102109
throw error;
103110
}
104111
}
112+
113+
/**
114+
* Check if a port is ready to accept connections
115+
* @param request - Port check configuration
116+
*/
117+
async checkPortReady(request: PortCheckRequest): Promise<PortCheckResponse> {
118+
try {
119+
const response = await this.post<PortCheckResponse>(
120+
'/api/port-check',
121+
request
122+
);
123+
return response;
124+
} catch (error) {
125+
// On error (e.g., container not ready), return not ready
126+
return {
127+
ready: false,
128+
error: error instanceof Error ? error.message : 'Port check failed'
129+
};
130+
}
131+
}
105132
}

packages/sandbox/src/errors/classes.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import type {
2525
PortErrorContext,
2626
PortNotExposedContext,
2727
ProcessErrorContext,
28+
ProcessExitedBeforeReadyContext,
2829
ProcessNotFoundContext,
30+
ProcessReadyTimeoutContext,
2931
ValidationFailedContext
3032
} from '@repo/shared/errors';
3133

@@ -592,3 +594,55 @@ export class ValidationFailedError extends SandboxError<ValidationFailedContext>
592594
return this.context.validationErrors;
593595
}
594596
}
597+
598+
// ============================================================================
599+
// Process Readiness Errors
600+
// ============================================================================
601+
602+
/**
603+
* Error thrown when a process does not become ready within the timeout period
604+
*/
605+
export class ProcessReadyTimeoutError extends SandboxError<ProcessReadyTimeoutContext> {
606+
constructor(errorResponse: ErrorResponse<ProcessReadyTimeoutContext>) {
607+
super(errorResponse);
608+
this.name = 'ProcessReadyTimeoutError';
609+
}
610+
611+
// Type-safe accessors
612+
get processId() {
613+
return this.context.processId;
614+
}
615+
get command() {
616+
return this.context.command;
617+
}
618+
get condition() {
619+
return this.context.condition;
620+
}
621+
get timeout() {
622+
return this.context.timeout;
623+
}
624+
}
625+
626+
/**
627+
* Error thrown when a process exits before becoming ready
628+
*/
629+
export class ProcessExitedBeforeReadyError extends SandboxError<ProcessExitedBeforeReadyContext> {
630+
constructor(errorResponse: ErrorResponse<ProcessExitedBeforeReadyContext>) {
631+
super(errorResponse);
632+
this.name = 'ProcessExitedBeforeReadyError';
633+
}
634+
635+
// Type-safe accessors
636+
get processId() {
637+
return this.context.processId;
638+
}
639+
get command() {
640+
return this.context.command;
641+
}
642+
get condition() {
643+
return this.context.condition;
644+
}
645+
get exitCode() {
646+
return this.context.exitCode;
647+
}
648+
}

packages/sandbox/src/errors/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,9 @@ export type {
6161
PortErrorContext,
6262
PortNotExposedContext,
6363
ProcessErrorContext,
64+
ProcessExitedBeforeReadyContext,
6465
ProcessNotFoundContext,
66+
ProcessReadyTimeoutContext,
6567
ValidationFailedContext
6668
} from '@repo/shared/errors';
6769
// Re-export shared types and constants
@@ -100,8 +102,11 @@ export {
100102
PortInUseError,
101103
PortNotExposedError,
102104
ProcessError,
105+
// Process Readiness Errors
106+
ProcessExitedBeforeReadyError,
103107
// Process Errors
104108
ProcessNotFoundError,
109+
ProcessReadyTimeoutError,
105110
SandboxError,
106111
ServiceNotRespondingError,
107112
// Validation Errors

packages/sandbox/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,10 @@ export type {
4040
RunCodeOptions,
4141
SandboxOptions,
4242
SessionOptions,
43-
StreamOptions
43+
StreamOptions,
44+
// Process readiness types
45+
WaitForLogResult,
46+
WaitForPortOptions
4447
} from '@repo/shared';
4548
// Export type guards for runtime validation
4649
export { isExecResult, isProcess, isProcessStatus } from '@repo/shared';
@@ -96,6 +99,11 @@ export type {
9699
ExecutionCallbacks,
97100
InterpreterClient
98101
} from './clients/interpreter-client.js';
102+
// Export process readiness errors
103+
export {
104+
ProcessExitedBeforeReadyError,
105+
ProcessReadyTimeoutError
106+
} from './errors';
99107
// Export file streaming utilities for binary file support
100108
export { collectFile, streamFile } from './file-stream';
101109
// Export interpreter functionality

0 commit comments

Comments
 (0)