@braedonsaunders/appkit-desk 0.7.0 → 0.8.0

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.
Files changed (46) hide show
  1. package/README.md +55 -0
  2. package/backend.d.ts +28 -7
  3. package/backend.d.ts.map +1 -1
  4. package/backend.js +18 -521
  5. package/backend.js.map +1 -1
  6. package/host.d.ts +4 -1
  7. package/host.d.ts.map +1 -1
  8. package/host.js +31 -16
  9. package/host.js.map +1 -1
  10. package/index.d.ts +2 -0
  11. package/index.d.ts.map +1 -1
  12. package/index.js +2 -0
  13. package/index.js.map +1 -1
  14. package/machine.d.ts +145 -0
  15. package/machine.d.ts.map +1 -0
  16. package/machine.js +518 -0
  17. package/machine.js.map +1 -0
  18. package/package.json +2 -2
  19. package/plan.d.ts +129 -31
  20. package/plan.d.ts.map +1 -1
  21. package/plan.js +165 -9
  22. package/plan.js.map +1 -1
  23. package/port-relay.d.ts +13 -0
  24. package/port-relay.d.ts.map +1 -0
  25. package/port-relay.js +56 -0
  26. package/port-relay.js.map +1 -0
  27. package/virtualization.d.ts +16 -0
  28. package/virtualization.d.ts.map +1 -0
  29. package/virtualization.js +148 -0
  30. package/virtualization.js.map +1 -0
  31. package/vz/Package.swift +23 -0
  32. package/vz/Sources/DeskNet/Addresses.swift +106 -0
  33. package/vz/Sources/DeskNet/DHCP.swift +111 -0
  34. package/vz/Sources/DeskNet/Fence.swift +57 -0
  35. package/vz/Sources/DeskNet/NetworkStack.swift +547 -0
  36. package/vz/Sources/DeskNet/Packets.swift +355 -0
  37. package/vz/Sources/DeskNet/Plumbing.swift +312 -0
  38. package/vz/Sources/DeskNet/Sockets.swift +221 -0
  39. package/vz/Sources/DeskNet/TCPFlow.swift +448 -0
  40. package/vz/Sources/appkit-desk-vz/Control.swift +152 -0
  41. package/vz/Sources/appkit-desk-vz/Helper.swift +505 -0
  42. package/vz/Sources/appkit-desk-vz/main.swift +38 -0
  43. package/vz/Tests/DeskNetTests/DeskNetTests.swift +426 -0
  44. package/vz/appkit-desk-vz.entitlements +8 -0
  45. package/vz/build.sh +23 -0
  46. package/vz/check/extract-boot.py +221 -0
package/backend.js CHANGED
@@ -1,21 +1,8 @@
1
- /**
2
- * The backend port and its default Cloud Hypervisor implementation.
3
- *
4
- * `DeskBackend` is the seam that keeps the package testable without a
5
- * hypervisor: the host asks a backend to boot a `DeskLaunchPlan` and gets a
6
- * `DeskMachine` — a request/response channel to the in-guest agent plus an
7
- * event subscription. CI and non-KVM developer machines inject an in-memory
8
- * fake; production uses `cloudHypervisorBackend`, whose process-spawning glue
9
- * is deliberately thin because everything that can be pure lives in the plan
10
- * builder and the protocol module.
11
- */
12
- import { spawn } from 'node:child_process';
13
1
  import { randomUUID } from 'node:crypto';
14
2
  import { request as httpRequest } from 'node:http';
15
- import { createConnection } from 'node:net';
16
- import { Buffer } from 'node:buffer';
3
+ import { GUEST_AGENT_VSOCK_PORT } from './protocol.js';
4
+ import { DEFAULT_REQUEST_TIMEOUT_MS, DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_CONNECT_RETRY_DELAY_MS, DEFAULT_HANDSHAKE_TIMEOUT_MS, DEFAULT_RECONNECT_WINDOW_MS, DEFAULT_RECONNECT_RETRY_DELAY_MS, DEFAULT_KILL_GRACE_MS, RECONNECT_BACKOFF_CEILING, connectWithRetry, createGuestMachine, defaultLauncher, defaultConnect, drainOutput, watchExit, terminate, runToCompletion, errorMessage, } from './machine.js';
17
5
  import { DeskError } from './plan.js';
