@adobe/aio-lib-sandbox 0.1.0-alpha.4 → 0.1.0-alpha.6

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.
package/src/ws.js CHANGED
@@ -12,6 +12,7 @@ governing permissions and limitations under the License.
12
12
  const WebSocket = require('ws')
13
13
  const {
14
14
  SandboxClientError,
15
+ SandboxCommandNotFoundError,
15
16
  SandboxUnauthorizedError,
16
17
  SandboxWebSocketError
17
18
  } = require('./errors')
@@ -36,11 +37,27 @@ class SandboxSocket {
36
37
 
37
38
  this.socket = null
38
39
  this.connectPromise = null
39
-
40
- /** @type {Map<string, {resolve: Function, reject: Function, stdout: string, stderr: string, onOutput: Function|undefined, timeout: any}>} */
40
+ this.intentionalClose = false
41
+
42
+ /**
43
+ * Pending exec entries
44
+ *
45
+ * @type {Map<string, {
46
+ * resolve: Function, reject: Function,
47
+ * waitResolve: Function|null, waitReject: Function|null,
48
+ * stdout: string, stderr: string,
49
+ * onOutput: Function|undefined, timeout: any,
50
+ * detached: boolean, resolved: boolean
51
+ * }>}
52
+ */
41
53
  this.pendingExecs = new Map()
42
54
  /** @type {Map<string, {resolve: Function, reject: Function}>} */
43
55
  this.pendingFileOps = new Map()
56
+ /**
57
+ * Pending exec.get operations keyed by execId.
58
+ * @type {Map<string, {resolve: Function, reject: Function, onOutput: Function|undefined, sandbox: object}>}
59
+ */
60
+ this.pendingGetOps = new Map()
44
61
  }
45
62
 
46
63
  /**
@@ -84,6 +101,10 @@ class SandboxSocket {
84
101
  const onClose = (code) => {
85
102
  cleanup()
86
103
  this.connectPromise = null
104
+ if (this.intentionalClose) {
105
+ resolve()
106
+ return
107
+ }
87
108
  reject(this.createCloseError(code))
88
109
  }
89
110
 
@@ -129,10 +150,28 @@ class SandboxSocket {
129
150
  this.socket.send(JSON.stringify(frame))
130
151
  }
131
152
 
153
+ /**
154
+ * Marks the next socket close as expected by the caller.
155
+ */
156
+ beginIntentionalClose () {
157
+ this.intentionalClose = true
158
+ }
159
+
160
+ /**
161
+ * Clears a previously requested intentional close.
162
+ */
163
+ cancelIntentionalClose () {
164
+ this.intentionalClose = false
165
+ }
166
+
132
167
  /**
133
168
  * Closes the underlying socket.
169
+ *
170
+ * @param {object} [options]
171
+ * @param {boolean} [options.intentional] whether pending work should be drained without error
134
172
  */
