@cloudflare/sandbox 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,10 @@
1
- import { type Kernel, KernelManager, ServerConnection } from "@jupyterlab/services";
2
- import type {
3
- IDisplayDataMsg,
4
- IErrorMsg,
5
- IExecuteResultMsg,
6
- IIOPubMessage,
7
- IStreamMsg
8
- } from "@jupyterlab/services/lib/kernel/messages";
9
- import {
10
- isDisplayDataMsg,
11
- isErrorMsg,
12
- isExecuteResultMsg,
13
- isStreamMsg
14
- } from "@jupyterlab/services/lib/kernel/messages";
1
+ import {
2
+ type Kernel,
3
+ KernelManager,
4
+ ServerConnection,
5
+ } from "@jupyterlab/services";
6
+ import type { IIOPubMessage } from "@jupyterlab/services/lib/kernel/messages";
15
7
  import { v4 as uuidv4 } from "uuid";
16
- import type { ExecutionResult } from "./mime-processor";
17
8
  import { processJupyterMessage } from "./mime-processor";
18
9
 
19
10
  export interface JupyterContext {
@@ -23,6 +14,8 @@ export interface JupyterContext {
23
14
  cwd: string;
24
15
  createdAt: Date;
25
16
  lastUsed: Date;
17
+ pooled?: boolean; // Track if this context is from the pool
18
+ inUse?: boolean; // Track if pooled context is currently in use
26
19
  }
27
20
 
28
21
  export interface CreateContextRequest {
@@ -38,10 +31,19 @@ export interface ExecuteCodeRequest {
38
31
  env_vars?: Record<string, string>;
39
32
  }
40
33
 
34
+ interface ContextPool {
35
+ available: JupyterContext[];
36
+ inUse: Set<string>; // context IDs currently in use
37
+ minSize: number;
38
+ maxSize: number;
39
+ warming: boolean; // Track if pool is currently being warmed
40
+ }
41
+
41
42
  export class JupyterServer {
42
43
  private kernelManager: KernelManager;
43
44
  private contexts = new Map<string, JupyterContext>();
44
45
  private defaultContexts = new Map<string, string>(); // language -> context_id
46
+ private contextPools = new Map<string, ContextPool>(); // language -> pool
45
47
 
46
48
  constructor() {
47
49
  // Configure connection to local Jupyter server
@@ -53,9 +55,9 @@ export class JupyterServer {
53
55
  appendToken: false,
54
56
  init: {
55
57
  headers: {
56
- 'Content-Type': 'application/json'
57
- }
58
- }
58
+ "Content-Type": "application/json",
59
+ },
60
+ },
59
61
  });
60
62
 
61
63
  this.kernelManager = new KernelManager({ serverSettings });
@@ -65,12 +67,172 @@ export class JupyterServer {
65
67
  await this.kernelManager.ready;
66
68
  console.log("[JupyterServer] Kernel manager initialized");
67
69
 
68
- // Create default Python context
69
- const pythonContext = await this.createContext({ language: "python" });
70
- this.defaultContexts.set("python", pythonContext.id);
70
+ // Don't create default context during initialization - use lazy loading instead
71
+
72
+ // Initialize pools for common languages (but don't warm them yet)
73
+ this.initializePool("python", 0, 3);
74
+ this.initializePool("javascript", 0, 2);
75
+ }
76
+
77
+ /**
78
+ * Initialize a context pool for a specific language
79
+ */
80
+ private initializePool(language: string, minSize = 0, maxSize = 5) {
81
+ const pool: ContextPool = {
82
+ available: [],
83
+ inUse: new Set(),
84
+ minSize,
85
+ maxSize,
86
+ warming: false,
87
+ };
88
+
89
+ this.contextPools.set(language, pool);
90
+
91
+ // Pre-warm contexts in background if minSize > 0
92
+ if (minSize > 0) {
93
+ setTimeout(() => this.warmPool(language, minSize), 0);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Enable pool warming for a language (called after Jupyter is ready)
99
+ */
100
+ async enablePoolWarming(language: string, minSize: number) {
101
+ const pool = this.contextPools.get(language);
102
+ if (!pool) {
103
+ this.initializePool(language, minSize, 3);
104
+ return;
105
+ }
106
+
107
+ // Update min size and warm if needed
108
+ pool.minSize = minSize;
109
+ const toWarm = minSize - pool.available.length;
110
+ if (toWarm > 0) {
111
+ await this.warmPool(language, toWarm);
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Pre-warm a pool with the specified number of contexts
117
+ */
118
+ private async warmPool(language: string, count: number) {
119
+ const pool = this.contextPools.get(language);
120
+ if (!pool || pool.warming) return;
121
+
122
+ pool.warming = true;
123
+ console.log(`[JupyterServer] Pre-warming ${count} ${language} contexts`);
124
+
125
+ try {
126
+ const promises: Promise<JupyterContext>[] = [];
127
+ for (let i = 0; i < count; i++) {
128
+ promises.push(this.createPooledContext(language));
129
+ }
130
+
131
+ const contexts = await Promise.all(promises);
132
+ pool.available.push(...contexts);
133
+ console.log(
134
+ `[JupyterServer] Pre-warmed ${contexts.length} ${language} contexts`
135
+ );
136
+ } catch (error) {
137
+ console.error(
138
+ `[JupyterServer] Error pre-warming ${language} pool:`,
139
+ error
140
+ );
141
+ } finally {
142
+ pool.warming = false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Create a context specifically for the pool
148
+ */
149
+ private async createPooledContext(language: string): Promise<JupyterContext> {
150
+ const context = await this.createContext({ language });
151
+ context.pooled = true;
152
+ context.inUse = false;
153
+ return context;
154
+ }
155
+
156
+ /**
157
+ * Get a context from the pool or create a new one
158
+ */
159
+ private async getPooledContext(
160
+ language: string,
161
+ cwd?: string,
162
+ envVars?: Record<string, string>
163
+ ): Promise<JupyterContext | null> {
164
+ const pool = this.contextPools.get(language);
165
+ if (!pool) return null;
166
+
167
+ // Find an available context in the pool
168
+ const availableContext = pool.available.find((ctx) => !ctx.inUse);
169
+
170
+ if (availableContext) {
171
+ // Mark as in use
172
+ availableContext.inUse = true;
173
+ pool.inUse.add(availableContext.id);
174
+
175
+ // Remove from available list
176
+ pool.available = pool.available.filter(
177
+ (ctx) => ctx.id !== availableContext.id
178
+ );
179
+
180
+ // Update context properties if needed
181
+ if (cwd && cwd !== availableContext.cwd) {
182
+ await this.changeWorkingDirectory(availableContext, cwd);
183
+ availableContext.cwd = cwd;
184
+ }
185
+
186
+ if (envVars) {
187
+ await this.setEnvironmentVariables(availableContext, envVars);
188
+ }
189
+
190
+ availableContext.lastUsed = new Date();
191
+ console.log(
192
+ `[JupyterServer] Reusing pooled ${language} context ${availableContext.id}`
193
+ );
194
+
195
+ // Warm another context in background if we're below minSize
196
+ if (pool.available.length < pool.minSize && !pool.warming) {
197
+ setTimeout(() => this.warmPool(language, 1), 0);
198
+ }
199
+
200
+ return availableContext;
201
+ }
202
+
203
+ // No available context, check if we can create a new one
204
+ if (pool.inUse.size < pool.maxSize) {
205
+ console.log(
206
+ `[JupyterServer] No pooled ${language} context available, creating new one`
207
+ );
208
+ return null; // Let the caller create a new context
209
+ }
210
+
211
+ // Pool is at max capacity
71
212
  console.log(
72
- "[JupyterServer] Default Python context created:",
73
- pythonContext.id
213
+ `[JupyterServer] ${language} context pool at max capacity (${pool.maxSize})`
214
+ );
215
+ return null;
216
+ }
217
+
218
+ /**
219
+ * Release a pooled context back to the pool
220
+ */
221
+ private releasePooledContext(context: JupyterContext) {
222
+ if (!context.pooled) return;
223
+
224
+ const pool = this.contextPools.get(context.language);
225
+ if (!pool) return;
226
+
227
+ // Mark as not in use
228
+ context.inUse = false;
229
+ pool.inUse.delete(context.id);
230
+
231
+ // Add back to available list
232
+ pool.available.push(context);
233
+
234
+ console.log(
235
+ `[JupyterServer] Released ${context.language} context ${context.id} back to pool`
74
236
  );
75
237
  }
76
238
 
@@ -78,6 +240,19 @@ export class JupyterServer {
78
240
  const language = req.language || "python";
79
241
  const cwd = req.cwd || "/workspace";
80
242
 
243
+ // Try to get a context from the pool first
244
+ const pooledContext = await this.getPooledContext(
245
+ language,
246
+ cwd,
247
+ req.envVars
248
+ );
249
+ if (pooledContext) {
250
+ // Add to active contexts map
251
+ this.contexts.set(pooledContext.id, pooledContext);
252
+ return pooledContext;
253
+ }
254
+
255
+ // Create a new context if pool didn't provide one
81
256
  const kernelModel = await this.kernelManager.startNew({
82
257
  name: this.getKernelName(language),
83
258
  });
@@ -108,6 +283,27 @@ export class JupyterServer {
108
283
  return context;
109
284
  }
110
285
 
286
+ private async getOrCreateDefaultContext(
287
+ language: string
288
+ ): Promise<JupyterContext | undefined> {
289
+ // Check if we already have a default context for this language
290
+ const defaultContextId = this.defaultContexts.get(language);
291
+ if (defaultContextId) {
292
+ const context = this.contexts.get(defaultContextId);
293
+ if (context) {
294
+ return context;
295
+ }
296
+ }
297
+
298
+ // Create new default context lazily
299
+ console.log(
300
+ `[JupyterServer] Creating default ${language} context on first use`
301
+ );
302
+ const context = await this.createContext({ language });
303
+ this.defaultContexts.set(language, context.id);
304
+ return context;
305
+ }
306
+
111
307
  async executeCode(
112
308
  contextId: string | undefined,
113
309
  code: string,
@@ -126,24 +322,10 @@ export class JupyterServer {
126
322
  }
127
323
  );
128
324
  }
129
- } else if (language) {
130
- // Use default context for the language
131
- const defaultContextId = this.defaultContexts.get(language);
132
- if (defaultContextId) {
133
- context = this.contexts.get(defaultContextId);
134
- }
135
-
136
- // Create new default context if needed
137
- if (!context) {
138
- context = await this.createContext({ language });
139
- this.defaultContexts.set(language, context.id);
140
- }
141
325
  } else {
142
- // Use default Python context
143
- const pythonContextId = this.defaultContexts.get("python");
144
- context = pythonContextId
145
- ? this.contexts.get(pythonContextId)
146
- : undefined;
326
+ // Use or create default context for the language
327
+ const lang = language || "python";
328
+ context = await this.getOrCreateDefaultContext(lang);
147
329
  }
148
330
 
149
331
  if (!context) {
@@ -304,10 +486,7 @@ export class JupyterServer {
304
486
  throw new Error(`Context ${contextId} not found`);
305
487
  }
306
488
 
307
- // Shutdown the kernel
308
- await context.connection.shutdown();
309
-
310
- // Remove from maps
489
+ // Remove from active contexts map
311
490
  this.contexts.delete(contextId);
312
491
 
313
492
  // Remove from default contexts if it was a default
@@ -317,10 +496,18 @@ export class JupyterServer {
317
496
  break;
318
497
  }
319
498
  }
499
+
500
+ // If it's a pooled context, release it back to the pool
501
+ if (context.pooled) {
502
+ this.releasePooledContext(context);
503
+ } else {
504
+ // Only shutdown non-pooled contexts
505
+ await context.connection.shutdown();
506
+ }
320
507
  }
321
508
 
322
509
  async shutdown() {
323
- // Shutdown all kernels
510
+ // Shutdown all active contexts
324
511
  for (const context of this.contexts.values()) {
325
512
  try {
326
513
  await context.connection.shutdown();
@@ -329,8 +516,64 @@ export class JupyterServer {
329
516
  }
330
517
  }
331
518
 
519
+ // Shutdown all pooled contexts
520
+ for (const pool of this.contextPools.values()) {
521
+ for (const context of pool.available) {
522
+ try {
523
+ await context.connection.shutdown();
524
+ } catch (error) {
525
+ console.error(
526
+ "[JupyterServer] Error shutting down pooled kernel:",
527
+ error
528
+ );
529
+ }
530
+ }
531
+ }
532
+
332
533
  this.contexts.clear();
333
534
  this.defaultContexts.clear();
535
+ this.contextPools.clear();
334
536
  }
335
- }
336
537
 
538
+ /**
539
+ * Get pool statistics for monitoring
540
+ */
541
+ async getPoolStats(): Promise<
542
+ Record<
543
+ string,
544
+ {
545
+ available: number;
546
+ inUse: number;
547
+ total: number;
548
+ minSize: number;
549
+ maxSize: number;
550
+ warming: boolean;
551
+ }
552
+ >
553
+ > {
554
+ const stats: Record<
555
+ string,
556
+ {
557
+ available: number;
558
+ inUse: number;
559
+ total: number;
560
+ minSize: number;
561
+ maxSize: number;
562
+ warming: boolean;
563
+ }
564
+ > = {};
565
+
566
+ for (const [language, pool] of this.contextPools.entries()) {
567
+ stats[language] = {
568
+ available: pool.available.length,
569
+ inUse: pool.inUse.size,
570
+ total: pool.available.length + pool.inUse.size,
571
+ minSize: pool.minSize,
572
+ maxSize: pool.maxSize,
573
+ warming: pool.warming,
574
+ };
575
+ }
576
+
577
+ return stats;
578
+ }
579
+ }