18
- import { encodeFrame, FrameDecoder, GUEST_AGENT_VSOCK_PORT, parseHostBoundMessage, } from './protocol.js';
19
6
  /**
20
7
  * A request that was in flight when the guest connection dropped.
21
8
  *
@@ -30,21 +17,6 @@ import { encodeFrame, FrameDecoder, GUEST_AGENT_VSOCK_PORT, parseHostBoundMessag
30
17
  export class DeskRequestFateUnknownError extends DeskError {
31
18
  name = 'DeskRequestFateUnknownError';
32
19
  }
33
- const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
34
- const DEFAULT_CONNECT_TIMEOUT_MS = 20_000;
35
- const DEFAULT_CONNECT_RETRY_DELAY_MS = 100;
36
- const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5_000;
37
- const DEFAULT_RECONNECT_WINDOW_MS = 30_000;
38
- const DEFAULT_RECONNECT_RETRY_DELAY_MS = 250;
39
- /**
40
- * How far the reconnect delay is allowed to double. A guest agent that systemd
41
- * is restarting is not helped by being hammered four times a second for the
42
- * whole window, and an unbounded doubling would spend most of the window
43
- * asleep after two failures.
44
- */
45
- const RECONNECT_BACKOFF_CEILING = 8;
46
- const DEFAULT_KILL_GRACE_MS = 2_000;
47
- const HANDSHAKE_LINE_LIMIT = 128;
48
20
  export function createCloudHypervisorBackend(options = {}) {
49
21
  const launcher = options.launcher ?? defaultLauncher;
50
22
  const connect = options.connect ?? defaultConnect;
@@ -60,6 +32,8 @@ export function createCloudHypervisorBackend(options = {}) {
60
32
  const killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
61
33
  const guestAgentPort = options.guestAgentPort ?? GUEST_AGENT_VSOCK_PORT;
62
34
  async function boot(plan, hooks) {
35
+ if (plan.hypervisor !== 'cloud-hypervisor')
36
+ throw new DeskError('Cloud Hypervisor requires a cloud-hypervisor plan.');
63
37
  if (platform !== 'linux') {
64
38
  throw new DeskError(`The AppKit desk requires Linux; received ${platform}.`);
65
39
  }
@@ -109,9 +83,8 @@ export function createCloudHypervisorBackend(options = {}) {
109
83
  ? new DeskError(`${error.message}${detail}`)
110
84
  : new DeskError(`Could not reach the guest agent for desk ${plan.deskId}: ${errorMessage(error)}${detail}`);
111
85
  }
112
- return createMachine({
86
+ return createGuestMachine({
113
87
  deskId: plan.deskId,
114
- child,
115
88
  exit,
116
89
  stream,
117
90
  // The same path the first connection took, deliberately: a reconnect
@@ -134,9 +107,16 @@ export function createCloudHypervisorBackend(options = {}) {
134
107
  now,
135
108
  idFactory,
136
109
  requestTimeoutMs,
137
- killGraceMs,
138
- apiSocketPath: plan.api.socketPath,
139
- vmmApi,
110
+ stop: () => terminate(child, exit, killGraceMs),
111
+ park: async (snapshotDir) => {
112
+ try {
113
+ await vmmApi(plan.api.socketPath, '/api/v1/vm.pause');
114
+ await vmmApi(plan.api.socketPath, '/api/v1/vm.snapshot', { destination_url: `file://${snapshotDir}` });
115
+ }
116
+ finally {
117
+ await terminate(child, exit, killGraceMs);
118
+ }
119
+ },
140
120
  });
141
121
  }
142
122
  return { boot };
@@ -203,490 +183,7 @@ async function waitForVmm(call, socketPath, options) {
203
183
  }
204
184
  }
205
185
  /** The default backend: real Cloud Hypervisor over a real vsock socket. */
206
- export const cloudHypervisorBackend = createCloudHypervisorBackend();
207
- /**
208
- * The machine, and the small state machine that keeps it connected.
209
- *
210
- * A vsock channel is not a fact the host learns once at boot — the transport
211
- * can lie about being connected, and it can go away mid-lease for reasons that
212
- * have nothing to do with the desk being unusable: the guest agent restarts,
213
- * the socat bridge drops, a transient vsock error, the guest wedges for a
214
- * moment. Treating any of those as terminal stranded the desk for the rest of
215
- * its lease with a perfectly healthy guest behind it, answering every call
216
- * with "no longer connected". So a live connection is something this machine
217
- * RE-ESTABLISHES:
218
- *
219
- * live ──unexpected drop──▶ reconnecting ──confirmed guest──▶ live
220
- * │ │
221
- * │ └──window exhausted / VMM exited──▶ closed
222
- * └──shutdown(), VMM already exited, protocol violation───────────▶ closed
223
- *
224
- * `closed` is terminal. Nothing reconnects out of it, which is what makes
225
- * `shutdown()` and a dead VMM final rather than the start of a retry loop.
226
- */
227
- function createMachine(options) {
228
- const { deskId, child, exit, now, idFactory, requestTimeoutMs, killGraceMs, reconnectWindowMs } = options;
229
- const pending = new Map();
230
- // Listeners live on the machine rather than on the socket, so a reconnect
231
- // re-points the data path at the new stream and every subscriber keeps
232
- // working without re-registering.
233
- const listeners = new Set();
234
- const watchers = new Set();
235
- let stream = options.stream;
236
- let state = 'live';
237
- let reconnects = 0;
238
- let reconnecting = null;
239
- function failAll(error) {
240
- for (const entry of pending.values()) {
241
- clearTimeout(entry.timer);
242
- entry.reject(error);
243
- }
244
- pending.clear();
245
- }
246
- function announce(change) {
247
- for (const watcher of [...watchers])
248
- watcher(change);
249
- }
250
- function close(error, intentional) {
251
- if (state === 'closed')
252
- return;
253
- state = 'closed';
254
- failAll(error);
255
- stream.destroy();
256
- // A desk the consumer asked to stop is not news; one that stopped on its
257
- // own is the thing an operator needs to be told about.
258
- if (!intentional)
259
- announce({ state: 'lost', deskId, reason: error.message });
260
- }
261
- function attach(next) {
262
- stream = next;
263
- // Each connection decodes with its OWN decoder: a frame the drop cut in
264
- // half must never be completed by the first bytes of its replacement.
265
- const decoder = new FrameDecoder();
266
- const current = () => stream === next && state !== 'closed';
267
- const onData = (chunk) => {
268
- if (!current())
269
- return;
270
- let values;
271
- try {
272
- values = decoder.push(chunk);
273
- }
274
- catch (error) {
275
- close(new DeskError(`Guest agent protocol violation on desk ${deskId}: ${errorMessage(error)}`), false);
276
- return;
277
- }
278
- for (const value of values) {
279
- let message;
280
- try {
281
- message = parseHostBoundMessage(value);
282
- }
283
- catch (error) {
284
- close(new DeskError(`Guest agent sent a malformed message on desk ${deskId}: ${errorMessage(error)}`), false);
285
- return;
286
- }
287
- if ('id' in message) {
288
- const entry = pending.get(message.id);
289
- if (!entry)
290
- continue;
291
- pending.delete(message.id);
292
- clearTimeout(entry.timer);
293
- if (message.ok)
294
- entry.resolve(message.result);
295
- else
296
- entry.reject(new DeskError(`Guest agent error on desk ${deskId}: ${message.error}`));
297
- }
298
- else {
299
- for (const listener of [...listeners])
300
- listener(message);
301
- }
302
- }
303
- };
304
- next.on('data', onData);
305
- next.once('close', () => {
306
- drop(next, `the guest agent connection closed on desk ${deskId}`);
307
- });
308
- next.once('error', (error) => {
309
- drop(next, `the guest agent connection failed on desk ${deskId}: ${error.message}`);
310
- });
311
- }
312
- function drop(from, reason) {
313
- if (state !== 'live' || from !== stream)
314
- return;
315
- from.destroy();
316
- if (exit.done) {
317
- // A dead VMM is not a connection problem. There is no guest to get back
318
- // to, and retrying against it forever only delays the fresh boot the
319
- // desk actually needs.
320
- close(new DeskError(`Desk ${deskId} is no longer connected: the VMM exited (${reason}).`), false);
321
- return;
322
- }
323
- state = 'reconnecting';
324
- const lostAt = now();
325
- // In-flight requests CANNOT be silently retried. Each of their frames
326
- // already reached the guest, and whether the guest ran it before the
327
- // channel went away is not knowable from here: an `exec` may have sent
328
- // mail, a `click` may have landed. Replaying a side-effecting operation to
329
- // paper over a blip is the wrong kind of resilience, so every one of them
330
- // is told its fate is unknown and the caller decides.
331
- failAll(new DeskRequestFateUnknownError(`Desk ${deskId} lost its guest agent connection while this request was in flight `
332
- + `(${reason}); the guest may or may not have run it.`));
333
- reconnecting = restore(reason, lostAt);
334
- }
335
- /** Always resolves: callers await it to learn the outcome, not to be thrown at. */
336
- async function restore(reason, lostAt) {
337
- let next;
338
- try {
339
- next = await options.reconnect(() => state !== 'reconnecting');
340
- }
341
- catch (error) {
342
- close(new DeskError(`Desk ${deskId} lost its guest agent connection and could not get it back: ${errorMessage(error)}`), false);
343
- return;
344
- }
345
- if (state !== 'reconnecting') {
346
- // shutdown() won the race while the replacement socket was coming up.
347
- next.destroy();
348
- return;
349
- }
350
- attach(next);
351
- state = 'live';
352
- reconnects += 1;
353
- announce({ state: 'reconnected', deskId, reason, downtimeMs: now() - lostAt, reconnects });
354
- }
355
- attach(options.stream);
356
- return {
357
- deskId,
358
- async request(command, requestOptions = {}) {
359
- // A call that lands mid-reconnect waits for it rather than failing on
360
- // the spot: the guest is usually back within seconds, and telling a
361
- // caller a desk is gone when it is about to be fine is the bug this
362
- // machine exists to end. The wait is bounded by the same window the
363
- // reconnect itself is.
364
- const waitUntil = now() + reconnectWindowMs;
365
- while (state === 'reconnecting') {
366
- const attempt = reconnecting;
367
- if (!attempt || now() >= waitUntil)
368
- break;
369
- await attempt;
370
- }
371
- if (state !== 'live') {
372
- // This one never left the host, so — unlike a request that was in
373
- // flight when the channel dropped — repeating it is safe.
374
- throw new DeskError(`Desk ${deskId} is no longer connected.`);
375
- }
376
- const id = idFactory();
377
- const timeoutMs = requestOptions.timeoutMs ?? requestTimeoutMs;
378
- const target = stream;
379
- return await new Promise((resolvePromise, rejectPromise) => {
380
- const timer = setTimeout(() => {
381
- pending.delete(id);
382
- rejectPromise(new DeskError(`Guest request ${command.op} timed out on desk ${deskId}.`));
383
- }, timeoutMs);
384
- timer.unref();
385
- pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer });
386
- target.write(encodeFrame({ ...command, id }));
387
- });
388
- },
389
- subscribe(listener) {
390
- listeners.add(listener);
391
- return () => {
392
- listeners.delete(listener);
393
- };
394
- },
395
- onConnectionChange(listener) {
396
- watchers.add(listener);
397
- return () => {
398
- watchers.delete(listener);
399
- };
400
- },
401
- async shutdown() {
402
- // Terminal by construction: `closed` is the state the reconnect loop
403
- // checks, so an explicit shutdown can never be followed by a reconnect.
404
- close(new DeskError(`Desk ${deskId} is shutting down.`), true);
405
- await terminate(child, exit, killGraceMs);
406
- },
407
- async park(snapshotDir) {
408
- // Close the guest channel FIRST. Pausing a VM strands whatever was in
409
- // flight on the vsock, and a reconnect attempt against a paused guest
410
- // would burn the whole reconnect window before giving up — so the channel
411
- // is made terminal before the VM stops answering, exactly as shutdown
412
- // does it.
413
- close(new DeskError(`Desk ${deskId} is being parked.`), true);
414
- try {
415
- await options.vmmApi(options.apiSocketPath, '/api/v1/vm.pause');
416
- await options.vmmApi(options.apiSocketPath, '/api/v1/vm.snapshot', {
417
- // Three slashes: `file://` plus an absolute path. CH rejects a
418
- // destination_url it cannot parse, and the directory must already
419
- // exist — it writes the config and memory files into it.
420
- destination_url: `file://${snapshotDir}`,
421
- });
422
- }
423
- catch (error) {
424
- // The VM is paused and there is no usable snapshot, which is the one
425
- // state nothing downstream can interpret. Stop the VMM so the desk is
426
- // plainly off, and say why — the caller falls back to a cold boot, and
427
- // the disk is still consistent because the guest was synced before this.
428
- await terminate(child, exit, killGraceMs);
429
- throw error instanceof DeskError
430
- ? new DeskError(`Desk ${deskId} could not be parked with its memory: ${error.message}`)
431
- : new DeskError(`Desk ${deskId} could not be parked with its memory: ${errorMessage(error)}`);
432
- }
433
- await terminate(child, exit, killGraceMs);
434
- },
435
- };
436
- }
437
- /**
438
- * Read the VMM's stdout and stderr, and keep only the last few kilobytes.
439
- *
440
- * This is not for logging — it is load-bearing. The child is spawned with
441
- * both streams as PIPES, and a pipe nobody reads fills after about 64KB, at
442
- * which point the VMM BLOCKS on its next write. Cloud Hypervisor logs while
443
- * it runs, so an unread pipe freezes the guest partway through boot: the
444
- * process is alive, the disk is untouched, and the guest agent never reaches
445
- * vsock. Every symptom points at the image and none of them are its fault.
446
- *
447
- * Draining costs nothing and buys the tail of what the VMM said, which is the
448
- * difference between "the VMM exited" and knowing why.
449
- */
450
- function drainOutput(child, limit = 8 * 1024) {
451
- let buffered = '';
452
- const absorb = (chunk) => {
453
- buffered += String(chunk);
454
- if (buffered.length > limit)
455
- buffered = buffered.slice(buffered.length - limit);
456
- };
457
- child.stdout?.on('data', absorb);
458
- child.stderr?.on('data', absorb);
459
- // A pipe that errors must not take the process down with it.
460
- child.stdout?.on('error', () => undefined);
461
- child.stderr?.on('error', () => undefined);
462
- return { tail: () => buffered.trim() };
463
- }
464
- function watchExit(child) {
465
- const watcher = { done: false, promise: Promise.resolve() };
466
- watcher.promise = new Promise((resolvePromise) => {
467
- const settle = () => {
468
- watcher.done = true;
469
- resolvePromise();
470
- };
471
- child.once('exit', settle);
472
- child.once('error', settle);
473
- });
474
- return watcher;
475
- }
476
- async function terminate(child, exit, killGraceMs) {
477
- if (exit.done)
478
- return;
479
- child.kill('SIGTERM');
480
- const forceKill = setTimeout(() => {
481
- if (!exit.done)
482
- child.kill('SIGKILL');
483
- }, killGraceMs);
484
- forceKill.unref();
485
- await exit.promise;
486
- clearTimeout(forceKill);
487
- }
488
- function runToCompletion(child, label) {
489
- return new Promise((resolvePromise, rejectPromise) => {
490
- let stderr = '';
491
- child.stderr?.on('data', (chunk) => {
492
- stderr += String(chunk);
493
- });
494
- child.once('error', (error) => {
495
- rejectPromise(new DeskError(`${label} failed to start: ${error.message}`));
496
- });
497
- child.once('exit', (code, signal) => {
498
- if (code === 0) {
499
- resolvePromise();
500
- return;
501
- }
502
- const detail = stderr.trim();
503
- rejectPromise(new DeskError(`${label} exited with code ${code ?? 'unknown'}`
504
- + `${signal ? ` (${signal})` : ''}${detail ? `: ${detail}` : '.'}`));
505
- });
506
- });
507
- }
508
- /**
509
- * Cloud Hypervisor exposes guest vsock ports through a host Unix socket with
510
- * a line-oriented preamble: the client writes `CONNECT <port>\n` and the VMM
511
- * answers `OK <assigned>\n` before the byte stream becomes the guest
512
- * connection. The VMM creates the socket during boot, so connection attempts
513
- * retry until the deadline.
514
- *
515
- * The same loop serves the first connection and every later reconnect, so a
516
- * reconnect gets `confirmGuest` too — a mid-lease reconnect that trusted the
517
- * banner would re-adopt exactly the hollow stream the boot path learned not to.
518
- */
519
- async function connectWithRetry(options) {
520
- const deadline = options.now() + options.timeoutMs;
521
- let lastFailure = 'the guest agent never answered';
522
- let retryDelayMs = options.retryDelayMs;
523
- while (options.now() <= deadline) {
524
- if (options.exited.done) {
525
- throw new DeskError(options.exitedMessage ?? 'The VMM exited before the guest agent came up.');
526
- }
527
- if (options.abandoned?.() === true) {
528
- throw new DeskError('the connection attempt was abandoned.');
529
- }
530
- try {
531
- const stream = await attemptHandshake(options.connect, options.socketPath, options.port, options.attemptTimeoutMs);
532
- try {
533
- await confirmGuest(stream, options.attemptTimeoutMs);
534
- }
535
- catch (error) {
536
- stream.destroy();
537
- throw error;
538
- }
539
- return stream;
540
- }
541
- catch (error) {
542
- lastFailure = errorMessage(error);
543
- }
544
- await delay(retryDelayMs);
545
- if (options.maxRetryDelayMs !== undefined) {
546
- retryDelayMs = Math.min(retryDelayMs * 2, options.maxRetryDelayMs);
547
- }
548
- }
549
- throw new DeskError(`Timed out connecting to the guest agent at ${options.socketPath}: ${lastFailure}`);
550
- }
551
- /**
552
- * One handshake attempt, which MUST settle.
553
- *
554
- * Cloud Hypervisor accepts a connection on the vsock socket whether or not
555
- * anything in the guest is listening on the port, and when nothing is it may
556
- * answer nothing at all: no reply, no error, no close. Without a deadline of
557
- * its own this promise then never settles — and because the caller awaits it
558
- * inside the retry loop, the loop stops iterating, the overall deadline is
559
- * never re-checked, and the boot hangs forever rather than failing. That is
560
- * indistinguishable from a wedged host and it is why every attempt is bounded
561
- * here as well as in aggregate.
562
- */
563
- /**
564
- * Prove a guest is behind the handshake by asking it something.
565
- *
566
- * Cloud Hypervisor answers `CONNECT <port>` with `OK` whether or not anything
567
- * in the guest is listening on that port, and only closes the socket a moment
568
- * later when it finds nobody there. A caller that trusts the banner therefore
569
- * "connects" to a guest that is still booting, gets a closed stream seconds
570
- * later, and — because a closed machine is not retried — leaves the desk dead
571
- * for as long as it is leased, with a healthy guest sitting behind it. So the
572
- * handshake is not the test; a reply is.
573
- */
574
- async function confirmGuest(stream, timeoutMs) {
575
- const decoder = new FrameDecoder();
576
- await new Promise((resolvePromise, rejectPromise) => {
577
- let settled = false;
578
- const finish = (error) => {
579
- if (settled)
580
- return;
581
- settled = true;
582
- clearTimeout(timer);
583
- stream.off('data', onData);
584
- stream.off('close', onClose);
585
- stream.off('error', finish);
586
- if (error)
587
- rejectPromise(error);
588
- else
589
- resolvePromise();
590
- };
591
- const timer = setTimeout(() => finish(new DeskError('the guest agent did not answer the first request.')), timeoutMs);
592
- timer.unref?.();
593
- const onClose = () => finish(new DeskError('the connection closed before the guest agent answered.'));
594
- const onData = (chunk) => {
595
- let values;
596
- try {
597
- values = decoder.push(chunk);
598
- }
599
- catch (error) {
600
- finish(error instanceof Error ? error : new Error(String(error)));
601
- return;
602
- }
603
- for (const value of values) {
604
- // Anything at all proves a guest is there; the reply is parsed for
605
- // real by the machine once it owns the stream.
606
- if (value && typeof value === 'object') {
607
- finish();
608
- return;
609
- }
610
- }
611
- };
612
- stream.on('data', onData);
613
- stream.once('close', onClose);
614
- stream.once('error', finish);
615
- stream.write(encodeFrame({ id: 'hello', op: 'ping' }));
616
- });
617
- }
618
- function attemptHandshake(connect, socketPath, port, attemptTimeoutMs) {
619
- return new Promise((resolvePromise, rejectPromise) => {
620
- let stream;
621
- try {
622
- stream = connect(socketPath);
623
- }
624
- catch (error) {
625
- rejectPromise(error instanceof Error ? error : new Error(String(error)));
626
- return;
627
- }
628
- let banner = '';
629
- let settled = false;
630
- const timer = setTimeout(() => {
631
- fail(new DeskError('vsock handshake went unanswered.'));
632
- }, attemptTimeoutMs);
633
- // An unref'd timer must not hold the process open on its own.
634
- timer.unref?.();
635
- const fail = (error) => {
636
- if (settled)
637
- return;
638
- settled = true;
639
- clearTimeout(timer);
640
- stream.destroy();
641
- rejectPromise(error);
642
- };
643
- const onData = (chunk) => {
644
- banner += chunk.toString('latin1');
645
- const newline = banner.indexOf('\n');
646
- if (newline === -1) {
647
- if (banner.length > HANDSHAKE_LINE_LIMIT) {
648
- fail(new DeskError('vsock handshake reply exceeded the line limit.'));
649
- }
650
- return;
651
- }
652
- const line = banner.slice(0, newline);
653
- const rest = banner.slice(newline + 1);
654
- stream.off('data', onData);
655
- stream.off('error', fail);
656
- if (!line.startsWith('OK ')) {
657
- fail(new DeskError(`vsock handshake was refused: ${line}`));
658
- return;
659
- }
660
- settled = true;
661
- clearTimeout(timer);
662
- if (rest.length > 0)
663
- stream.unshift(Buffer.from(rest, 'latin1'));
664
- resolvePromise(stream);
665
- };
666
- stream.on('data', onData);
667
- stream.once('error', fail);
668
- // A close before the banner is a refusal, not a hang.
669
- stream.once('close', () => fail(new DeskError('vsock handshake closed before a reply.')));
670
- stream.write(`CONNECT ${port}\n`);
671
- });
672
- }
673
- function defaultLauncher(command, args, options) {
674
- return spawn(command, [...args], {
675
- stdio: ['ignore', 'pipe', 'pipe'],
676
- uid: options.identity?.uid,
677
- gid: options.identity?.gid,
678
- });
679
- }
680
- function defaultConnect(socketPath) {
681
- return createConnection(socketPath);
682
- }
683
- function delay(ms) {
684
- return new Promise((resolvePromise) => {
685
- const timer = setTimeout(resolvePromise, ms);
686
- timer.unref();
687
- });
688
- }
689
- function errorMessage(error) {
690
- return error instanceof Error ? error.message : String(error);
691
- }
186
+ export const cloudHypervisorBackend = {
187
+ boot: (plan, hooks) => createCloudHypervisorBackend().boot(plan, hooks),
188
+ };
692
189
  //# sourceMappingURL=backend.js.map