135
- close () {
173
+ close ({ intentional = false } = {}) {
174
+ if (intentional) this.beginIntentionalClose()
136
175
  this.socket?.close()
137
176
  }
138
177
 
@@ -140,6 +179,60 @@ class SandboxSocket {
140
179
  // Pending operation helpers
141
180
  // ------------------------------------------------------------------
142
181
 
182
+ /**
183
+ * Registers a pending exec, sends the frame, and returns the promises
184
+ * that will be settled by subsequent result or ack frames (for detached)
185
+ *
186
+ * @param {string} execId
187
+ * @param {object} frame the frame to send (must include `type` and `execId`)
188
+ * @param {{ detached: boolean, onOutput?: Function }} options
189
+ * @returns {{ ackPromise: Promise, waitPromise: Promise|null }}
190
+ */
191
+ sendExec (execId, frame, { detached, onOutput }) {
192
+ let waitResolve, waitReject, waitPromise
193
+ if (detached) {
194
+ waitPromise = new Promise((res, rej) => { waitResolve = res; waitReject = rej })
195
+ }
196
+
197
+ let resolve, reject
198
+ const ackPromise = new Promise((res, rej) => { resolve = res; reject = rej })
199
+
200
+ this.pendingExecs.set(execId, {
201
+ resolve,
202
+ reject,
203
+ waitResolve: waitResolve || null,
204
+ waitReject: waitReject || null,
205
+ _waitPromise: waitPromise || null,
206
+ stdout: '',
207
+ stderr: '',
208
+ onOutput: onOutput || null,
209
+ timeout: undefined,
210
+ detached,
211
+ resolved: false
212
+ })
213
+
214
+ try {
215
+ this.send(frame)
216
+ } catch (error) {
217
+ this.rejectExec(execId, new SandboxWebSocketError(
218
+ `Could not send exec frame: ${error.message}`
219
+ ))
220
+ }
221
+
222
+ return { ackPromise, waitPromise: waitPromise || null }
223
+ }
224
+
225
+ /**
226
+ * Stores a timer handle on a pending exec entry so it can be cleared on completion.
227
+ *
228
+ * @param {string} execId
229
+ * @param {ReturnType<setTimeout>} handle
230
+ */
231
+ setExecTimeout (execId, handle) {
232
+ const entry = this.pendingExecs.get(execId)
233
+ if (entry) entry.timeout = handle
234
+ }
235
+
143
236
  /**
144
237
  * Rejects and removes a pending exec, clearing its timeout.
145
238
  *
@@ -151,7 +244,13 @@ class SandboxSocket {
151
244
  if (!pending) return
152
245
  this.pendingExecs.delete(execId)
153
246
  clearTimeout(pending.timeout)
154
- pending.reject(error)
247
+
248
+ // For detached execs, the first promise is always resolved, so reject the wait promise instead
249
+ if (pending.detached && pending.resolved) {
250
+ if (pending.waitReject) pending.waitReject(error)
251
+ } else {
252
+ pending.reject(error)
253
+ }
155
254
  }
156
255
 
157
256
  /**
@@ -167,10 +266,45 @@ class SandboxSocket {
167
266
  pending.reject(error)
168
267
  }
169
268
 
269
+ /**
270
+ * Resolves and removes a pending exec during an intentional sandbox shutdown.
271
+ *
272
+ * @param {string} execId
273
+ */
274
+ resolveExecOnIntentionalClose (execId) {
275
+ const pending = this.pendingExecs.get(execId)
276
+ if (!pending) return
277
+ this.pendingExecs.delete(execId)
278
+ clearTimeout(pending.timeout)
279
+
280
+ const result = { exitCode: null, destroyed: true }
281
+ if (pending.detached) {
282
+ if (!pending.resolved) {
283
+ pending.resolved = true
284
+ pending.resolve({ pid: undefined, startedAt: undefined, destroyed: true })
285
+ }
286
+ if (pending.waitResolve) pending.waitResolve(result)
287
+ return
288
+ }
289
+
290
+ pending.resolve({
291
+ execId,
292
+ stdout: pending.stdout,
293
+ stderr: pending.stderr,
294
+ ...result
295
+ })
296
+ }
297
+
170
298
  handleMessage (message) {
171
299
  const frame = this.parseFrame(message)
172
300
  if (!frame || this.isAuthAckFrame(frame)) return
173
301
 
302
+ if (frame.type === 'exec.info' ||
303
+ (frame.type === 'error' && this.pendingGetOps.has(frame.execId))) {
304
+ this.handleGetFrame(frame)
305
+ return
306
+ }
307
+
174
308
  if (this.pendingFileOps.has(frame.execId)) {
175
309
  this.handleFileFrame(frame)
176
310
  return
@@ -182,6 +316,24 @@ class SandboxSocket {
182
316
  }
183
317
 
184
318
  handleClose (code) {
319
+ if (this.intentionalClose) {
320
+ for (const execId of [...this.pendingExecs.keys()]) {
321
+ this.resolveExecOnIntentionalClose(execId)
322
+ }
323
+ for (const [, pending] of [...this.pendingFileOps.entries()]) {
324
+ pending.resolve(undefined)
325
+ }
326
+ for (const [, pending] of [...this.pendingGetOps.entries()]) {
327
+ pending.resolve(null)
328
+ }
329
+ this.pendingFileOps.clear()
330
+ this.pendingGetOps.clear()
331
+ this.connectPromise = null
332
+ this.socket = null
333
+ this.intentionalClose = false
334
+ return
335
+ }
336
+
185
337
  const error = this.createCloseError(code)
186
338
  for (const execId of [...this.pendingExecs.keys()]) {
187
339
  this.rejectExec(execId, error)
@@ -189,6 +341,10 @@ class SandboxSocket {
189
341
  for (const execId of [...this.pendingFileOps.keys()]) {
190
342
  this.rejectFileOp(execId, error)
191
343
  }
344
+ for (const [, pending] of [...this.pendingGetOps.entries()]) {
345
+ pending.reject(error)
346
+ }
347
+ this.pendingGetOps.clear()
192
348
  this.connectPromise = null
193
349
  this.socket = null
194
350
  }
@@ -236,15 +392,32 @@ class SandboxSocket {
236
392
  return
237
393
  }
238
394
 
395
+ // For detached cmds, we will resolve with the server response, which is all the command info needed for a
396
+ // handle. The entry stays in pendingExecs to receive subsequent output and exec.exit.
397
+ if (frame.type === 'exec.detached') {
398
+ clearTimeout(pending.timeout)
399
+ pending.timeout = undefined
400
+ pending.resolved = true
401
+ pending.resolve({ pid: frame.pid, startedAt: frame.startedAt })
402
+ return
403
+ }
404
+
239
405
  if (frame.type === 'exec.exit') {
240
406
  this.pendingExecs.delete(frame.execId)
241
407
  clearTimeout(pending.timeout)
242
- pending.resolve({
243
- execId: frame.execId,
244
- stdout: pending.stdout,
245
- stderr: pending.stderr,
246
- exitCode: frame.exitCode
247
- })
408
+ if (pending.detached && pending.resolved) {
409
+ // For detached, the initial ack promise already resolved, so we resolve the wait promise
410
+ if (pending.waitResolve) {
411
+ pending.waitResolve({ exitCode: frame.exitCode })
412
+ }
413
+ } else {
414
+ pending.resolve({
415
+ execId: frame.execId,
416
+ stdout: pending.stdout,
417
+ stderr: pending.stderr,
418
+ exitCode: frame.exitCode
419
+ })
420
+ }
248
421
  return
249
422
  }
250
423
 
@@ -292,6 +465,133 @@ class SandboxSocket {
292
465
  ))
293
466
  }
294
467
  }
468
+
469
+ /**
470
+ * Handles exec.info (response to exec.get) and error frames routed from handleMessage.
471
+ *
472
+ * @param {object} frame
473
+ */
474
+ handleGetFrame (frame) {
475
+ const pending = this.pendingGetOps.get(frame.execId)
476
+ if (!pending) return
477
+
478
+ if (frame.type === 'exec.info') {
479
+ this.resolveGetOp(frame, pending)
480
+ return
481
+ }
482
+
483
+ if (frame.type === 'error') {
484
+ this.rejectGetOp(frame, pending)
485
+ }
486
+ }
487
+
488
+ /**
489
+ * Resolves a pending exec.get by building a command handle and resolving the caller's promise.
490
+ *
491
+ * @param {object} frame exec.info frame
492
+ * @param {object} pending entry from pendingGetOps
493
+ */
494
+ resolveGetOp (frame, pending) {
495
+ this.pendingGetOps.delete(frame.execId)
496
+ const waitPromise = this.resolveExecEntry(frame, pending)
497
+ const commandObj = this.buildCommandObject(frame, waitPromise, pending.sandbox)
498
+ pending.resolve(commandObj)
499
+ }
500
+
501
+ /**
502
+ * Rejects a pending exec.get with a not-found error.
503
+ *
504
+ * @param {object} frame error frame
505
+ * @param {object} pending entry from pendingGetOps
506
+ */
507
+ rejectGetOp (frame, pending) {
508
+ this.pendingGetOps.delete(frame.execId)
509
+ pending.reject(new SandboxCommandNotFoundError(
510
+ frame.message || `No running process for execId '${frame.execId}'`
511
+ ))
512
+ }
513
+
514
+ /**
515
+ * Returns the wait promise for the exec, either by reusing an existing pendingExecs entry
516
+ * (same session) or by registering a fresh reattached entry (new session / previous connection).
517
+ *
518
+ * @param {object} frame exec.info frame
519
+ * @param {object} pending entry from pendingGetOps
520
+ * @returns {Promise}
521
+ */
522
+ resolveExecEntry (frame, pending) {
523
+ const existingExec = this.pendingExecs.get(frame.execId)
524
+ if (existingExec) {
525
+ this.mergeOnOutputCallback(existingExec, pending.onOutput)
526
+ return existingExec._waitPromise
527
+ }
528
+ return this.registerReattachedExec(frame, pending.onOutput)
529
+ }
530
+
531
+ /**
532
+ * Appends `onOutput` to an existing exec entry's callback chain, preserving the previous handler.
533
+ *
534
+ * @param {object} existingExec entry from pendingExecs
535
+ * @param {Function|undefined} onOutput new callback to add
536
+ */
537
+ mergeOnOutputCallback (existingExec, onOutput) {
538
+ if (!onOutput) return
539
+ const prev = existingExec.onOutput
540
+ existingExec.onOutput = (data, stream) => {
541
+ if (prev) prev(data, stream)
542
+ onOutput(data, stream)
543
+ }
544
+ }
545
+
546
+ /**
547
+ * Creates a fresh pendingExecs entry for a process reattached from a previous connection,
548
+ * registers it, and returns its wait promise.
549
+ *
550
+ * @param {object} frame exec.info frame
551
+ * @param {Function|undefined} onOutput output callback
552
+ * @returns {Promise}
553
+ */
554
+ registerReattachedExec (frame, onOutput) {
555
+ let waitResolve, waitReject
556
+ const waitPromise = new Promise((res, rej) => { waitResolve = res; waitReject = rej })
557
+ this.pendingExecs.set(frame.execId, {
558
+ resolve: () => {},
559
+ reject: () => {},
560
+ waitResolve,
561
+ waitReject,
562
+ _waitPromise: waitPromise,
563
+ stdout: '',
564
+ stderr: '',
565
+ onOutput: onOutput || null,
566
+ timeout: undefined,
567
+ detached: frame.detached,
568
+ resolved: true
569
+ })
570
+ return waitPromise
571
+ }
572
+
573
+ /**
574
+ * Builds the command handle object returned to the caller of exec.get.
575
+ *
576
+ * @param {object} frame exec.info frame
577
+ * @param {Promise} waitPromise resolves when the process exits
578
+ * @param {object} sandbox Sandbox instance for delegating control operations
579
+ * @returns {object}
580
+ */
581
+ buildCommandObject (frame, waitPromise, sandbox) {
582
+ const { execId } = frame
583
+ return {
584
+ execId,
585
+ command: frame.command,
586
+ pid: frame.pid,
587
+ startedAt: frame.startedAt,
588
+ detached: frame.detached,
589
+ wait: () => waitPromise,
590
+ writeStdin: (data) => sandbox.writeStdin(execId, data),
591
+ closeStdin: () => sandbox.closeStdin(execId),
592
+ kill: (signal) => sandbox.kill(execId, signal)
593
+ }
594
+ }
295
595
  }
296
596
 
297
597
  module.exports = { SandboxSocket }